Verify webhook signatures

Verify HMAC-signed io.sign deliveries, prevent replay, and rotate subscription secrets

io.sign supports a hash-based message authentication code using SHA-256 (HMAC-SHA256) for webhook deliveries. When a deployment has a signing root key, every delivery carries a signature compatible with Standard Webhooks v1. Verify it with your subscription's secret before processing the event.

Prepare your receiver

For first-time setup, follow Set up webhook signing. It covers enabling signing on managed and self-hosted deployments, creating a subscription, and retrieving the secret used by the verifier below.

  1. Confirm with your io.sign operator that signing is active. Set your receiver to require signatures based on your own configuration. A deployment without a signing root key sends unsigned deliveries.
  2. Fetch your subscription's secret using the endpoint below and store it in your secret manager.
  3. Preserve the raw HTTP request body. Verify it before parsing JSON or performing any event action.
  4. Reject requests missing webhook-id, webhook-timestamp, or webhook-signature. Never treat a missing header as permission to skip verification.
  5. Check the timestamp and deduplicate verified deliveries as described below.

Signing adds headers without changing the body. Receivers that ignore the new headers continue to receive events, but do not gain signature verification.

Fetch the subscription secret

Call this endpoint on your deployment's webhooks service with a verified token:

GET /v1/webhooks/{subscriptionId}/secret
Authorization: Bearer <access-token>

The response has these fields and includes Cache-Control: no-store:

FieldMeaning
secretCurrent subscription secret, prefixed with whsec_. The suffix is a base64-encoded 32-byte key.
versionSubscription secret version; changes when you rotate the subscription secret.
rootKeyIdNon-secret identifier of the deployment root key used to derive the secret. Changes on root-key rotation.
previousSecretPrevious secret during a subscription rotation overlap; otherwise null.
previousExpiresAtExpiry of that overlap as a timestamp; otherwise null.

Both secret endpoints require the caller's identity and organisation to be proven by a verified token. Any authenticated member of the subscription's organisation can retrieve or rotate its secret. On managed stages, caller-supplied x-user-id and x-organisation-id headers cannot substitute for that proof.

Do not log secret responses or expose them to clients. Rotate the subscription secret when someone with access leaves: revoking their account does not invalidate a copy they already hold.

Signed headers and body

HeaderVerification role
webhook-idDelivery identifier. It stays the same across retries and matches x-webhook-delivery-id. Use it to deduplicate deliveries.
webhook-timestampUnix time in seconds, regenerated for each delivery attempt. Reject values more than 5 minutes before or after your clock.
webhook-signatureSpace-separated signature entries, each formatted as v1,<base64>. Accept if any entry matches any secret you currently accept.

The signed bytes are the UTF-8 encoding of webhook-id, a literal period, webhook-timestamp, another period, and then the exact raw body bytes:

<webhook-id>.<webhook-timestamp>.<raw body bytes>

Remove the whsec_ prefix from the secret and base64-decode the remainder before using it as the HMAC key. Compute HMAC-SHA256 over the signed bytes. Each v1, entry contains the base64-encoded result.

Do not verify a body produced by parsing and re-serialising JSON. Whitespace, key order, and every other byte must remain unchanged, including through proxies. Use a constant-time comparison for the computed and received signatures.

Node.js verification example

This example accepts Node.js request headers, a raw body Buffer, and the whsec_ secrets your receiver currently trusts. It returns false for missing headers, malformed timestamps, expired timestamps, or signatures that do not match.

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyWebhook(headers, rawBody, secrets) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatureHeader = headers['webhook-signature'];

  if (
    typeof id !== 'string' || !id ||
    typeof timestamp !== 'string' || !/^\d+$/.test(timestamp) ||
    typeof signatureHeader !== 'string' || !signatureHeader ||
    !Buffer.isBuffer(rawBody)
  ) {
    return false;
  }

  const seconds = Number(timestamp);
  const now = Math.floor(Date.now() / 1000);
  if (!Number.isSafeInteger(seconds) || Math.abs(now - seconds) > 5 * 60) {
    return false;
  }

  const expected = secrets.map((secret) => {
    const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
    return createHmac('sha256', key)
      .update(`${id}.${timestamp}.`, 'utf8')
      .update(rawBody)
      .digest();
  });

  return signatureHeader.split(' ').some((entry) => {
    // SHA-256 produces 32 bytes, represented by 44 base64 characters.
    if (!/^v1,[A-Za-z0-9+/]{43}=$/.test(entry)) return false;
    const received = Buffer.from(entry.slice(3), 'base64');
    return expected.some((digest) =>
      digest.length === received.length && timingSafeEqual(digest, received)
    );
  });
}
🚧

