Webhook signing secrets should be rotated periodically as a security best practice, and immediately if you suspect a secret has been compromised. OpenFX provides a built-in rotation mechanism with a 24-hour grace period that allows both the old and new secrets to validate signatures simultaneously, enabling zero-downtime migration.

How rotation works

  1. You call POST /webhooks/subscriptions/{id}/rotate-secret.
  2. OpenFX generates a new signing secret and returns it in the response.
  3. For the next 24 hours, OpenFX signs deliveries with the new secret, but your endpoint can verify using either the old or the new secret.
  4. After 24 hours, the old secret is permanently invalidated.
  Time 0                    Time +24h
  |                         |
  | Rotate called           | Old secret expires
  |                         |
  |------- Grace period ----|
  |                         |
  | Both secrets valid      | Only new secret valid
  | Update your code here   |
During the 24-hour grace period, OpenFX signs webhook deliveries with the new secret. However, it will also accept verification using the old secret. This gives you time to deploy the new secret to your application without any dropped deliveries.

Rotating the secret

Call POST /webhooks/subscriptions/{id}/rotate-secret to generate a new signing secret.
curl -X POST https://sandbox.api.openfx.com/v1/webhooks/subscriptions/wsub_01953e1a5f4b700a/rotate-secret \
  -H "Authorization: Bearer sk_sandbox_your_api_key" \
  -H "X-Signature: <computed-signature>" \
  -H "X-Timestamp: 1740500000" \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440030"
The response includes the full webhook subscription object with the new signingSecret:
{
  "id": "wsub_01953e1a5f4b700a",
  "url": "https://your-app.example.com/webhooks/openfx",
  "eventTypes": ["payment.completed", "payment.failed"],
  "status": "active",
  "signingSecret": "whsec_new_secret_value...",
  "createdAt": "2026-02-23T12:00:00Z",
  "updatedAt": "2026-02-24T10:00:00Z"
}
The new signingSecret is only returned in this response. It cannot be retrieved again. Store it in your secrets manager immediately. If you lose both the old and new secrets, you must rotate again.

Migration procedure

Follow these steps for a zero-downtime rotation:

Step 1: Update your verification code to accept both secrets

Before rotating, modify your webhook handler to try verification with multiple secrets:
import hashlib
import hmac

def verify_webhook(raw_body, received_signature, secrets):
    """Try each secret until one matches. Return True if verified."""
    for secret in secrets:
        expected = hmac.new(
            secret.encode("utf-8"),
            raw_body,
            hashlib.sha256,
        ).hexdigest()

        if hmac.compare_digest(expected, received_signature):
            return True

    return False

# During rotation, provide both secrets
SECRETS = [
    "whsec_current_secret...",  # Current secret
    # New secret will be added here after rotation
]
import crypto from "crypto";

function verifyWebhook(rawBody, receivedSignature, secrets) {
  for (const secret of secrets) {
    const expected = crypto
      .createHmac("sha256", secret)
      .update(rawBody)
      .digest("hex");

    const expectedBuf = Buffer.from(expected, "utf-8");
    const receivedBuf = Buffer.from(receivedSignature, "utf-8");

    if (
      expectedBuf.length === receivedBuf.length &&
      crypto.timingSafeEqual(expectedBuf, receivedBuf)
    ) {
      return true;
    }
  }

  return false;
}

// During rotation, provide both secrets
const SECRETS = [
  "whsec_current_secret...", // Current secret
  // New secret will be added here after rotation
];

Step 2: Rotate the secret

Call the rotate endpoint as shown above. Store the new secret.

Step 3: Deploy the new secret

Add the new secret to your application’s secret list. Your verification code now tries both:
SECRETS = [
    "whsec_new_secret...",      # New secret (primary)
    "whsec_current_secret...",  # Old secret (grace period)
]

Step 4: Wait for the grace period to expire

After 24 hours, the old secret is no longer valid. Remove it from your configuration:
SECRETS = [
    "whsec_new_secret...",  # Now the only valid secret
]

Step 5: Simplify your verification code (optional)

Once you are back to a single secret, you can optionally simplify back to single-secret verification. However, keeping the multi-secret pattern makes future rotations smoother.
Your rotation is complete. The old secret is invalidated and only the new secret is in use. Repeat this process periodically (e.g., every 90 days) as a security best practice.

Rotation frequency recommendations

ScenarioRecommended frequency
Routine security hygieneEvery 90 days
Suspected compromiseImmediately
Employee offboardingWithin 24 hours of departure
Security incident responseImmediately
Compliance requirementPer your security policy

Important notes

  • Only one rotation at a time. Do not call rotate-secret again before the 24-hour grace period from the previous rotation has expired. The behavior of overlapping rotations is undefined.
  • Signing secret is write-only. It is returned only when the subscription is created and when the secret is rotated. There is no endpoint to retrieve an existing secret.
  • The old secret works for 24 hours. If you need to cut over faster, update your verification code before calling rotate. If you need to extend the window, do not — the 24-hour period is fixed.
  • Test after rotation. Use POST /webhooks/subscriptions/{id}/test to send a test delivery and verify that your updated handler works with the new secret.

Next steps