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
idandx-webhook-event-ididentify 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.messagescontains at most the latest 100 messages for that intent, ordered from oldest to newest bycreatedAtand thenid. 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
- In the Zeus Dashboard, select the account you want to configure.
- Open Developers → Webhooks.
- Select Create webhook, then enter a name and your publicly reachable HTTPS endpoint URL.
- Open the webhook detail page and copy its active Ed25519 public verification key and key ID.
- 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.
| Header | Included | Description |
|---|---|---|
content-type | Always | application/json |
user-agent | Always | Zeus/1.0 |
x-webhook-event-id | Always | Stable event ID. It equals the top-level body id and remains the same across retries. Use it for idempotency. |
x-webhook-request-id | Always | Unique delivery-attempt ID. It changes on every retry and traces one attempt. |
x-webhook-timestamp | Signed deliveries | ISO 8601 timestamp generated for this attempt. |
x-webhook-signature | Signed deliveries | Base64-encoded Ed25519 signature over the timestamp and exact raw body bytes. |
x-webhook-key-id | Signed deliveries | Identifies 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:
- Require all three signature headers.
- Find the public verification key matching
x-webhook-key-id. - Reject an invalid or stale timestamp. A five-minute tolerance is a reasonable starting point; choose a value suitable for your systems.
- Verify the Ed25519 signature using the exact raw request body.
- Parse the body and confirm its top-level
idmatchesx-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.
| Type | Sent when |
|---|---|
intent.created | Zeus creates a new intent. |
intent.updated | Zeus updates an existing intent. |
intent.outgoing-message-created | Zeus 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
| Field | Description |
|---|---|
id | Event ID; equal to x-webhook-event-id. |
type | One of the three event types above. |
intent.id | Stable intent ID. |
intent.attributes | Account-specific intent attributes as JSON. |
intent.createdAt | Intent creation time as an ISO 8601 string. |
intent.updatedAt | Intent update time as an ISO 8601 string. |
intent.source | Source that created the intent. |
intent.status | New, In Progress, Successful, Expired, or Opted Out. |
intent.contact.id | Contact ID. |
intent.contact.attributes | Account-specific contact attributes as JSON. |
intent.contact.optedOutAt | ISO 8601 opt-out time, or null. |
intent.contact.communicationChannels | Array of linked channels. Each contains id, type, and externalId. |
intent.messages | At most the latest 100 messages for this intent, ordered oldest to newest by createdAt and then id. |
intent.messages[].id | Stable message ID. Use it to merge and deduplicate messages. |
intent.messages[].agentChannelId | Zeus-issued ID of the agent channel for the message. |
intent.messages[].contactChannelExternalId | External contact or chat-session identifier for the message. |
intent.messages[].content | Message text. |
intent.messages[].type | Incoming or Outgoing. |
intent.messages[].createdAt | Message creation time as an ISO 8601 string. |
intent.messages[].updatedAt | Message 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:
- Route each message to the account-scoped conversation identified by its
agentChannelIdandcontactChannelExternalId. The receiving webhook configuration tells you the account. - Upsert by message
idrather than replacing local history withintent.messages. - If the same message is received again, keep the representation with the
newer
updatedAt. - Display messages by
createdAtand thenid, 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
idandx-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.