Webhooks & signatures

Every delivery is HMAC-SHA256 signed with your endpoint's secret. Verification takes twenty lines — copy them below, don't improvise them.

Headers on every delivery

HeaderValue
X-QannasAPI-Signaturev1=<hex> — HMAC-SHA256 of "{timestamp}." + rawBody under the endpoint's current secret. During a rotation overlap a second ,v2=<hex> made with the previous secret is appended, so a consumer holding either secret keeps accepting deliveries.
X-QannasAPI-TimestampUnix seconds. Reject anything more than 300 seconds from your own clock — without that check a captured delivery can be replayed forever.
X-QannasAPI-Event-IdA ULID that is stable across retries. Use it as your idempotency key: a delivery you have already processed will arrive again after any 5xx or timeout on your side.

Verify the signature

The two most common mistakes are signing the parsed-and-reserialised JSON (byte-for-byte equality is required — mount a raw body parser) and ignoring the {timestamp}. prefix. The reference verifiers get both right:

Node.js
import crypto from 'node:crypto';

// Mount with express.raw({ type: 'application/json' }): the signature covers the
// exact bytes we sent, so re-serialising a parsed body will not match.
export function verify(rawBody, headers, secrets) {
  const timestamp = Number(headers['x-qannasapi-timestamp']);
  if (!Number.isFinite(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false; // replay window

  const presented = String(headers['x-qannasapi-signature'])
    .split(',')
    .map((part) => part.trim().split('=')[1])
    .filter(Boolean);

  // Accept a match against ANY held secret — that is what makes rotation
  // seamless while both the old and new secret are in play.
  return secrets.some((secret) => {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.`)
      .update(rawBody)
      .digest('hex');
    return presented.some(
      (digest) =>
        digest.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(expected)),
    );
  });
}
Python
import hashlib
import hmac
import time

def verify(raw_body: bytes, headers: dict, secrets: list[str]) -> bool:
    try:
        timestamp = int(headers["x-qannasapi-timestamp"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > 300:  # replay window
        return False

    presented = [
        part.strip().split("=", 1)[1]
        for part in headers.get("x-qannasapi-signature", "").split(",")
        if "=" in part
    ]
    signed = str(timestamp).encode() + b"." + raw_body

    # Accept a match against ANY held secret (rotation overlap).
    for secret in secrets:
        expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(digest, expected) for digest in presented):
            return True
    return False

Retries

A delivery that does not get a 2xx is retried up to seven times with exponential backoff — roughly 30s, 2m, 8m, 32m, 2h, 8h — and is then marked dead. Dead deliveries stay in the log and can be redriven by hand from the portal (or POST /v1/webhooks/deliveries/{id}/redrive) once your endpoint is healthy again. Answer quickly and do your work asynchronously: a slow 200 costs you the same as a 500.

Rotation

Rotating a secret (portal, or POST /v1/webhooks/{id}/rotate) mints a new secret and keeps the outgoing one alive for 24 hours, during which every delivery carries both signatures (v1= new, v2= old). Deploy the new secret alongside the old one, verify against both, then drop the old one before the overlap ends. The new secret is shown exactly once — store it immediately.

Events

Subscribe to specific events or everything with ["*"] (the default):

EventFires when
post.publishedA scheduled post was published to a channel.
post.failedA scheduled post failed to publish.
message.receivedAn inbound message landed in the inbox.
review.newA new review appeared on a connected profile.
account.connectedA social account finished connecting.
account.disconnectedA social account was disconnected.
account.expiringA connected account's credentials are about to expire.
template.status_changedA WhatsApp template's approval status changed.

webhook.test exists too, but only fires on demand (POST /v1/webhooks/{id}/test) and is never fanned out. Production endpoints must use HTTPS.