Guide to Zernio Webhooks: Features and Best Practices
Zernio, formerly Late, is an API for scheduling social media posts and running a unified inbox across social networks, WhatsApp and SMS, with ads, calls and phone numbers on top. Its webhooks tell your backend when something happens: a post publishes or fails, a DM or comment arrives, an account disconnects, a lead comes in or a number activates. Without them you would poll the API to find out whether a scheduled post went out.
This guide covers how to create Zernio webhook endpoints, how to verify their signatures, the events available, how to work out which customer an event belongs to, and the best practices for production.
What are Zernio webhooks?
A Zernio webhook endpoint is a URL plus a list of events, created through the API or the dashboard. Each user can have up to 50 endpoints. When a subscribed event happens, Zernio sends a JSON POST to the endpoint, with the event name in both the body and the X-Zernio-Event header.
Signing depends on you. You supply the secret when you create the endpoint, because Zernio never generates one, and Zernio then adds an X-Zernio-Signature header with a lowercase hex HMAC-SHA256 of the raw body. An endpoint created without a secret receives unsigned deliveries.
The old Late header names still arrive alongside the new ones: X-Late-Signature carries the same value as X-Zernio-Signature, and X-Late-Event-Id the same value as X-Zernio-Event-Id.
Zernio webhook features
| Feature | Details |
|---|---|
| Configuration | API (POST /v1/webhooks/settings) or the dashboard |
| Endpoints | Up to 50 per user, each with its own events list |
| Scoping | Optional profileIds and accountIds per endpoint |
| Signature header | X-Zernio-Signature (legacy alias X-Late-Signature), only when the endpoint has a secret |
| Scheme | HMAC-SHA256, lowercase hex digest |
| Signed content | The raw request body, with no timestamp |
| Secret | Chosen by you; Zernio never generates one |
| Event ID | Body id and X-Zernio-Event-Id, stable across retries |
| Timeout | A 2xx within 5 seconds |
| Retries | Up to 7 attempts over about 51 hours, then a dead-letter queue |
| Auto-disable | After 3 days without success, plus 20 consecutive terminal failures or 3 days of continuous failure |
| Source IPs | Not published |
Common events
Zernio lists 74 events across 13 areas, plus webhook.test. The ones most integrations start with are:
| Event | Fires when |
|---|---|
post.scheduled | A post enters the scheduled state |
post.published | A post publishes on every target platform |
post.partial | A post publishes on some platforms and fails on others |
post.failed | A post fails on every target platform |
post.platform.published | One platform target finishes publishing |
post.platform.failed | One platform target fails permanently |
message.received | A new inbox message (DM) arrives |
comment.received | A new comment arrives on a tracked post |
account.connected | An account is connected to a profile |
account.disconnected | A connected account stops working or is removed |
webhook.test | You call the test endpoint or press test in the dashboard |
The other areas are automations (contact.*, sequence.*, workflow.run.*), the support assistant (support.run.*), analytics (analytics.synced), ads (lead.received, ad.status_changed and ad sync events), calls (call.received, call.ended, call.failed), WhatsApp templates and accounts, phone numbers, SMS registrations, branded calling, managed verification (verification.approved, verification.failed) and api.changelog.published.
Every payload carries id, event and timestamp. Here is the webhook.test delivery from Zernio's docs:
{
"id": "3f0c1c2e-6c4a-4d3e-9b1f-2a7d8e9f0a1b",
"event": "webhook.test",
"message": "This is a test webhook from Zernio",
"timestamp": "2027-01-01T17:00:00Z"
}
id identifies the event and nothing else. timestamp is when Zernio generated the event, and it stays the same on retries and redeliveries. Events that belong to a profile also carry a top-level profiles array of { id, externalId }, where externalId is the value you set on the profile. Samples sent by the test endpoint carry test: true; real deliveries never do.
Setting up Zernio webhooks
- Generate a high-entropy secret and store it, for example as
ZERNIO_WEBHOOK_SECRET. - Create the endpoint with your API key:
curl -X POST https://zernio.com/api/v1/webhooks/settings \
-H "Authorization: Bearer $ZERNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Production",
"url": "https://example.com/webhooks/zernio",
"secret": "'"$ZERNIO_WEBHOOK_SECRET"'",
"events": ["post.published", "post.failed", "message.received"]
}'
- Note
webhook._idin the response, then send a test event:
curl -X POST https://zernio.com/api/v1/webhooks/test \
-H "Authorization: Bearer $ZERNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"webhookId": "507f1f77bcf86cd799439011"}'
webhook.test reaches the endpoint whatever its events list says, so you can test before subscribing to anything real. It is sent once and never retried, and a 500 from the test call means your endpoint didn't answer 2xx. You can also create and test endpoints in the dashboard.
To pin an endpoint to some of your customers, set profileIds (or accountIds) when you create it. Zernio's suggested use is a staging endpoint scoped to a profile that holds your test accounts.
For local development, hookdeck listen 3000 zernio --path /webhooks/zernio gives you a public HTTPS URL that forwards to your machine.
Securing Zernio webhooks
Compute an HMAC-SHA256 over the raw request bytes with your secret, hex-encode it, and compare it with X-Zernio-Signature in constant time. Zernio's own Node example compares with !==, which isn't constant time, so use timingSafeEqual instead.
const crypto = require('crypto');
const express = require('express');
function verifyZernioSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const received = Buffer.from(signatureHeader, 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/zernio', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.ZERNIO_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
const signature = req.get('x-zernio-signature') || req.get('x-late-signature');
if (!verifyZernioSignature(req.body, signature, secret)) {
return res.sendStatus(401);
}
const payload = JSON.parse(req.body.toString('utf8'));
// Store payload.id for deduplication, queue the work, then acknowledge.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
def verify_zernio_signature(raw_body: bytes, signature_header, secret: str) -> bool:
if not signature_header or not secret:
return False
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature_header.encode("utf-8"), expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_zernio_signature(raw_body, request.headers.get("x-zernio-signature"),
# os.environ["ZERNIO_WEBHOOK_SECRET"])
To check your code, the secret my-zernio-secret-0123456789 and the body {"id":"3f0c1c2e-6c4a-4d3e-9b1f-2a7d8e9f0a1b","event":"webhook.test","message":"This is a test webhook from Zernio","timestamp":"2027-01-01T17:00:00Z"} give the signature 676c95b5dca51dbd9197c7853243b6c5523002791594c72928f8f4800758bb24.
The signature covers the body only. The body's timestamp is the event's creation time, so it can't tell you whether a delivery is fresh, and a retry two days later carries the same value.
Make Zernio webhooks production-ready. Hookdeck Event Gateway verifies
X-Zernio-Signature, answers Zernio within the 5-second window, and durably queues post, inbox and account events for your service.
Zernio webhook limitations and pain points
Endpoints without a secret are unsigned
The Problem: An endpoint created without a secret receives deliveries with no signature, so anyone who learns the URL can send fake events.
Why It Happens: Zernio signs only when the endpoint has a secret, and it never generates one for you. It doesn't publish source IPs either.
Workarounds:
- Always send a secret when creating an endpoint, and reject requests without a valid signature.
- List your endpoints and re-create any that were made without a secret.
How Hookdeck Can Help: Hookdeck's Zernio source type verifies X-Zernio-Signature with the secret you set. Verification is optional on the source, so you can leave it off while an endpoint has no secret and turn it on once you have added one.
A 5-second deadline
The Problem: Handlers that publish a follow-up post, call another API or write to a slow database time out, and Zernio retries.
Why It Happens: A delivery counts as successful only if your endpoint returns a 2xx within 5 seconds. Anything slower counts as a failure, and the event comes back on the retry schedule.
Workarounds:
- Verify, store the event, return
2xx, and process it in a background worker. - Deduplicate, because a slow response can mean the same event arrives twice.
How Hookdeck Can Help: Hookdeck accepts the delivery and answers Zernio straight away, then delivers to your service with its own delivery timeout and retries.
Disabled endpoints and the dead-letter queue
The Problem: After a long outage, events stop arriving and some never reach you.
Why It Happens: Zernio makes up to 7 attempts over about 51 hours, then moves the event to a dead-letter queue. An endpoint with no successful delivery for 3 days that has also had 20 consecutive terminal failures, or has failed continuously for those 3 days, is disabled. Zernio emails the owner, but you have to re-enable the endpoint with isActive: true.
Workarounds:
- Watch webhook logs or the dashboard for failures, and re-enable the endpoint once you've fixed the cause.
- Use the Redeliver endpoint to resend dead-lettered events.
How Hookdeck Can Help: Hookdeck acknowledges every Zernio delivery even while your service is down, so the endpoint stays healthy. Failed deliveries to your service raise issues, and you can replay them once it recovers.
At-least-once delivery
The Problem: Your system marks a post as published twice, or replies to the same DM twice.
Why It Happens: Zernio delivers every event at least once. The same event can arrive again when an earlier response was lost or took longer than 5 seconds.
Workarounds:
- Deduplicate on the body
idor theX-Zernio-Event-Idheader, which are the same on every retry and redelivery. - Don't deduplicate on post or account IDs, because one post produces several events.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeats based on X-Zernio-Event-Id before they reach your service.
The account ID moves between event families
The Problem: Code that reads the account from one place breaks on other events.
Why It Happens: Most events carry it in account.accountId, but post.published, post.failed, post.partial and the other whole-post events have no top-level account and list one per target in post.platforms[].accountId. whatsapp.automatic_event uses a top-level accountId, and verification, changelog and test events have none.
Workarounds:
- Route by
profiles[].externalIdwhere you can. Set each profile'sexternalIdto your own customer ID and you won't need a lookup. - Never fall back to the top-level
id, which is the event ID, not an account or post ID.
How Hookdeck Can Help: A Hookdeck transformation can copy the account or profile ID into one field before delivery, so your handler reads it from the same place on every event.
Best practices
Verify against the raw body
Compute the HMAC over the exact bytes Zernio sent. Use express.raw() in Express, await request.text() in Next.js, and await request.body() in FastAPI, and parse the JSON only after the signature matches.
Subscribe only to what you handle
Zernio recommends subscribing each endpoint only to the events it handles. Fewer deliveries mean less load and fewer chances to hit the 5-second limit.
Scope endpoints by profile, not account
If you split traffic between staging and production, or between customers, use profileIds rather than accountIds. An account ID changes when the account is reconnected, and a profile ID doesn't. Remember that events with no profile, such as verification.* and api.changelog.published, reach only unscoped endpoints.
Make processing idempotent
Store each event id and skip any you've already processed. See how to implement webhook idempotency.
Acknowledge fast and process asynchronously
Persist the event and return 2xx, then do the work in the background, as Zernio's docs recommend. See why to process webhooks asynchronously.
Conclusion
Zernio signs a webhook only when you give the endpoint a secret, with a lowercase hex HMAC-SHA256 of the raw body in X-Zernio-Signature. Always set a secret, compare in constant time, answer within 5 seconds, deduplicate on the event id, and read the account ID from the right field for each event family.
Hookdeck Event Gateway verifies every Zernio signature, answers Zernio within its deadline, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Zernio webhooks reliably in minutes.