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
| Header | Value |
|---|---|
| X-QannasAPI-Signature | v1=<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-Timestamp | Unix seconds. Reject anything more than 300 seconds from your own clock — without that check a captured delivery can be replayed forever. |
| X-QannasAPI-Event-Id | A 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:
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)),
);
});
}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 FalseRetries
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):
| Event | Fires when |
|---|---|
| post.published | A scheduled post was published to a channel. |
| post.failed | A scheduled post failed to publish. |
| message.received | An inbound message landed in the inbox. |
| review.new | A new review appeared on a connected profile. |
| account.connected | A social account finished connecting. |
| account.disconnected | A social account was disconnected. |
| account.expiring | A connected account's credentials are about to expire. |
| template.status_changed | A 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.