Skip to content

Webhooks

How Zernio webhook deliveries work and the payload sent for each event.

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-Signature header when you configure a webhook secret.
  • Expect fast acknowledgement from your endpoint and move heavier processing to async jobs.

Delivery flow

  1. Create a webhook endpoint with Create webhook settings.
  2. Choose the events you want to subscribe to.
  3. Receive a POST request from Zernio whenever one of those events occurs.
  4. Return a 2xx response after you have accepted the payload.
  5. 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:

AttemptDelay before this attemptCumulative time since the first attempt
1immediate0
210s~10s
31m 40s~1m 50s
416m 40s~18m 30s
52h 46m 40s~3h 5m
624h (capped)~27h 5m
724h (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:

typescript
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
  // ...
};

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

EventDescription
post.publishedFired when a post is successfully published.
post.failedFired when a post fails to publish on all target platforms.
post.partialFired when a post publishes on some platforms and fails on others.
post.cancelledFired when a post publishing job is cancelled.
post.scheduledFired when a post is scheduled for future publishing.
post.recycledFired when a post is recycled for republishing.
account.connectedFired when a social account is successfully connected.
account.disconnectedFired when a connected social account becomes disconnected.
account.ads.initial_sync_completedFired once per ads-enabled account when the initial 90-day backfill completes.
message.receivedFired when a new inbox message is received.
message.sentFired when an outgoing message is sent from the inbox.
message.editedFired when a sender edits a previously-sent message.
message.deletedFired when a sender deletes (unsends) a message.
message.deliveredFired when an outgoing message is delivered to the recipient.
message.readFired when an outgoing message is read by the recipient.
message.failedFired when an outgoing message fails to deliver (WhatsApp only).
comment.receivedFired when a new comment is received on a tracked post.
review.newFired when a new review is posted on a connected account.
review.updatedFired when a review is edited or a reply is added.
webhook.testFired when sending a test webhook to verify the endpoint configuration.

post.published

Fired when a post is successfully published. Subscribe with Create webhook settings or Update webhook settings.

post.failed

Fired when a post fails to publish on all target platforms. Subscribe with Create webhook settings or Update webhook settings.

post.partial

Fired when a post publishes on some platforms and fails on others. Subscribe with Create webhook settings or Update webhook settings.

post.cancelled

Fired when a post publishing job is cancelled. Subscribe with Create webhook settings or Update webhook settings.

post.scheduled

Fired when a post is scheduled for future publishing. Subscribe with Create webhook settings or Update webhook settings.

post.recycled

Fired when a post is recycled for republishing. Subscribe with Create webhook settings or Update webhook settings.

account.connected

Fired when a social account is successfully connected. Subscribe with Create webhook settings or Update webhook settings.

account.disconnected

Fired when a connected social account becomes disconnected. Subscribe with Create webhook settings or Update webhook settings.

account.ads.initial_sync_completed

Fired once per ads-enabled account when the initial sync completes. The initial sync runs after an ads-capable account is connected and performs ad-account discovery plus a 90-day historical ad backfill. The payload includes a sync summary reporting whether the backfill succeeded fully or partially and how many ads were synced vs. failed.

When scoping was applied at connect time (see Scoping sync to specific ad accounts), account.platformAdAccountId echoes the chosen ad account back (when scope is exactly one) and account.platformAdAccountIds lists every act_* actually synced.

On failure (sync.status == "failure"), the payload also carries optional fields to help branch your UX without parsing prose:

  • sync.error: raw platform error message (truncated to ~2KB).
  • sync.errorCode / sync.errorSubcode: platform-native error codes when parseable (e.g. Meta 190, 10).
  • sync.errorCategory: a stable enum, one of token_invalid, permission_denied, no_ad_accounts, rate_limited, discovery_failed, unknown. New values may be added; existing ones are stable.

Subscribe with Create webhook settings or Update webhook settings.

message.received

Fired when a new inbox message is received. Subscribe with Create webhook settings or Update webhook settings.

message.sent

Fired when an outgoing message is sent from the inbox. Subscribe with Create webhook settings or Update webhook settings.

message.edited

Fired when the sender edits a previously-sent message. Supported on Instagram, Facebook Messenger, and Telegram. The payload carries the full editHistory (oldest prior version first) so you can reconstruct every version of the message. message.text is the latest version. Subscribe with Create webhook settings or Update webhook settings.

message.deleted

Fired when the sender deletes (unsends) a message. Supported on Instagram (incoming unsend) and WhatsApp (when the business deletes an outgoing message via the Cloud API). The payload retains the pre-delete text and attachments so API consumers can access the original content, useful for moderation, compliance, or archival. The Zernio dashboard UI does not show this content. Subscribe with Create webhook settings or Update webhook settings.

message.delivered

Fired when an outgoing message is delivered to the recipient. Supported on WhatsApp and Facebook Messenger. Instagram doesn't emit a separate delivery event (only message.read). Subscribe with Create webhook settings or Update webhook settings.

message.read

Fired when an outgoing message is read by the recipient. Supported on WhatsApp, Facebook Messenger, and Instagram. Subscribe with Create webhook settings or Update webhook settings.

message.failed

Fired when an outgoing message fails to deliver. Currently only emitted for WhatsApp (Messenger, Instagram, and Telegram don't expose per-message failure via webhook). The payload error object includes code, title, and message from the platform, useful for categorising failures (e.g. 131026 for "recipient phone not reachable"). Subscribe with Create webhook settings or Update webhook settings.

comment.received

Fired when a new comment is received on a tracked post. Subscribe with Create webhook settings or Update webhook settings.

The payload includes an optional comment.ad object when the comment was made on paid content. For Instagram this carries ad.id and ad.title directly from the Meta webhook. For Facebook it carries ad.promotionStatus ("active" for boosted organic posts, "ineligible" for dark post creatives). The field is absent for comments on organic posts that are not currently promoted, so clients can filter ad-driven comments with a simple if (comment.ad) { ... } check.

review.new

Fired when a new review is posted on a connected account. Currently supported for Google Business Profile (real-time via Pub/Sub). Available on the Usage plan (or AppSumo with the Inbox add-on). Subscribe with Create webhook settings or Update webhook settings.

review.updated

Fired when a review changes: the reviewer edits their text or rating, or a reply is added (via the API or directly through the Google Business dashboard). Available on the Usage plan (or AppSumo with the Inbox add-on). The payload has the same shape as review.new; when a reply is present, review.hasReply is true and review.reply is populated. Subscribe with Create webhook settings or Update webhook settings.

webhook.test

Fired when sending a test webhook to verify the endpoint configuration. Subscribe with Create webhook settings or Update webhook settings.