Webhooks Guide

Last updated 04/08/2026

Zeus webhooks send account activity to your HTTPS endpoint as JSON POST requests. Zeus attempts to deliver every supported event to each configured webhook for its account.

Webhook event model

  • Webhook: An account-scoped configuration that connects Zeus to your endpoint.
  • Event: An immutable notification built after supported intent or message activity. The top-level body id and x-webhook-event-id identify the event and remain stable across retries.
  • Intent snapshot: Intent and contact state captured when Zeus builds the event. Retries resend the same snapshot; it is not refreshed at delivery time.
  • Message window: intent.messages contains at most the latest 100 messages for that intent, ordered from oldest to newest by createdAt and then id. It is not the complete conversation history.
  • Delivery attempt: One HTTP request for an event. An event can have multiple attempts, each with a different x-webhook-request-id.

One intent may produce intent.created, intent.updated, and intent.outgoing-message-created events. An event can be delivered more than once, and events for the same intent can arrive out of order. Do not assume the most recently received body contains the newest state.

The current envelope has no occurrence timestamp, schema version, or monotonic revision. Consumers cannot reliably order complete snapshots and should tolerate unknown future event and enum values.

Set up a webhook

  1. In the Zeus Dashboard, select the account you want to configure.
  2. Open Developers → Webhooks.
  3. Select Create webhook, then enter a name and your publicly reachable HTTPS endpoint URL.
  4. Open the webhook detail page and copy its active Ed25519 public verification key and key ID.
  5. Use the delivery history on that page to inspect attempts, responses, and errors.

Use only a webhook with an active public verification key in production. Zeus includes signature headers only when a webhook has an active key. A delivery to a webhook that shows No signing keys available is unsigned. Reject unsigned deliveries, replace the webhook, or contact Zeus.

Event filtering is not currently configurable: Zeus attempts every supported event type for every webhook in the account.

Request headers

Header names are case-insensitive.

HeaderIncludedDescription
content-typeAlwaysapplication/json
user-agentAlwaysZeus/1.0
x-webhook-event-idAlwaysStable event ID. It equals the top-level body id and remains the same across retries. Use it for idempotency.
x-webhook-request-idAlwaysUnique delivery-attempt ID. It changes on every retry and traces one attempt.
x-webhook-timestampSigned deliveriesISO 8601 timestamp generated for this attempt.
x-webhook-signatureSigned deliveriesBase64-encoded Ed25519 signature over the timestamp and exact raw body bytes.
x-webhook-key-idSigned deliveriesIdentifies the public verification key for this signature.

Verify a signed request

A valid signature proves that the request came from Zeus and that its body has not been altered. Checking the timestamp limits replay of an old signed request.

The signed bytes are:

x-webhook-timestamp + "." + exact raw JSON body bytes

Verify the request before parsing its JSON body:

  1. Require all three signature headers.
  2. Find the public verification key matching x-webhook-key-id.
  3. Reject an invalid or stale timestamp. A five-minute tolerance is a reasonable starting point; choose a value suitable for your systems.
  4. Verify the Ed25519 signature using the exact raw request body.
  5. Parse the body and confirm its top-level id matches x-webhook-event-id.

For Ed25519, Node.js requires null as the algorithm passed to crypto.verify.

The following Express example is an integration skeleton, not a complete server. Implement getVerificationKeyById and acceptEventOnce, add schema validation and application error handling, choose a raw-body size limit, and register this route before any global JSON body parser.

import { verify } from 'node:crypto';
import express from 'express';

const app = express();
const MAX_TIMESTAMP_AGE_MS = 5 * 60 * 1000;

// Load the public verification key shown for this ID in the Zeus Dashboard.
function getVerificationKeyById(keyId) {
  // Return the matching PEM key from your configuration.
}

