Skip to main content

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​

EventWhen
number.purchasedAn order completes. One event per order. numbers holds every number in that order.
number.releasedA number is released. A retry can send it again with already_released: true.
verification.updatedDocument extraction finishes, including when it fails. extracted_fields is a count, not the field values.
call.initiatedThe switch reports a new call.
call.answeredThe call is answered.
call.hangupThe 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.

FieldMeaning
call_idThe SIP Call-ID, the same value as the accounting row.
directioninbound, outbound, or null. Inbound is as the carrier sees it.
didThe Zoetel number, E.164.
from, toE.164 when they parsed. from_raw and to_raw are the original values.
provider, trunk_idBoth the constant zoetel.
connection_idThe SIP connection, when the call used one.
occurred_atThe switch clock. Null means the switch did not send one.
received_atWhen the control plane received the post.
sip_codeResponse code when the call did not complete.
dispositionanswered, no_answer, busy, failed, or null. Null on initiated and answered.
duration_secondsOn hangup. Null means unknown, not zero.
duration_sourcereported, 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:

HeaderValue
Zoe-Tel-DeliveryDelivery UUID
Zoe-Tel-EventEvent name
Zoe-Tel-TimestampUnix seconds for this attempt
Zoe-Tel-Signaturev1= 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.