Gareth Wilson Gareth Wilson

Guide to Zernio Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationAPI (POST /v1/webhooks/settings) or the dashboard
EndpointsUp to 50 per user, each with its own events list
ScopingOptional profileIds and accountIds per endpoint
Signature headerX-Zernio-Signature (legacy alias X-Late-Signature), only when the endpoint has a secret
SchemeHMAC-SHA256, lowercase hex digest
Signed contentThe raw request body, with no timestamp
SecretChosen by you; Zernio never generates one
Event IDBody id and X-Zernio-Event-Id, stable across retries
TimeoutA 2xx within 5 seconds
RetriesUp to 7 attempts over about 51 hours, then a dead-letter queue
Auto-disableAfter 3 days without success, plus 20 consecutive terminal failures or 3 days of continuous failure
Source IPsNot published

Common events

Zernio lists 74 events across 13 areas, plus webhook.test. The ones most integrations start with are:

EventFires when
post.scheduledA post enters the scheduled state
post.publishedA post publishes on every target platform
post.partialA post publishes on some platforms and fails on others
post.failedA post fails on every target platform
post.platform.publishedOne platform target finishes publishing
post.platform.failedOne platform target fails permanently
message.receivedA new inbox message (DM) arrives
comment.receivedA new comment arrives on a tracked post
account.connectedAn account is connected to a profile
account.disconnectedA connected account stops working or is removed
webhook.testYou 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

  1. Generate a high-entropy secret and store it, for example as ZERNIO_WEBHOOK_SECRET.
  2. 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"]
  }'
  1. Note webhook._id in 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 id or the X-Zernio-Event-Id header, 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[].externalId where you can. Set each profile's externalId to 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.