app.post(
  '/webhooks/zeus',
  express.raw({ type: 'application/json', limit: '1mb' }),
  async (req, res) => {
    const timestamp = req.get('x-webhook-timestamp');
    const signature = req.get('x-webhook-signature');
    const keyId = req.get('x-webhook-key-id');
    const eventId = req.get('x-webhook-event-id');
    const rawBody = req.body;

    if (
      !timestamp ||
      !signature ||
      !keyId ||
      !eventId ||
      !Buffer.isBuffer(rawBody)
    ) {
      return res.status(400).send('Missing webhook headers or raw body');
    }

    const timestampMs = Date.parse(timestamp);

    if (
      !Number.isFinite(timestampMs) ||
      Math.abs(Date.now() - timestampMs) > MAX_TIMESTAMP_AGE_MS
    ) {
      return res.status(401).send('Invalid webhook timestamp');
    }

    const verificationKey = getVerificationKeyById(keyId);

    if (!verificationKey) {
      return res.status(401).send('Unknown webhook verification key');
    }

    const signedPayload = Buffer.concat([
      Buffer.from(`${timestamp}.`, 'utf8'),
      rawBody,
    ]);

    let signatureIsValid = false;

    try {
      signatureIsValid = verify(
        null,
        signedPayload,
        verificationKey,
        Buffer.from(signature, 'base64')
      );
    } catch {
      signatureIsValid = false;
    }

    if (!signatureIsValid) {
      return res.status(401).send('Invalid webhook signature');
    }

    let event;

    try {
      event = JSON.parse(rawBody.toString('utf8'));
    } catch {
      return res.status(400).send('Invalid JSON');
    }

    if (event.id !== eventId) {
      return res.status(400).send('Event ID mismatch');
    }

    // Durably claim the event and enqueue its processing in one transaction.
    await acceptEventOnce(event.id, event);
    return res.sendStatus(200);
  }
);

Do not reconstruct or re-serialize the JSON before verification: even harmless formatting changes produce different bytes and invalidate the signature.

Idempotency and request IDs

Webhook deliveries can be duplicated. After an event is successfully added to the delivery queue, failed attempts are retried and the same event may arrive more than once. Dispatch before enqueue is best effort, so retries do not provide an end-to-end delivery guarantee.

Use x-webhook-event-id—or the matching signed body id—as the idempotency key. Commit the event claim and durable internal work in one transaction. If that is not possible, persist processing and completed states and recover stale processing claims. Marking an event complete before its side effects finish can cause every retry after a failure to be skipped.

If the event has already completed, skip its side effects and return a 2xx response. Do not use x-webhook-request-id for idempotency: it identifies one attempt and changes when Zeus retries an event.

Event types

All event types use the same envelope and contain an intent snapshot captured when Zeus builds the event. Retries keep the same body. Events are processed independently, so an older snapshot can arrive after a newer one.

TypeSent when
intent.createdZeus creates a new intent.
intent.updatedZeus updates an existing intent.
intent.outgoing-message-createdZeus creates an outgoing message. The payload does not identify the triggering message, guarantee that it remains in the latest-100 window under concurrency, or correlate it to an incoming API request.

intent.outgoing-message-created is a notification, not a per-request completion event. One accepted incoming message does not guarantee one outgoing event or outgoing message.

Payload fields

FieldDescription
idEvent ID; equal to x-webhook-event-id.
typeOne of the three event types above.
intent.idStable intent ID.
intent.attributesAccount-specific intent attributes as JSON.
intent.createdAtIntent creation time as an ISO 8601 string.
intent.updatedAtIntent update time as an ISO 8601 string.
intent.sourceSource that created the intent.
intent.statusNew, In Progress, Successful, Expired, or Opted Out.
intent.contact.idContact ID.
intent.contact.attributesAccount-specific contact attributes as JSON.
intent.contact.optedOutAtISO 8601 opt-out time, or null.
intent.contact.communicationChannelsArray of linked channels. Each contains id, type, and externalId.
intent.messagesAt most the latest 100 messages for this intent, ordered oldest to newest by createdAt and then id.
intent.messages[].idStable message ID. Use it to merge and deduplicate messages.
intent.messages[].agentChannelIdZeus-issued ID of the agent channel for the message.
intent.messages[].contactChannelExternalIdExternal contact or chat-session identifier for the message.
intent.messages[].contentMessage text.
intent.messages[].typeIncoming or Outgoing.
intent.messages[].createdAtMessage creation time as an ISO 8601 string.
intent.messages[].updatedAtMessage update time as an ISO 8601 string.

