Documentation topics
Developer docs

Verifying signatures

Check every webhook against its untouched body bytes before parsing JSON. Use the endpoint's signing secret, not the WhatsApp token or an agent API key.

Signing input and key

HMAC-SHA256(secret_utf8_bytes, timestamp_utf8_bytes + b"." + raw_body_bytes)
  • timestamp is the exact canonical decimal value of X-Unpoll-Timestamp: nonnegative Unix seconds with no sign or leading zeros, except 0 itself.
  • The separator is one ASCII period (.).
  • raw_body_bytes are the exact received HTTP body, without parsing, whitespace changes, Unicode escaping, or decompression/re-encoding.
  • The key is the whole secret string as UTF-8, including unpoll_whsec_ and the following 43 base64url characters. Do not strip the prefix or base64-decode the secret.
  • X-Unpoll-Signature is v1= followed by the HMAC's 64 lowercase hexadecimal characters (32 bytes).

Illustrative verification excerpts

These Rust, Python, Go, and Node.js excerpts show the HMAC operation. They are not complete runnable verifiers or HTTP receivers. They assume validated headers, a checked timestamp, a configured secret, and bounded raw body bytes. The byte-oriented examples assume the MAC has already been strictly decoded into signature_bytes or signatureBytes. The Python excerpt compares a canonical hex representation; it still requires all format, freshness, and length checks below.

Rust

let key = hmac::Key::new(hmac::HMAC_SHA256, secret.as_bytes());
let input = [timestamp.as_bytes(), b".", raw_body].concat();
hmac::verify(&key, &input, &signature_bytes)?;

Python

expected = hmac.new(
    secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
valid = hmac.compare_digest(expected, signature.removeprefix("v1="))

Go

mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
valid := hmac.Equal(mac.Sum(nil), signatureBytes)

Node.js

const expected = createHmac("sha256", secret)
  .update(`${timestamp}.`).update(rawBody).digest();
const valid = timingSafeEqual(expected, signatureBytes);
Illustrative signature check · excerpt

Do not use an excerpt by itself as a security boundary. Implement every check below around it.

Required checks

  1. Bound the body before collecting it. Configure a request-body limit in your receiving HTTP server before buffering bytes, and reject oversized requests. Keep the bytes untouched. Do not let automatic JSON middleware parse and reserialize them before verification.
  2. Reject malformed authentication headers. Require exactly one X-Unpoll-Timestamp and one X-Unpoll-Signature. Reject missing, duplicate, non-text, oversized, or malformed values rather than accepting a comma-joined or partially parsed value. Header names are case-insensitive.
  3. Validate the timestamp completely. Accept only canonical nonnegative decimal Unix seconds. Reject signs, whitespace, leading zeros, trailing characters, and integer overflow. Keep your server clock synchronized. A recommended replay tolerance is ±300 seconds (5 minutes), inclusive, measured against your current clock; reject older timestamps and timestamps further in the future.
  4. Validate the signature format before comparison. Require the exact v1= prefix and exactly 64 lowercase hexadecimal characters, with no extra data. Strictly decode the MAC into 32 bytes; do not rely on permissive decoders that silently truncate malformed input.
  5. Compute the expected HMAC over the timestamp and exact body bytes. Use the complete configured secret as the key. Do not use parsed or reconstructed JSON.
  6. Compare in constant time. Confirm the decoded MAC is exactly 32 bytes before calling a constant-time byte comparison or your cryptographic library's HMAC verifier. Do not use ordinary string or byte equality. In Node.js, check lengths before timingSafeEqual, which throws on different lengths. If using the Python hex excerpt, first enforce the canonical 64-character format and use hmac.compare_digest, never ==.
  7. Only after successful verification, parse and validate the event. Take the event ID from the authenticated body's id, not an ID header. The v1 MAC covers the timestamp and body, not X-Unpoll-Event-Id or X-Unpoll-Delivery-Id. Match the event header to the body if you use it; do not trust the delivery header as signed data.
  8. Atomically deduplicate and durably accept work. Use a unique constraint on the body's event id and commit the dedupe record together with the saved event or queued processing work. Do not perform an irreversible side effect and then separately mark the ID as handled. An already accepted duplicate should get 2xx without repeating the work.
  9. Acknowledge promptly and keep secrets out of logs. Return 2xx after durable acceptance, within the 10-second attempt budget. Never log the signing secret, provider token, API key, supplied signature, authentication headers, or full customer body. Verification failures should reveal no supplied secret or payload.

Timestamp tolerance limits replay age; it does not prevent duplicate processing inside the window. Automatic retries and manual redelivery keep the same event ID and body, so atomic deduplication is required even when the MAC and timestamp are valid.

Secret rotation

Rotating the signing secret in the dashboard replaces it immediately, including for newly prepared queued retries. There is no overlap period in which both secrets are active. A request already prepared or in flight can still use the prior configuration.

Coordinate the rotation with your receiver and update its configured secret right away. Save the new secret when it is shown; it cannot be retrieved later. Deliveries that fail verification during the change can be manually redelivered from the dashboard within the retention period. Their body and IDs stay the same, but a new attempt uses the current secret.

Webhook headers and envelope →