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
| Header | Meaning |
|---|---|
X-Mixmax-Webhook-Id | A new UUID for every delivery attempt, including retries. Don't use it to detect duplicates. |
X-Mixmax-Timestamp | When this attempt was signed, in Unix seconds. |
X-Mixmax-Signature | v1, followed by the signature. |
Check a delivery
- Read the raw request body exactly as received, before parsing the JSON. Re-serializing parsed JSON changes the bytes and breaks the check.
- Join
X-Mixmax-Webhook-Id,X-Mixmax-Timestampand the raw body, with a.between each. - Remove
whsec_from the secret and base64-decode the rest. That is the HMAC key. - Compute the HMAC-SHA256 of the joined string with that key, and base64-encode it.
- Compare it with the part of
X-Mixmax-Signatureafterv1,, using a constant-time comparison. - Reject the delivery if
X-Mixmax-Timestampisn't a number or is more than 5 minutes from your clock. - To reject replays of a captured delivery within those 5 minutes, remember each accepted
X-Mixmax-Webhook-Idfor 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-000000000000X-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}.
