# 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:

```json
{
  "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:

```bash
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:

```bash
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](https://zernio.com/dashboard/webhooks).

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.

```javascript
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:

```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](/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](/docs/destinations#set-a-delivery-timeout) and [retries](/docs/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](/docs/issues), and you can [replay](/docs/use-cases/receive-webhooks#replay-for-debugging-and-recovery) 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](/docs/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](/docs/transformations) 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](/webhooks/guides/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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) verifies every Zernio signature, answers Zernio within its deadline, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Zernio webhooks reliably in minutes.