Webhooks
Each endpoint has its own secret. Delivery is at-least-once: any non-2xx or a transport failure is retried. De-duplicate on the id in the body. That id matches the Zoe-Tel-Delivery header and stays the same across retries.
Events that fire
| Event | When |
|---|---|
number.purchased | An order completes. One event per order. numbers holds every number in that order. |
number.released | A number is released. A retry can send it again with already_released: true. |
verification.updated | Document extraction finishes, including when it fails. extracted_fields is a count, not the field values. |
call.initiated | The switch reports a new call. |
call.answered | The call is answered. |
call.hangup | The call ends, answered or not. Once per call. |
number.purchased also includes currency, charged_minor, transaction_id, balance_after_minor, and routing_jobs.
number.released includes number_id, e164, released_at, status, already_released, credit_minor, and credit_reason.
Call events are sent only for organisations named in CALL_EVENT_EMIT_ORGS on that deployment. Other organisations still have the calls recorded. Nothing is posted.
Call payload
The same fields are present on call.initiated, call.answered, and call.hangup. A field that does not apply is null.
| Field | Meaning |
|---|---|
call_id | The SIP Call-ID, the same value as the accounting row. |
direction | inbound, outbound, or null. Inbound is as the carrier sees it. |
did | The Zoetel number, E.164. |
from, to | E.164 when they parsed. from_raw and to_raw are the original values. |
provider, trunk_id | Both the constant zoetel. |
connection_id | The SIP connection, when the call used one. |
occurred_at | The switch clock. Null means the switch did not send one. |
received_at | When the control plane received the post. |
sip_code | Response code when the call did not complete. |
disposition | answered, no_answer, busy, failed, or null. Null on initiated and answered. |
duration_seconds | On hangup. Null means unknown, not zero. |
duration_source | reported, derived_from_answer, or derived_from_initiated. The last of those includes ring time. |
There is no org_id in the body. The endpoint the event was posted to is the organisation.
Signature
Every attempt sends:
| Header | Value |
|---|---|
Zoe-Tel-Delivery | Delivery UUID |
Zoe-Tel-Event | Event name |
Zoe-Tel-Timestamp | Unix seconds for this attempt |
Zoe-Tel-Signature | v1= plus 64 lowercase hex characters |
The signed string is v1: + timestamp + : + the raw body bytes, HMAC-SHA256 with the endpoint secret, hex encoded.
Read the raw body before parsing JSON. Reject a timestamp more than five minutes from your clock. Compare the v1= value with a constant-time compare. Take event and id from the body after that. The headers are a copy for logging, and they are not inside the signature.
const crypto = require('node:crypto')
const ts = req.get('Zoe-Tel-Timestamp') ?? ''
const sig = (req.get('Zoe-Tel-Signature') ?? '').match(/(?:^|,)\s*v1=([0-9a-f]{64})/)?.[1]
const expected = crypto.createHmac('sha256', process.env.ZOETEL_WEBHOOK_SECRET)
.update('v1:').update(ts).update(':').update(rawBody).digest('hex')
Names that do not fire
call.bridged, call.recording.saved, call.dtmf.received, call.machine.detection.ended, streaming.started, streaming.stopped, balance.low, message.received, message.sent, and message.finalized can appear in the dashboard picker. Nothing raises them. The message events are for an SMS product that was cancelled.