Current communication-channel types are api, whatsapp, twilio-sms, widget, and test.

Example payload

{
  "id": "4a46c183-1b92-48df-a75e-277be4749a22",
  "type": "intent.outgoing-message-created",
  "intent": {
    "id": "b15d8dc2-c3a1-456d-97f1-b91923188d6b",
    "attributes": {
      "product": "Business insurance",
      "renewalMonth": "October"
    },
    "createdAt": "2026-08-02T10:14:31.120Z",
    "updatedAt": "2026-08-02T10:16:04.442Z",
    "source": "api",
    "status": "In Progress",
    "contact": {
      "id": "d673c489-981c-4af0-a209-ab65821a09c0",
      "attributes": {
        "firstName": "Alex",
        "email": "alex@example.com"
      },
      "optedOutAt": null,
      "communicationChannels": [
        {
          "id": "340b440a-ed9c-4987-a60f-e9bec48d42f8",
          "type": "api",
          "externalId": "customer-user-123"
        }
      ]
    },
    "messages": [
      {
        "id": "1d2277e3-952a-4ae7-8407-853bb4e85dd1",
        "agentChannelId": "8dc72cb9-eaf2-4b6f-af0d-e68ee6add246",
        "contactChannelExternalId": "customer-user-123",
        "content": "I need help renewing my policy.",
        "type": "Incoming",
        "createdAt": "2026-08-02T10:15:43.008Z",
        "updatedAt": "2026-08-02T10:15:43.008Z"
      },
      {
        "id": "9526b821-368f-4b2a-a89c-513b9b6b3d28",
        "agentChannelId": "8dc72cb9-eaf2-4b6f-af0d-e68ee6add246",
        "contactChannelExternalId": "customer-user-123",
        "content": "Of course. What month does your current policy renew?",
        "type": "Outgoing",
        "createdAt": "2026-08-02T10:16:04.442Z",
        "updatedAt": "2026-08-02T10:16:04.442Z"
      }
    ]
  }
}

Ordering and reconciliation

Treat a webhook as a change notification with a bounded snapshot, not as an ordered event log or complete transcript.

For messages:

  1. Route each message to the account-scoped conversation identified by its agentChannelId and contactChannelExternalId. The receiving webhook configuration tells you the account.
  2. Upsert by message id rather than replacing local history with intent.messages.
  3. If the same message is received again, keep the representation with the newer updatedAt.
  4. Display messages by createdAt and then id, both ascending.

One intent snapshot can contain messages from more than one channel pair. Do not infer a single conversation from intent.id or route every message in the window to the same local thread.

Delivery order cannot reliably select the newest complete intent or contact snapshot. intent.updatedAt describes the intent in that snapshot; it is not a monotonic version for the complete event payload.

For api channel conversations, use GET /v1/messages when local message state is missing or stale. Request the latest page first, merge messages by ID, and follow nextCursor only when older history is needed. The GET endpoint returns fixed pages of 100 messages for the complete channel thread, which may span multiple intents. Webhook snapshots do not contain a cursor.

See the API guide for the GET contract and the server-to-server messaging guide for the complete synchronization flow.

Delivery and retries

After successful enqueue, Zeus treats any 2xx response as a successful attempt. A network error or non-2xx response causes that queued event to be retried.

Zeus makes one initial attempt and up to eight retries with exponential backoff from a 15-minute base. The base sequence is 15 minutes, 30 minutes, 1 hour, 2 hours, 4 hours, 8 hours, 16 hours, and 32 hours. Queue jitter and processing delays mean exact delivery times are not guaranteed.

Every retry:

  • keeps the same event body id and x-webhook-event-id;
  • receives a new x-webhook-request-id; and
  • is signed again with a fresh timestamp and signature when signing is enabled.

Durably accept webhook work and return 2xx promptly. Delivery history is available in the Zeus Dashboard, but retry exhaustion does not currently create a terminal webhook event or support customer replay. Reconcile api message history through GET /v1/messages when delivery may have been missed.