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.