Preserve the raw body in Express

If you use Express, verify the signature before parsing the request body:

  1. Add express.raw({ type: 'application/json' }) to the webhook route before any JSON body parser.
  2. Pass req.headers, req.body, and your stored secrets to verifyWebhook.
  3. Reject the request if verification fails.
  4. If verification succeeds, parse the body with JSON.parse(req.body.toString('utf8')) and process the event.

Alternatively, use the verify callback of express.json to capture the original buffer for signature verification.

The function checks authenticity and timestamp freshness. Your handler must also deduplicate deliveries.

Prevent replay and duplicate processing

Delivery is at least once, so the same verified delivery can arrive more than once. Keep processed webhook-id values for at least 24 hours and ignore repeats. Use a durable store shared by your receiver instances, and make the duplicate check and event processing safe against concurrent attempts.

Timestamp validation alone does not stop replay within the 5-minute window. Retries also receive fresh timestamps while keeping the same delivery identifier. Perform signature verification before accepting an identifier into your deduplication store.

Rotate a subscription secret

Use a verified token to call:

POST /v1/webhooks/{subscriptionId}/secret/rotate
Authorization: Bearer <access-token>

The response has the same fields as secret retrieval, with the new secret and version, plus previousSecret and previousExpiresAt. It also includes Cache-Control: no-store.

  1. Add the new secret to the receiver's accepted secrets immediately.
  2. Keep accepting the previous secret until previousExpiresAt. Deliveries carry signatures for both valid secrets during the overlap, and a request signed just before rotation can still arrive afterwards.
  3. Remove the previous secret once the overlap expires.

Keep both secrets to avoid interrupting deliveries. If you miss the rotation response, fetch the secret again during the overlap to recover the same pair. The default subscription overlap is 24 hours; use the returned expiry rather than assuming that duration.

Secret endpoint errors

The specific error identifier appears in meta.errorCode in the io.sign error response.

HTTP statusmeta.errorCodeAction
403PROVEN_PRINCIPAL_REQUIREDSupply a verified token proving both identity and organisation.
404—Check the subscription identifier and organisation; the subscription may have been deleted.
409SIGNING_ROTATION_OVERLAP_ACTIVEWait until meta.previousExpiresAt before rotating again.
409SIGNING_NOT_CONFIGUREDAsk the operator to configure signing. This deployment has no root key and sends unsigned deliveries.

Deployment root-key rotation

A deployment root key derives every subscription secret. An operator rotation changes all of those secrets, even though each subscription's version stays the same. The rootKeyId changes instead.

When your operator announces a root-key rotation, fetch your subscription secret again and add the new value to your receiver. Keep the old value until the operator confirms completion, for at least seven days. During the overlap, deliveries carry signatures derived from both roots. This is separate from the subscription rotation governed by previousExpiresAt.

Webhook signing configuration

For self-hosted deployments, inject these variables into the webhooks service, which runs separately from the signing API. Store root keys in your secret manager: access to a root key affects every subscription secret in that deployment.

VariableBehaviour
WEBHOOKS_SIGNING_ROOT_KEYActivates signing when present. Use a high-entropy printable ASCII string of at least 44 characters. Without it, deliveries are unsigned and the service logs a startup warning.
WEBHOOKS_SIGNING_ROOT_KEY_PREVIOUSOutgoing root key during an operator rotation. Requires a current root key and must differ from it. Deliveries are signed using both roots.
WEBHOOKS_SIGNING_ENABLEDOptional startup assertion. Setting it to true without a current root key fails startup. It does not activate signing by itself.
WEBHOOKS_SIGNING_ROTATION_OVERLAP_HOURSSubscription secret overlap in hours; defaults to 24.

For an on-premise root rotation, move the outgoing root into WEBHOOKS_SIGNING_ROOT_KEY_PREVIOUS and set WEBHOOKS_SIGNING_ROOT_KEY to the new root. Coordinate the overlap with all consumers, and remove the previous root only after they have fetched their new secrets and the root-rotation overlap above is complete.

The upstream on-premise guide covers these signing settings, but does not yet document the webhooks service's image, migrations, or database deployment. Obtain those details from io.finnet before deploying the service.


Did this page help you?