Verifying webhooks

Mixmax signs each webhook delivery with the webhook's signing secret, so you can check that it came from Mixmax and wasn't changed. The secret starts with whsec_. You get it when you create a webhook action with POST /v1/rules/{id}/actions, when you rotate it, or, for the call recording webhook, under Admin center > API keys.

The headers

HeaderMeaning
X-Mixmax-Webhook-IdA new UUID for every delivery attempt, including retries. Don't use it to detect duplicates.
X-Mixmax-TimestampWhen this attempt was signed, in Unix seconds.
X-Mixmax-Signaturev1, followed by the signature.

Check a delivery

  1. Read the raw request body exactly as received, before parsing the JSON. Re-serializing parsed JSON changes the bytes and breaks the check.
  2. Join X-Mixmax-Webhook-Id, X-Mixmax-Timestamp and the raw body, with a . between each.
  3. Remove whsec_ from the secret and base64-decode the rest. That is the HMAC key.
  4. Compute the HMAC-SHA256 of the joined string with that key, and base64-encode it.
  5. Compare it with the part of X-Mixmax-Signature after v1,, using a constant-time comparison.
  6. Reject the delivery if X-Mixmax-Timestamp isn't a number or is more than 5 minutes from your clock.
  7. To reject replays of a captured delivery within those 5 minutes, remember each accepted X-Mixmax-Webhook-Id for at least 5 minutes and reject repeats. This is separate from handling Mixmax's own retries, which arrive with new ids: make your processing safe to run twice for those.

In Node.js:

const crypto = require('crypto');

// rawBody: the request body as a Buffer, before JSON parsing.
function verifyMixmaxSignature({ rawBody, headers, secret }) {
  const id = headers['x-mixmax-webhook-id'];
  const timestamp = headers['x-mixmax-timestamp'];
  const signature = headers['x-mixmax-signature'] || '';
  if (!id || !timestamp || !signature.startsWith('v1,')) return false;
  const timestampSeconds = Number(timestamp);
  if (!Number.isFinite(timestampSeconds)) return false;
  if (Math.abs(Date.now() / 1000 - timestampSeconds) > 5 * 60) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();
  const received = Buffer.from(signature.slice(3), 'base64');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

In Python:

import base64, hashlib, hmac, time

def verify_mixmax_signature(raw_body: bytes, headers, secret: str) -> bool:
    webhook_id = headers.get('X-Mixmax-Webhook-Id')
    timestamp = headers.get('X-Mixmax-Timestamp')
    signature = headers.get('X-Mixmax-Signature', '')
    if not webhook_id or not timestamp or not signature.startswith('v1,'):
        return False
    try:
        timestamp_seconds = int(timestamp)
    except (TypeError, ValueError):
        return False
    if abs(time.time() - timestamp_seconds) > 5 * 60:
        return False
    key = base64.b64decode(secret.removeprefix('whsec_'))
    signed = webhook_id.encode() + b'.' + timestamp.encode() + b'.' + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return hmac.compare_digest(expected, signature[3:])

Test your code

Use these placeholder inputs (not a real secret) and skip the timestamp check. They must produce the signature v1,97yqJFN2Tx0WlGgkygjuwQaZHhjsIy1E+eP6ejjShHk=.

  • Secret: whsec_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
  • X-Mixmax-Webhook-Id: 00000000-0000-4000-8000-000000000000
  • X-Mixmax-Timestamp: 1791468381
  • Raw body, on one line with no trailing newline:
{"eventName":"call:recording-available","eventId":"65f000000000000000000001:recording-available","callId":"65f000000000000000000001","userId":"65f000000000000000000002","workspaceId":"65f000000000000000000003","recordingStartedAt":"2026-10-08T14:03:11.000Z","recordingDurationSeconds":184,"occurredAt":"2026-10-08T14:06:20.412Z","recordingUrl":"https://api.mixmax.com/v1/calls/65f000000000000000000001/recording"}

Rotating the secret

After a rotation, the next delivery is signed with the new secret. Update your receiver right away; until you do, deliveries fail verification. Rotate a rule's webhook secret with PATCH /v1/rules/{id}/actions/{actionId} and {"rotateSigningSecret": true}.

Psst... like working with APIs? Come join us!