Webhooks
How Zernio webhook deliveries work, including retries, idempotency, and signature verification.
How to think about webhooks
- Subscribe only to the events you actually handle.
- Treat each delivery as an event notification, not a full source of truth sync.
- Use the webhook event ID as your deduplication key.
- Verify the
X-Zernio-Signatureheader when you configure a webhook secret. - Expect fast acknowledgement from your endpoint and move heavier processing to async jobs.
Delivery flow
- Create a webhook endpoint with Create webhook settings.
- Choose the events you want to subscribe to.
- Receive a
POSTrequest from Zernio whenever one of those events occurs. - Return a
2xxresponse after you have accepted the payload. - Use Test webhook and Webhook logs to validate your integration.
Delivery retries
A delivery is considered successful when your endpoint returns a 2xx response within 5 seconds. Any other outcome (non-2xx status, request timeout, connection error) triggers a retry on an exponential backoff schedule capped at 24 hours.
Up to 7 attempts are made per event. The full schedule, measured from the moment the previous attempt finished:
| Attempt | Delay before this attempt | Cumulative time since the first attempt |
|---|---|---|
| 1 | immediate | 0 |
| 2 | 10s | ~10s |
| 3 | 1m 40s | ~1m 50s |
| 4 | 16m 40s | ~18m 30s |
| 5 | 2h 46m 40s | ~3h 5m |
| 6 | 24h (capped) | ~27h 5m |
| 7 | 24h (capped) | ~51h 5m |
After the 7th attempt fails the event is moved to a dead-letter queue and is no longer retried automatically. Failures are visible via Webhook logs (attemptNumber records which try produced each log entry). Webhooks are never auto-disabled based on failure count, you can pause or remove them from your webhook settings.
Keep your handler fast. Acknowledge the request as soon as you have persisted the event, then process it on a background worker. Long-running handlers risk hitting the 5-second timeout and triggering an unnecessary retry.
Idempotency
Webhook deliveries use at-least-once semantics: the same event may arrive more than once if a previous attempt's response was lost or your endpoint took too long to acknowledge. Your handler must therefore be idempotent.
Every payload carries a stable event identifier that is also exposed as a header:
payload.id, the canonical event ID (UUID).X-Zernio-Event-Id, the same value, repeated as a header for convenience.X-Late-Event-Id, legacy alias of the above, kept for backward compatibility.
Use this identifier as your deduplication key. A typical pattern is to insert the event ID into a unique-indexed table or cache before processing the payload, and skip processing when the insert conflicts.
Signature verification
If the webhook has a secret configured, every delivery includes an X-Zernio-Signature header. The signature is the lowercase hex HMAC-SHA256 of the raw request body keyed by your webhook secret.
X-Zernio-Signature, the signature.X-Late-Signature, legacy alias of the above, kept for backward compatibility.
Read the raw body, compute the HMAC, and compare it to the header value:
import crypto from "crypto";
export const POST = async (req: Request) => {
const webhookSignature = req.headers.get("X-Zernio-Signature");
if (!webhookSignature) {
return new Response("No signature provided.", { status: 401 });
}
const secret = process.env.ZERNIO_WEBHOOK_SECRET;
if (!secret) {
return new Response("No secret provided.", { status: 401 });
}
const rawBody = await req.text();
const computedSignature = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
if (webhookSignature !== computedSignature) {
return new Response("Invalid signature", { status: 400 });
}
const payload = JSON.parse(rawBody);
// Handle the webhook event
// ...
};import hashlib
import hmac
import json
import os
from fastapi import FastAPI, Request, Response
app = FastAPI()
@app.post("/webhooks/zernio")
async def handle_webhook(request: Request):
signature = request.headers.get("X-Zernio-Signature")
if not signature:
return Response("No signature provided.", status_code=401)
secret = os.environ.get("ZERNIO_WEBHOOK_SECRET")
if not secret:
return Response("No secret provided.", status_code=401)
raw_body = await request.body()
computed = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(computed, signature):
return Response("Invalid signature", status_code=400)
payload = json.loads(raw_body)
# Handle the webhook event
return Response(status_code=200)Reject unsigned or mismatched requests. A failed signature check means the request did not originate from Zernio (or the body was tampered with in transit). Do not process the payload.
Available events
Events are grouped by area. Each page documents the full payload schema for every event it covers.
Post webhooks
Publishing lifecycle, per-platform results, and native (external) posts
Inbox webhooks
Messages, conversations, reactions, comments, and reviews
Account webhooks
Social account connections and disconnections
Ads webhooks
Ad syncs, lead form submissions, and ad status changes
Call webhooks
Call lifecycle on phone (PSTN) and WhatsApp numbers
WhatsApp webhooks
Template review status and CTWA lead/purchase detection
Phone number webhooks
Number provisioning: KYC, activation, suspension, release
Post webhooks
| Event | Description |
|---|---|
post.published | Fired when a post is successfully published. |
post.failed | Fired when a post fails to publish on all target platforms. |
post.partial | Fired when a post publishes on some platforms and fails on others. |
post.cancelled | Fired when a post publishing job is cancelled. |
post.scheduled | Fired whenever a post enters the scheduled state: created with a schedule, added to a queue, promoted from draft, or retried after failing. |
post.recycled | Fired when a post is recycled for republishing. |
post.platform.published | Fired once per platform target inside a post as that platform finishes publishing, without waiting for the other platforms. |
post.platform.failed | Fired once per platform target inside a post when that platform fails permanently. |
post.platform.deleted | Fired when a published platform target is detected as deleted on the platform. |
post.tiktok.url_resolved | Fired when a published TikTok post's public URL becomes available. |
post.external.created | Fired when a post authored natively on the platform (outside Zernio) is detected for the first time. |
post.external.updated | Fired when a tracked native post's text or media changes on the platform. |
post.external.deleted | Fired when a tracked native post is detected as removed from the platform. |
Inbox webhooks
| Event | Description |
|---|---|
message.received | Fired when a new inbox message is received. |
message.sent | Fired when an outgoing message is sent from the inbox. |
conversation.started | Fired once when a new conversation begins between an account and a contact, on any DM platform. |
message.edited | Fired when a sender edits a previously-sent message. |
message.deleted | Fired when a sender deletes (unsends) a message. |
message.delivered | Fired when an outgoing message is delivered to the recipient. |
message.read | Fired when an outgoing message is read by the recipient. |
message.failed | Fired when an outgoing message fails to deliver (WhatsApp only). |
reaction.received | Fired when a participant adds or removes an emoji reaction (WhatsApp, Telegram). |
comment.received | Fired when a new comment is received on a tracked post. |
review.new | Fired when a new review is posted on a connected account. |
review.updated | Fired when a review is edited or a reply is added. |
Account webhooks
| Event | Description |
|---|---|
account.connected | Fired when a social account is successfully connected. |
account.disconnected | Fired when a connected social account becomes disconnected. |
Ads webhooks
| Event | Description |
|---|---|
account.ads.initial_sync_completed | Fired once per ads-enabled account when the initial 90-day backfill completes. |
lead.received | Fired when a new lead is submitted against a Meta Lead Gen form. |
ad.status_changed | Fired when an ad, ad set, or campaign changes status on the ad platform (Meta only). |
Call webhooks
| Event | Description |
|---|---|
call.received | Fired when a call is set up: an inbound call (phone/PSTN or WhatsApp) reaching one of your numbers, or an outbound WhatsApp call placed via the API. |
call.ended | Fired when a call (phone/PSTN or WhatsApp) ends; carries duration, end reason, and the cost breakdown. |
call.failed | Fired when a call (phone/PSTN or WhatsApp) fails with a hard error before or during bridging. |
call.permission_request | Fired when a WhatsApp user accepts or rejects your call-permission request. |
WhatsApp webhooks
| Event | Description |
|---|---|
whatsapp.template.status_updated | Fired when Meta finishes (re)reviewing a WhatsApp Business template attached to a connected WABA. |
whatsapp.automatic_event | Fired when Meta's automatic event identification detects a lead or purchase in a Click-to-WhatsApp conversation. |
Phone number webhooks
| Event | Description |
|---|---|
whatsapp.number.kyc_submitted | Fired when an end customer completes a hosted KYC share link; the number enters review under your account. |
whatsapp.number.activated | Fired when a WhatsApp number you provisioned finishes setup and is ready to connect. |
whatsapp.number.declined | Fired when a regulated number order is declined in review and no number is activated. |
whatsapp.number.action_required | Fired when the regulator asks for more information on a placed number order; the order stays pending until you provide it. |
whatsapp.number.verification_required | Fired when a regulated number needs end-user ID verification; carries the link to forward. |
whatsapp.number.suspended | Fired when an active number is suspended (e.g. failed payment); carries a reason. |
whatsapp.number.reactivated | Fired when a suspended number is usable again. |
whatsapp.number.released | Fired when a number is released and no longer usable (terminal); carries a reason. |
Testing
webhook.test is fired when you send a test delivery to verify your endpoint configuration, via Test webhook or the webhooks dashboard. It is delivered to the endpoint regardless of which events it subscribes to.
Stable webhook event ID
"webhook.test"Human-readable test message
UTC time at which Zernio generated this test event (set once when the payload is built). Test fires are sent synchronously as a single attempt; a later redelivery of this event keeps the original value.
date-time