Developers

Webhooks

Subscribing, verifying the HMAC signature, the retry policy, and the full event catalogue.

Read as markdown · OpenAPI 3.1 · llms.txt

Subscribe once; we push. No polling.

Subscribe

curl -X POST https://app.zellerai.co/api/v1/webhooks \
  -H "Authorization: token $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.example/hooks/zeller",
    "event_types": ["message.sent", "call.completed"]
  }'

The response carries secret once. Store it — it is how you verify deliveries, and we will not show it again. An empty event_types array subscribes to everything.

An unknown event name is refused with a 400 rather than accepted. A subscription that silently never fires is the most expensive way to be wrong here.

Verify the signature

Every delivery carries:

X-Webhook-Event: message.sent
X-Webhook-Delivery: clx…
X-Webhook-Signature: sha256=<hex>

The signature is an HMAC-SHA256 of the exact raw request body, keyed by your endpoint secret. Verify it before trusting anything.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody: string, header: string, secret: string): boolean {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(header);
  return a.length === b.length && timingSafeEqual(a, b);
}
import hmac, hashlib

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header)

Both examples hash the raw bytes, not a re-serialized object. JSON.parse then JSON.stringify will change key order or whitespace and the signature will not match.

Payload

{
  "event": "message.sent",
  "tenantId": "clx…",
  "occurredAt": "2026-08-13T12:00:00Z",
  "data": {
    "event_id": "evt_9a…",
    "occurred_at": "2026-08-13T12:00:00Z",
    "id": "clx…",
    "ai_generated": true,
    "channel": "SMS",
    "to": "+1••••••0142"
  }
}

event_id is stable across redeliveries — dedupe on it.

Phone numbers in webhook payloads are masked. A webhook crosses a network to a machine we do not control, so the full number is not pushed to everyone who subscribed; fetch the contact with contacts:read:pii if you need it.

Retries

Non-2xx (or a timeout over 10 seconds) is retried with exponential backoff — 1, 2, 4, 8, 16 minutes — up to 6 attempts, then the delivery is marked failed. Return 2xx as soon as you have durably accepted the payload; do your work afterwards.

The event catalogue

contact.created

A contact was added to the workspace.

Scope: contacts:read

contact.updated

A contact's details changed.

Scope: contacts:read

contact.stage_changed

A contact moved to a different lifecycle stage.

Scope: contacts:read

contact.tag_added

A tag was added to a contact.

Scope: contacts:read

lead.created

A new lead was captured from any source.

Scope: leads:read

lead.assigned

A lead was routed to an agent.

Scope: leads:read

message.received

An inbound text, WhatsApp message or email arrived.

Scope: messages:read

message.sent

An outbound text, WhatsApp message or email was sent — INCLUDING ones our AI composed (ai_generated: true).

Scope: messages:read

call.completed

A call finished — including AI-handled calls, with the summary and disposition.

Scope: calls:read

appointment.booked

An appointment was booked.

Scope: appointments:read

appointment.rescheduled

An appointment moved.

Scope: appointments:read

appointment.outcome

An appointment was marked showed/no-show/cancelled.

Scope: appointments:read

deal.stage_changed

A deal moved to a different pipeline stage.

Scope: deals:read

automation.run

An automation started for a contact.

Scope: automations:read

document.signed

A document came back signed.

Scope: deals:read

saved_search.sent

A listing alert digest went out to a contact.

Scope: contacts:read

What makes this different

message.sent fires for messages our AI composed, and call.completed fires for calls our voice agent handled — with the summary and the disposition. Integrations built on platforms that exclude automated communications from their webhooks cannot see that half of the conversation at all.