Webhooks
Create a webhook endpoint, receive your first event, and verify, deduplicate and retry deliveries the way Zernio sends them.
Zernio POSTs an event to your webhook endpoint when something happens: a post publishes, a DM arrives, an account disconnects, a number activates. Enable it with POST /v1/webhooks/settings. The secret Zernio signs deliveries with is the secret you send in that call; Zernio never generates one for you, and the same endpoints are editable in the webhooks dashboard.
First event
Call POST /v1/webhooks/settings with name, url, events and a secret. You can create up to 50 endpoints per user.
import Zernio from '@zernio/node';
const zernio = new Zernio();
const { data: created } = await zernio.webhooks.createWebhookSettings({
body: {
name: 'Production',
url: 'https://example.com/webhooks/zernio',
secret: process.env.ZERNIO_WEBHOOK_SECRET,
events: ['post.published', 'post.failed', 'message.received']
}
});
const webhookId = created.webhook._id;Response (200):
{
"success": true,
"webhook": {
"_id": "507f1f77bcf86cd799439011",
"name": "Production",
"url": "https://example.com/webhooks/zernio",
"events": ["post.published", "post.failed", "message.received"],
"isActive": true,
"failureCount": 0
}
}webhook._id is the webhookId for the test call. Call POST /v1/webhooks/test to send a webhook.test event to the endpoint right away:
const { data: tested } = await zernio.webhooks.testWebhook({
body: { webhookId }
});
console.log(tested.message);Response (200):
{
"success": true,
"message": "Test webhook sent successfully"
}Your endpoint receives one POST with these headers and body:
POST /webhooks/zernio HTTP/1.1
Content-Type: application/json
User-Agent: Zernio-Webhooks/1.0
X-Zernio-Event: webhook.test
X-Zernio-Event-Id: 3f0c1c2e-6c4a-4d3e-9b1f-2a7d8e9f0a1b
X-Zernio-Signature: 5d41402abc4b2a76b9719d911017c592e4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9{
"id": "3f0c1c2e-6c4a-4d3e-9b1f-2a7d8e9f0a1b",
"event": "webhook.test",
"message": "This is a test webhook from Zernio",
"timestamp": "2027-01-01T17:00:00Z"
}Return any 2xx within 5 seconds. webhook.test reaches the endpoint whatever its events list says, so it works before you subscribe to anything real. A 500 from the test call means your endpoint did not answer 2xx:
{
"success": false,
"message": "Test webhook failed"
}Check the endpoint's URL, then read the attempt in webhook logs or the dashboard.
Events
Every event is one event name in the events array. Each area page documents the payload of every event it covers.
Subscribe an endpoint only to the events it handles. Manage endpoints with List webhooks, Update webhook settings and Delete webhook settings.
Verification events
The two managed-OTP events have no area page: they belong to a verification, not to a connected account. verification.approved fires when the recipient submits the right code to Check a verification code, verification.failed when the attempts run out, with reason: "max_attempts_reached". Both carry verification.verificationId, verification.channel (sms) and verification.to. Send the code with Send a verification code.
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
Example Requests
/verification.approvedRequest Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
Example Requests
/verification.failedAPI changelog events
api.changelog.published fires when an entry is published to the changelog, which happens when a change to the OpenAPI spec goes live. It belongs to no profile or account: every active endpoint subscribed to it receives it, and an endpoint scoped with profileIds or accountIds does not. The payload carries the entry (entry.type, the announcement in entry.message, entry.url, the spec entry.specVersion) and entry.changes, the diff of the spec as data: the operations and component schemas added, removed and modified. Automate on changes and type; read message for the reason. Nothing in it is specific to your account, so one subscription per integration is enough. List API changelog entries returns the same entries, newest first, to back-fill after downtime.
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
Example Requests
/api.changelog.publishedHow it behaves
Delivery retries
Zernio delivers an event up to 7 times, counting the first attempt. A delivery succeeds when your endpoint returns a 2xx within 5 seconds. Any other outcome (a non-2xx status, a timeout, a connection error) schedules the next attempt on an exponential backoff capped at 24 hours, 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 failure Zernio moves the event to a dead-letter queue and stops retrying it. Every attempt is visible in webhook logs and the dashboard; attemptNumber says which try produced each entry, and Redeliver sends a dead-lettered event again.
The schedule starts once the event reaches Zernio's delivery queue. If that queue briefly refuses a delivery when the event is emitted (for example under load), Zernio parks it and re-sends it to the queue every minute for up to 24 hours, so the event can arrive later than usual instead of being dropped.
To stay under the 5-second limit, persist the event and return 2xx, then process it on a background worker.
Zernio disables an endpoint only when it has had no successful delivery for 3 days and has either reached 20 consecutive terminal failures (events that exhausted every attempt) or been failing continuously for 3 days. One successful delivery inside that window keeps it enabled whatever the count. Zernio emails the owner; re-enable it with isActive: true on Update webhook settings. You can also pause or remove an endpoint yourself at any time.
At-least-once delivery
Zernio delivers every event at least once. The same event can arrive twice when a previous attempt's response was lost or your endpoint took longer than 5 seconds to answer, so your handler must be idempotent.
Every payload carries a stable event id, repeated as a header:
payload.id, the canonical event id (UUID).X-Zernio-Event-Id, the same value as a header.X-Late-Event-Id, the legacy alias, kept for backward compatibility.
Use it as your deduplication key: insert the id into a unique-indexed table or cache before processing, and skip the payload when the insert conflicts.
Finding the account an event is about
The top-level id identifies the event only; it is never an account, post or other resource id, so do not fall back to it when routing. The connected account lives here:
| Event family | Account id |
|---|---|
account.*, analytics.synced | account.accountId |
message.*, reaction.received, referral.received, conversation.*, comment.received, review.*, lead.received, call.* | account.accountId (account.id carries the same value, kept for compatibility; call.received, call.ended and call.failed also carry call.accountId) |
post.platform.*, post.tiktok.url_resolved | account.accountId, the target this event is about (post.platforms[].accountId lists every target) |
post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled | post.platforms[].accountId, one per target; there is no top-level account |
post.external.* | account.accountId (also post.accountId) |
ad.status_changed, whatsapp.template.*, whatsapp.account.*, whatsapp.contact.identity_changed | account.accountId |
whatsapp.automatic_event | top-level accountId |
whatsapp.number.*, sms.registration.* | none; number.id and registration.id are the number and registration ids, number.profileId and registration.profileId the profile |
verification.*, phone_number.stock_available, api.changelog.published, webhook.test, contact.*, sequence.*, workflow.run.* | none (sequence.* and workflow.run.* still reach endpoints scoped to the sending account) |
Signatures
Zernio signs every delivery when the endpoint has a secret. X-Zernio-Signature is the lowercase hex HMAC-SHA256 of the raw request body keyed by that secret; X-Late-Signature is the legacy alias with the same value. Read the raw body, compute the HMAC and compare:
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
// ...
};A mismatch means the request did not come from Zernio or the body changed in transit. Do not process it.
Routing by profile or account
An endpoint receives every profile's events by default. Set profileIds on the subscription to pin it to a list of profiles: it is then only sent events attributable to one of them: the profile of the connected account behind the event, or for a post.* event the profile of every account the post targets (a post that spans two scoped endpoints' profiles reaches both). The usual reason is a staging environment. Put the test accounts in their own profile, point one endpoint at staging with profileIds set to that profile's id, and give the production endpoint the production profiles, so a post to a test account never reaches production and a customer's DM never reaches staging.
POST /v1/webhooks/settings
{
"name": "Staging",
"url": "https://staging.example.com/webhooks/zernio",
"events": ["post.published", "post.failed", "message.received"],
"profileIds": ["66a1f0c2a4b9d3e8f1a2b3c4"]
}Every id must be a profile in your team; an unknown one fails the call with 404 and code: "profile_not_found", and nothing is saved. Replace the list on Update webhook settings, where an empty array clears it, and sending the stored list back unchanged is always accepted, so an endpoint stays editable after a listed profile is deleted (the deleted id simply never matches again).
accountIds does the same keyed on connected accounts (404, code: "account_not_found" for an id outside your team); a subscription with both lists must match on both. Prefer profiles: an account id changes when the account is reconnected, a profile does not. Events that belong to no profile (verification.*, phone_number.stock_available, api.changelog.published, a legacy post whose accounts were deleted) only reach unscoped endpoints. The filter runs when an event is emitted: Redeliver replays a delivery already made to that endpoint, and the test event ignores the list.
The test event
Zernio sends webhook.test to an endpoint when you call Test webhook or press test in the dashboard, whatever events the endpoint subscribes to. It is sent once, synchronously, and is never retried.
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Webhook payload for test deliveries
Response Body
Example Requests
Webhook payload for test deliveries
/webhook.testRelated
- Create webhook settings: every field, including
customHeaders,disabledResourceGroups,profileIdsandaccountIds. - Webhook logs: every attempt with its status code and response body.
- Post webhooks: the events behind a scheduled post.
- Inbox webhooks:
message.receivedand what it carries. - Multi-tenant: route events to the right customer by
account.profileId.