# Webhooks

Subscribe once; we push. No polling.

## Subscribe

```bash
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.

```ts
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);
}
```

```python
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

```json
{
  "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.
