Skip to main content

Webhook Events

Receive real-time notifications about calls, recordings, voicemails, transcriptions, and faxes via HTTP webhooks.

Webhook Events is the backend transport for call events. For the browser transport, see Real-Time Events (SSE). For common patterns built on top, see Screen Pop and Activity Logging.

Overview

Webhook events are push notifications that DialStack sends to your server as things happen in the system. Unlike appointment webhooks (which are request/response), event webhooks are fire-and-forget notifications — return 200 to acknowledge receipt.

Webhook events are platform-scoped: register webhook endpoints on your platform, and DialStack delivers events for all accounts on that platform to the endpoints that match.

For frontend real-time notifications (account-scoped), see Real-Time Events (SSE).

Configuration

Register one or more webhook endpoints with the Webhook Endpoints API. Each endpoint has:

  • a url that receives event POST requests,
  • a secret used for signature verification (generated by DialStack and returned only in the create response — store it then, it cannot be retrieved later),
  • enabled_events, the list of event types it subscribes to (defaults to ["*"], all events),
  • a status (enabled or disabled).
Bash
curl https://api.dialstack.ai/v1/webhook_endpoints \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks",
"enabled_events": ["call.answered", "call.end"]
}'

Or with the SDK:

TypeScript
const endpoint = await dialstack.webhookEndpoints.create({
url: 'https://example.com/webhooks',
enabled_events: ['call.answered', 'call.end'], // omit for all events
});
console.log(endpoint.secret); // shown only once — store it

Sandbox vs live keys

Webhook endpoints are environment-scoped, like the rest of the API. For an overview of sandbox vs. live keys and how the two environments are isolated, see Authentication. The environment is determined by the key you create the endpoint with and is fixed for the life of the endpoint (reported as livemode on the object):

  • An endpoint created with a live key (sk_live_...) receives events only from your live accounts.
  • An endpoint created with a sandbox key (sk_test_...) receives events only from your sandbox accounts.

This lets you point sandbox traffic at a separate backend: a sandbox account's events are never delivered to a live endpoint, and vice versa. If no endpoint matches an event's environment, the event is simply not delivered. Listing endpoints with a given key returns only that environment's endpoints.

Event Types

EventDescription
call.initiatedOutbound call started
call.incomingInbound call received
call.ringingA user's phones are about to ring
call.mobile_push_wakeupSend a push to wake the user's mobile app for this call
call.answeredCall answered
call.endCall ended
call.transferCall transferred
call.emergencyAn emergency number was dialed (911 or regional equivalent)
call.command_succeededA call-control command completed successfully
call.command_failedA call-control command did not complete
queue.call.queuedCaller entered a call queue
queue.call.dispatchedQueue dispatched the caller to agent targets
queue.call.answeredQueue caller was answered by an agent
queue.call.abandonedCaller hung up while waiting in queue
queue.call.timed_outQueue stopped holding a caller before an agent answered
queue.call.completedAnswered queue call completed
queue.call.callback_requestedCaller requested a queue callback
queue.call.callback_attemptedQueue callback attempt completed
queue.call.callback_failedQueue callback request reached a terminal failure
fax.receivedInbound fax received
fax.deliveredOutbound fax delivered successfully
fax.failedOutbound fax failed to send
voicemail.newNew voicemail received
recording.availableCall recording ready for download
recording.failedCall recording could not be prepared (e.g. redaction failed)
recording.transcription.completeCall transcript ready
recording.summary.completeAI call summary and sentiment ready
voicemail.transcription.completeVoicemail transcript ready
voicemail.summary.completeAI voicemail summary and sentiment ready
device.provisionedDeskphone or DECT base completed its first provisioning
device.config_fetchedA device fetched its configuration (every boot / check-sync)
device.provisioning_failedA device's configuration could not be generated
user.createdA user was created and is now billable
user.deletedA user was deleted and is no longer billable
phone_number.activatedA phone number went into service and is now billable
phone_number.disconnectedA phone number left service and is no longer billable

Event Envelope

All webhook events share this envelope structure:

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v2",
"type": "call.end",
"created_at": "2026-01-15T14:35:30Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {}
}
FieldTypeDescription
idstringUnique event identifier. Use for idempotent processing.
typestringEvent type (see table above).
created_atstringISO 8601 timestamp when the event was created.
account_idstringAccount where the event occurred.
dataobjectEvent-specific payload (see below).
The SSE transport uses a different shape

The same call events are also delivered over the Real-Time Events (SSE) stream, but without this envelope: SSE payloads are flat (no id/created_at/data wrapper) and name the event-type field event rather than type. Treat the two transports as separate schemas — see the GET /v1/events reference for the authoritative SSE field names.

Event Payloads

call.initiated

Sent when an outbound call starts dialing.

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v2",
"type": "call.initiated",
"created_at": "2026-01-15T14:30:00Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"call_id": "call_01h2xcejqtf2nbrexx3vqjhp45",
"direction": "outbound",
"from_number": "+14155559876",
"to_number": "+14155551234",
"user_id": "user_01h2xcejqtf2nbrexx3vqjhp42",
"from_user_id": "user_01h2xcejqtf2nbrexx3vqjhp42",
"started_at": "2026-01-15T14:30:00Z"
}
}

call.incoming

Sent when an inbound call is received.

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v3",
"type": "call.incoming",
"created_at": "2026-01-15T14:30:00Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"call_id": "call_01h2xcejqtf2nbrexx3vqjhp45",
"direction": "inbound",
"from_number": "+14155551234",
"from_name": "John Smith",
"to_number": "+14155559876",
"user_id": "user_01h2xcejqtf2nbrexx3vqjhp42",
"to_user_id": "user_01h2xcejqtf2nbrexx3vqjhp42",
"started_at": "2026-01-15T14:30:00Z"
}
}
FieldTypeDescription
call_idstringCall identifier
directionstringAlways "inbound"
from_numberstringCaller's phone number (E.164)
from_namestring | nullCaller's display name
to_numberstringCalled phone number (E.164)
user_idstring | nullUser the call is routed to. Only set when the called number targets a single user directly; omitted when the call routes to a ring group, voice app, dial plan, or any other destination where a specific user is not known at this stage.
from_user_idstring | nullCalling user (DIA-801). See User attribution below.
to_user_idstring | nullCalled user (DIA-801). See User attribution below.
started_atstringISO 8601 timestamp

call.ringing

Sent when a user's phones are about to ring. Fired once per (call, user) — a user with multiple phones (desk + mobile) produces a single event, not one per device. For a ring group or ring-all-users call, one event is emitted per user being rung, all sharing the same call_id. user_id always identifies the user whose phones are ringing, never the caller.

Not emitted for the initiating user's own phone ringing during a click-to-call (leg A).

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v8",
"type": "call.ringing",
"created_at": "2026-01-15T14:30:01Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"call_id": "call_01h2xcejqtf2nbrexx3vqjhp45",
"user_id": "user_01h2xcejqtf2nbrexx3vqjhp42",
"direction": "inbound",
"from_number": "+14155551234",
"from_name": "John Smith",
"to_number": "+14155559876",
"ringing_at": "2026-01-15T14:30:01Z"
}
}
FieldTypeDescription
call_idstringCall identifier
user_idstringUser whose phones are about to ring
directionstring"inbound", "outbound" or "internal". Absent if the platform could not determine the direction
from_numberstringCaller's phone number (E.164)
from_namestring | nullCaller's display name
to_numberstringCalled phone number (E.164)
ringing_atstringISO 8601 timestamp when the ring began

call.mobile_push_wakeup

Sent when an incoming call is being delivered to the user's web/mobile calling session and the user has mobile_push_wakeup enabled. This is your cue to send the push notification that wakes the user's mobile app: when the app has no active session, the call is held for a wake-up window while your push → app wake-up → connect flow completes.

Unlike call.ringing (which fires whenever a user is being reached, including when their calls forward to an external number), this event fires only when the user's own app session is actually being rung — every push it triggers corresponds to a real, answerable call. Use call.ringing for screen pop; use this event for mobile push delivery.

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v9",
"type": "call.mobile_push_wakeup",
"created_at": "2026-01-15T14:30:01Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"call_id": "call_01h2xcejqtf2nbrexx3vqjhp45",
"user_id": "user_01h2xcejqtf2nbrexx3vqjhp42",
"direction": "inbound",
"from_number": "+14155551234",
"from_name": "John Smith",
"to_number": "+14155559876",
"ringing_at": "2026-01-15T14:30:01Z"
}
}
FieldTypeDescription
call_idstringCall identifier
user_idstringUser whose app should be woken
directionstring"inbound", "outbound" or "internal". Absent if the platform could not determine the direction
from_numberstringCaller's phone number (E.164)
from_namestring | nullCaller's display name
to_numberstringCalled phone number (E.164)
ringing_atstringISO 8601 timestamp when delivery began

call.answered

Sent when a call is answered.

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v4",
"type": "call.answered",
"created_at": "2026-01-15T14:30:05Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"call_id": "call_01h2xcejqtf2nbrexx3vqjhp45",
"direction": "inbound",
"from_number": "+14155551234",
"from_name": "John Smith",
"to_number": "+14155559876",
"user_id": "user_01h2xcejqtf2nbrexx3vqjhp42",
"to_user_id": "user_01h2xcejqtf2nbrexx3vqjhp42",
"started_at": "2026-01-15T14:30:00Z",
"answered_at": "2026-01-15T14:30:05Z",
"connected_at": "2026-01-15T14:30:47Z"
}
}

answered_at is the signalling answer — a greeting, menu, or voice app answering the media path counts, so for an inbound call that plays one it is roughly started_at, not when a person picked up. connected_at is when the winning leg (a user's device or an external-number fallback) answered, i.e. when live conversation began; it is null when the call is answered by voicemail. For a direct call with no greeting the two coincide.

call.end

Sent when a call ends.

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v5",
"type": "call.end",
"created_at": "2026-01-15T14:35:30Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"call_id": "call_01h2xcejqtf2nbrexx3vqjhp45",
"direction": "inbound",
"from_number": "+14155551234",
"from_name": "John Smith",
"to_number": "+14155559876",
"user_id": "user_01h2xcejqtf2nbrexx3vqjhp42",
"to_user_id": "user_01h2xcejqtf2nbrexx3vqjhp42",
"started_at": "2026-01-15T14:30:00Z",
"answered_at": "2026-01-15T14:30:05Z",
"connected_at": "2026-01-15T14:30:47Z",
"ended_at": "2026-01-15T14:35:30Z",
"duration_seconds": 325,
"status": "completed"
}
}

connected_at is null when the call never connected to a person: the caller hung up during the greeting or while ringing, or the call was answered by voicemail (status: "voicemail"). This lets you distinguish a genuinely-handled call from one that only ever reached the platform.

status valueDescription
completedCall was answered and ended normally
no-answerCall rang but was not answered
busyCallee was busy or rejected the call
failedCall failed due to a network or system error
voicemailCall went to voicemail
direction valueDescription
inboundCall from PSTN to a DID
outboundCall from an endpoint to PSTN
internalExtension-to-extension or feature code

user_id is populated with the user most closely associated with the call: the caller for outbound; for inbound and internal, the user who answered — or, when the call ended unanswered, the single user whose phones were being rung (so a missed call is attributed to the user who missed it). It is omitted when no single user was involved — for example, an unanswered inbound ring group call, a call handled entirely by a voice app, or a dial plan call answered by a forward to an external number.

The generic call.* events are queue-agnostic; queue context is exposed via the dedicated queue.call.* events below. Subscribe to both streams and correlate by call_id when you need queue analytics alongside generic call state.

Queue call lifecycle

Queue lifecycle events are emitted in addition to the generic call.* lifecycle. Use them when you need queue-specific analytics or routing decisions without inferring queue state from call.end.

Common fields:

FieldTypeDescription
call_idstringCall identifier
queuestringQueue identifier
queue_namestringQueue display name
from_numberstringCaller's phone number (E.164)
from_namestring | nullCaller's display name
to_numberstringCalled phone number (E.164)
position_at_admitnumberCaller position when admitted to the queue, omitted when unavailable
wait_secondsnumberSeconds spent waiting in queue before this outcome

The three callback_* events are the exception: a callback is recorded against the queue rather than a live call, so they carry queue and the caller's number but not call_id, from_name, position_at_admit or wait_seconds, and only callback_requested carries queue_name.

Event-specific fields:

EventAdditional fields
queue.call.queuedqueued_at
queue.call.dispatchedagents_claimed, targets_dispatched, dispatched_at
queue.call.answeredagent_user, agent_endpoint, answered_at
queue.call.abandonedabandoned_at
queue.call.timed_outtimed_out_at
queue.call.completedagent_user, agent_endpoint, completed_at
queue.call.callback_requestedcallback, original_priority, original_entered_at, requested_at
queue.call.callback_attemptedcallback, attempt_number, result, next_attempt_at
queue.call.callback_failedcallback, attempts, reason

queue, agent_user, agent_endpoint and callback are also sent under the older queue_id, agent_user_id, agent_endpoint_id and callback_id keys. Those are deprecated aliases kept for existing integrations; use the canonical names above in new code.

queue.call.timed_out fires when the timeout behavior runs for an unanswered caller, whether because their Max wait elapsed or because Leave When Empty moved them on.

User attribution

call.initiated, call.incoming, call.answered, and call.end also carry from_user_id and to_user_id so both sides of a call are visible — most usefully on internal user-to-user calls, where user_id alone only identifies the answerer. Both fields are omitted when the side in question is not a user on this account (for example, an external PSTN caller has no from_user_id).

Call typefrom_user_idto_user_id
Inbound PSTN → useromitted (external caller)The routed user
Inbound PSTN → ring group, answeredomittedThe answering member
Inbound PSTN → voice app or dial planomittedomitted (until a user is rung — see below)
Inbound PSTN → user with Find Me / Follow MeomittedThe Find Me / Follow Me owner
Outbound from an endpointThe endpoint's useromitted (external destination)
Internal user-to-userThe calling userThe called user
Click-to-callThe initiating userThe answering endpoint's user, when applicable

On a dial plan call, to_user_id (and user_id) track the user step currently being rung, exactly as if that user had been called directly: set while a user's phones are ringing — and kept if the caller hangs up during that ring, so the missed call is attributed to them — then replaced by the answering user when the call is answered, and cleared when the plan moves past an unanswered step or a forward to an external number answers the call. A dial plan call answered by an external number therefore carries no user_id or to_user_id.

user_id is preserved with its original semantics as a convenience alias for existing integrations; new integrations should prefer from_user_id and to_user_id.

call.transfer

Sent when a call is transferred. A transfer always involves two calls, and each one receives its own webhooks, so you can track both sides. How the second call behaves depends on the transfer type:

  • Blind (cold) transfer — the caller is sent to a new destination without a consultation. This creates a brand-new call that runs its full lifecycle independently, including its own call.end when it finishes. You receive call.transfer on the original call plus a complete set of events for the new call.
  • Warm (attended) transfer — you first place a consultation call to the target, then complete the transfer. On completion the consultation call is merged into the primary call rather than hung up. Because it does not end on its own, the consultation call emits call.initiated and call.answered but not call.end — its call.transfer event is its final event. The primary call continues (now connected to the transfer target) and ends normally with its own call.end.

Both calls also appear in your call logs. To relate the two calls in a transfer, use related_call, which references the other call in the pair (each call's event points at the other). It is populated whenever the two calls can be linked; if it is null, correlate them using the phone numbers and timestamps.

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v6",
"type": "call.transfer",
"created_at": "2026-01-15T14:32:00Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"call_id": "call_01h2xcejqtf2nbrexx3vqjhp45",
"direction": "inbound",
"from_number": "+14155551234",
"to_number": "+14155559876",
"transferred_to": "1001",
"transferred_by": "user_01h2xcejqtf2nbrexx3vqjhp42",
"related_call": "call_01h2xcejqtf2nbrexx3vqjhp47",
"transferred_at": "2026-01-15T14:32:00Z"
}
}

call.emergency

Sent when an emergency number is dialed from any endpoint on the account. Subscribers can route this event to a paging system, security desk, or any other on-call destination.

The event also fires for carrier emergency test numbers (such as 933 in the US) so you can verify your wiring end-to-end without contacting an actual public safety answering point.

e911_provisioned is false when the dialing endpoint had no provisioned dispatchable location at the time of the call. In that case location_id and location_name are omitted from the payload. Treat that combination as a signal that an emergency call went out without an accurate address — the account holder should provision the location before the next call.

e911_provisioned is omitted entirely when the emergency call was placed via a transfer. On a transfer to an emergency number the platform deliberately attaches no dispatchable location — it cannot know whether the transferring or transferred party's address is correct — and lets the public safety answering point resolve the location. The field has no meaning in that case, so its absence (rather than false) distinguishes a transfer from an unprovisioned direct call.

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v8",
"type": "call.emergency",
"created_at": "2026-05-03T18:30:00Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"call_id": "call_01h2xcejqtf2nbrexx3vqjhp45",
"to_number": "911",
"user_id": "user_01h2xcejqtf2nbrexx3vqjhp47",
"device_id": "dev_01h2xcejqtf2nbrexx3vqjhp49",
"extension": "1001",
"location_id": "loc_01h2xcejqtf2nbrexx3vqjhp48",
"location_name": "Headquarters",
"location_address": "123 Main St, Springfield, IL 62701, US",
"e911_provisioned": true,
"placed_at": "2026-05-03T18:30:00Z"
}
}

voicemail.new

Sent when a new voicemail is received. Use GET /v1/voicemails/{voicemail_id} to retrieve the full voicemail details and audio.

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v7",
"type": "voicemail.new",
"created_at": "2026-01-15T14:36:00Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"voicemail_id": "vm_01h2xcejqtf2nbrexx3vqjhp46",
"user_id": "user_01h2xcejqtf2nbrexx3vqjhp42",
"call_id": "call_01h2xcejqtf2nbrexx3vqjhp45",
"from_number": "+14155551234",
"from_name": "John Smith",
"duration_seconds": 42,
"created_at": "2026-01-15T14:36:00Z"
}
}

user_id is included when the voicemail belongs to a single user's mailbox and is omitted when it belongs to a shared voicemail box. call_id identifies the call that produced the voicemail — use it to correlate with GET /v1/calls/{call_id} — and is omitted when the call could not be resolved.

recording.available

Sent when a call recording is ready for download. Use GET /v1/calls/{call_id}/recording to download the audio file.

A recording covers the conversation, which does not always run to the end of the call — so this event can arrive before call.end, and you should not assume it means the call is over. The common example is a call that reaches voicemail: the recording stops at that point, and the voicemail message itself is delivered separately via voicemail.new. See Ordering.

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v8",
"type": "recording.available",
"created_at": "2026-01-15T14:35:35Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"call_id": "call_01h2xcejqtf2nbrexx3vqjhp45",
"duration_seconds": 325
}
}

recording.failed

Sent when a call recording could not be prepared and will never become available. On accounts with PII redaction enabled, recording.available is withheld until redaction succeeds; if redaction cannot complete, the audio is discarded and this event is sent instead so you are not left waiting for a recording that will never arrive. No recording is retrievable for the call.

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1w0",
"type": "recording.failed",
"created_at": "2026-01-15T14:35:40Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"call_id": "call_01h2xcejqtf2nbrexx3vqjhp45",
"error_code": "audio_redaction_failed",
"reason": "audio could not be redacted"
}
}

Branch on error_code, not the free-form reason:

error_codeMeaning
audio_redaction_failedThe audio could not be redacted (unreadable recording, or sensitive speech that could not be located in the audio)
transcription_failedThe call could not be transcribed, so sensitive speech could not be found
internal_errorAn internal error prevented the recording from being processed

reason is an optional human-readable sentence for logging; new codes may be added, so treat an unrecognized error_code as a generic failure.

recording.transcription.complete

Sent when a call transcription is ready. Use GET /v1/calls/{call_id}/transcript to retrieve the full transcript text.

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1v9",
"type": "recording.transcription.complete",
"created_at": "2026-01-15T14:35:45Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"call_id": "call_01h2xcejqtf2nbrexx3vqjhp45",
"status": "completed"
}
}

recording.summary.complete

Sent when the AI enrichment for a call transcript is ready. This covers both the summary and the sentiment object — they are derived from the same transcript and committed together, so one event signals both. Use GET /v1/calls/{call_id}/transcript to retrieve them; sentiment also appears on the call record itself.

Both are best-effort, so the event can fire with only one of the two present.

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1va",
"type": "recording.summary.complete",
"created_at": "2026-01-15T14:36:00Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"call_id": "call_01h2xcejqtf2nbrexx3vqjhp45"
}
}

voicemail.transcription.complete

Sent when a voicemail transcription is ready. Use GET /v1/voicemails/{voicemail_id}/transcript to retrieve the transcript text.

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1vb",
"type": "voicemail.transcription.complete",
"created_at": "2026-01-15T14:36:10Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"voicemail_id": "vm_01h2xcejqtf2nbrexx3vqjhp46",
"user_id": "user_01h2xcejqtf2nbrexx3vqjhp42",
"call_id": "call_01h2xcejqtf2nbrexx3vqjhp45",
"status": "completed"
}
}

call_id identifies the call that produced the voicemail and is omitted when the call could not be resolved.

voicemail.summary.complete

Sent when the AI enrichment for a voicemail transcript is ready — both the summary and the sentiment object, which are derived from the same transcript and committed together. Use GET /v1/voicemails/{voicemail_id} to retrieve them.

Both are best-effort, so the event can fire with only one of the two present. Voicemail audio is single-channel, so its sentiment carries no local/remote breakdown.

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1vc",
"type": "voicemail.summary.complete",
"created_at": "2026-01-15T14:36:20Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"voicemail_id": "vm_01h2xcejqtf2nbrexx3vqjhp46",
"user_id": "user_01h2xcejqtf2nbrexx3vqjhp42",
"call_id": "call_01h2xcejqtf2nbrexx3vqjhp45"
}
}

call_id identifies the call that produced the voicemail and is omitted when the call could not be resolved.

Fax events

The three fax events share a payload shape so a single fax.* handler can process all of them. fax.received is an inbound event; fax.delivered and fax.failed are outbound outcomes. They are terminal — a fax produces exactly one of fax.delivered or fax.failed, never both, and there are no intermediate queued/sending events.

Common fields:

FieldTypeDescription
fax_idstringFax identifier. Use GET /v1/faxes/{fax_id} for full details
from_numberstringSending fax number (E.164)
to_numberstringReceiving fax number (E.164)
pagesnumber | nullPages transmitted (see per-event notes)
transportstring | nullNegotiated fax transport: "t38" (fax over IP) or "g711" (fax over audio)

The transport value reflects how the fax was carried, not whether it succeeded — read fax.delivered vs fax.failed for the outcome. It is always present on fax.received; on the outbound events it is null when the call ended before a transport was negotiated.

fax.received

Sent when an inbound fax has been fully received and converted. pages and transport are always present.

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1vd",
"type": "fax.received",
"created_at": "2026-01-15T14:40:00Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"fax_id": "fax_01h2xcejqtf2nbrexx3vqjhp4a",
"from_number": "+14155551234",
"to_number": "+14155559876",
"pages": 3,
"transport": "t38"
}
}

fax.delivered

Sent when an outbound fax completed successfully — the receiving machine confirmed the full document. pages is the number of pages confirmed delivered.

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1ve",
"type": "fax.delivered",
"created_at": "2026-01-15T14:42:00Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"fax_id": "fax_01h2xcejqtf2nbrexx3vqjhp4b",
"from_number": "+14155559876",
"to_number": "+14155551234",
"pages": 2,
"transport": "t38"
}
}

fax.failed

Sent when an outbound fax did not complete. Carries an error_code in addition to the common fields.

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1vf",
"type": "fax.failed",
"created_at": "2026-01-15T14:44:00Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"fax_id": "fax_01h2xcejqtf2nbrexx3vqjhp4c",
"from_number": "+14155559876",
"to_number": "+14155551234",
"pages": null,
"transport": null,
"error_code": "max_attempts_exceeded"
}
}
FieldTypeDescription
error_codestring | nullA short code for why the fax failed — e.g. dial_busy, dial_no_answer, max_attempts_exceeded, fax_failed. null when no code was determined
pagesnumber | nullnull when the page count is unknown (the fax never began transmitting); 0 when it connected but failed before the first page; N when N pages were sent before failing
transportstring | nullnull when the call failed before a transport was negotiated

fax.delivered and fax.failed are mutually exclusive and final. A fax.delivered means the receiving machine acknowledged the whole document; a fax.failed means it did not, after all automatic retries.

Device provisioning events

The three device.* events report on a physical phone fetching its configuration from the provisioning server. They share a payload shape so a single device.* handler can process all of them.

Pick the event that matches what you are reacting to:

  • device.provisioned fires each time a device leaves the pending-sync state by successfully fetching its configuration — normally exactly once, at install. This is the "installation complete" signal — use it to kick off downstream provisioning workflows. A device you deliberately set back to pending-sync (POST /v1/devices/:id) emits again on its next fetch, so treat a re-provision as a legitimate repeat rather than assuming one event per device forever.
  • device.config_fetched fires on every successful fetch. A deployed phone re-reads its configuration on each reboot and each check-sync, so expect this event repeatedly over a device's life, not just at install.
  • device.provisioning_failed fires when a device was recognized but its configuration could not be generated.

Common fields:

FieldTypeDescription
devicestringDevice identifier — dev_… for a deskphone, dectb_… for a DECT base
device_typestring"deskphone" or "dect_base"
mac_addressstringThe device's MAC address
vendorstringDevice vendor, e.g. "snom", "yealink", "polycom"
modelstring | nullDevice model. null until the device reports a model we recognize — it is detected on first contact
ip_addressstring | nullSource IP of the configuration request. null when it could not be determined

device.provisioned

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1w1",
"type": "device.provisioned",
"created_at": "2026-01-15T14:50:00Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"device": "dev_01h2xcejqtf2nbrexx3vqjhp4d",
"device_type": "deskphone",
"mac_address": "000413AABBCC",
"vendor": "snom",
"model": "D785",
"ip_address": "203.0.113.42",
"provisioned_at": "2026-01-15T14:50:00Z"
}
}

device.config_fetched

Same fields, with fetched_at in place of provisioned_at.

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1w2",
"type": "device.config_fetched",
"created_at": "2026-01-15T18:05:00Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"device": "dev_01h2xcejqtf2nbrexx3vqjhp4d",
"device_type": "deskphone",
"mac_address": "000413AABBCC",
"vendor": "snom",
"model": "D785",
"ip_address": "203.0.113.42",
"fetched_at": "2026-01-15T18:05:00Z"
}
}

device.provisioning_failed

Carries an error_code instead of model and ip_address.

JSON
{
"id": "evt_01jqr5k8m3n4p6q7r8s9t0u1w3",
"type": "device.provisioning_failed",
"created_at": "2026-01-15T14:52:00Z",
"account_id": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"device": "dev_01h2xcejqtf2nbrexx3vqjhp4d",
"device_type": "deskphone",
"mac_address": "000413AABBCC",
"vendor": "snom",
"error_code": "invalid_configuration",
"failed_at": "2026-01-15T14:52:00Z"
}
}
FieldTypeDescription
error_codestringWhy configuration could not be generated — unsupported_vendor, invalid_configuration, or generation_failed

A request that fails before the device is identified — an unknown provisioning URL, or an unrecognized device — produces no event, since it cannot be attributed to an account.

Billable count changes

Four events report changes to the counts that drive your bill, as they happen, so you can reconcile before an invoice arrives rather than after:

  • user.created / user.deleted — a user became or stopped being billable.
  • phone_number.activated / phone_number.disconnected — a number went into or out of service.

Each event names the resource that changed and carries the billable count either side of the change, so you can follow the running total without recounting:

JSON
{
"id": "evt_01h2xcejqtf2nbrexx3vqjhp4e",
"type": "user.created",
"created_at": "2026-01-15T14:50:00Z",
"account": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"user": "user_01h2xcejqtf2nbrexx3vqjhp4f",
"previous_billable_users": 11,
"current_billable_users": 12,
"request": "8f14e45fceea167a5a36dedd4bea2543",
"changed_at": "2026-01-15T14:50:00Z"
}
}

The phone_number.* events use the same shape with previous_billable_dids / current_billable_dids, plus the number itself and the order it came from:

JSON
{
"id": "evt_01h2xcejqtf2nbrexx3vqjhp4g",
"type": "phone_number.activated",
"created_at": "2026-01-15T14:51:00Z",
"account": "acct_01h2xcejqtf2nbrexx3vqjhp41",
"data": {
"phone_number": "did_01h2xcejqtf2nbrexx3vqjhp4h",
"number": "+14155550123",
"order": "nord_01h2xcejqtf2nbrexx3vqjhp4i",
"previous_billable_dids": 3,
"current_billable_dids": 4,
"changed_at": "2026-01-15T14:51:00Z"
}
}
FieldTypeDescription
user / phone_numberstringThe resource whose billable state changed
numberstringThe phone number in E.164 format (phone_number.* only)
orderstringThe number order or port order this change came from, when there was one
previous_billable_users / previous_billable_didsintegerThe billable count immediately before this change
current_billable_users / current_billable_didsintegerThe billable count immediately after this change
requeststringCorrelation ID for the API request that made the change, when there was one — see below
changed_atstringWhen the change took effect

These are resource counts, not invoice amounts

The counts tell you how many billable resources are in service, not what the next invoice will charge. Billing is prorated: a user added halfway through a billing period is charged for the remainder of that period, not a full period. So a change of +1 in these counts is not a change of one full unit on your bill. Use these events to reconcile what you have, and the invoice for what you owe.

A batch — one order activating several numbers — sends one event per number, each carrying its own step of the count, so the sequence reads as a running total.

Reassigning a number between two of your accounts sends two events: a phone_number.disconnected on the account losing it and a phone_number.activated on the account gaining it. Both carry the same request, so you can pair them into one move from the payloads alone — but see below for which of the two you can look that id up against.

What the running total does not cover

Every change that moves a billable count sends an event, with two exceptions:

  • Deleting an account. Releasing its numbers and removing its users sends nothing — the account itself is gone, so there is nothing left to reconcile.
  • Sandbox accounts. Sandbox users and numbers are never billed, so provisioning one asserts no billable change. See Testing your integration for how to trigger these events in sandbox on demand.

These are a signal, not a ledger

Read the count off current_* rather than accumulating your own tally: every event carries the account's real count at the instant it was taken, so the most recent event you have is the best number you have.

Do not use the previous_*/current_* chain as a dropped-delivery check. Two changes committed at nearly the same moment each read the count after both have landed, so they can legitimately report the same pair — a chain that lines up is not proof you missed nothing, and a break in it is not proof you did. Delivery is best-effort besides: the event is sent after the change is committed, so the change can stand with no event behind it.

When the count has to be exact — reconciling an invoice, answering an audit — recount from the API (GET /v1/users, GET /v1/phone-numbers) and use these events for what they are: prompt notice that something moved.

Finding out who made the change

These payloads deliberately carry no actor. Instead, every change is attributable through the audit log, which is a richer and permission-aware record. For user.*, take the request value from the event and look it up:

Bash
curl "https://api.dialstack.ai/v1/audit-logs?request_id=8f14e45fceea167a5a36dedd4bea2543" \
-H "Authorization: Bearer sk_live_..."

The matching entry's actor identifies who performed the change. For a person, resolve it to a name and email:

Bash
curl "https://api.dialstack.ai/v1/admin/users/{actor}" \
-H "Authorization: Bearer sk_live_..."

phone_number.* events split between the two join keys, and the reason is worth knowing. A number usually goes into service when the upstream provider confirms the order, not at the moment someone clicked, so there is no caller request to correlate — and the audit entry for the number itself records the platform, not a person. The human who requested the number is on the order's audit entry, which is why those events carry order and no request. Look that up instead:

Bash
curl "https://api.dialstack.ai/v1/audit-logs?resource=nord_01h2xcejqtf2nbrexx3vqjhp4i" \
-H "Authorization: Bearer sk_live_..."

The other way round when the change came from a direct API call — activating or deactivating a number, reassigning one, recovering a released one, or keeping a temporary one. There is no order to point at, but there is a caller, so those events carry request and no order. Look them up the same way as user.*, with ?request_id=.

One exception on a reassignment: the audit entry is written under the account whose key made the call — the account losing the number. So ?request_id= resolves the actor against the source account, and returns nothing against the target. On the target event, treat request as a correlation id that pairs it with its source event, not as something you can look up on that account.

Webhook Protocol

Request Format

DialStack sends a POST request to each matching endpoint's url with the event as the JSON body. An event is delivered to an endpoint only when the endpoint's mode matches the originating account's mode and its enabled_events includes the event type (or is ["*"]).

Headers:

HeaderDescription
Content-Typeapplication/json
X-DialStack-SignatureHMAC-SHA256 signature for verification
X-DialStack-Account-IdAccount where the event occurred
User-AgentDialStack-Webhook/1.0

Signature Verification

Every webhook request is signed using HMAC-SHA256 with the receiving endpoint's secret. Each endpoint has its own secret, so verify against the secret of the endpoint that received the request. The signature header format is:

Text
X-DialStack-Signature: t=1705312530,v1=a1b2c3d4e5f6...

To verify:

  1. Extract the timestamp (t) and signature (v1) from the header
  2. Construct the signed payload: {timestamp}.{request_body}
  3. Compute HMAC-SHA256 of the signed payload using your webhook secret
  4. Compare with the provided signature

For detailed verification examples in Node.js, Python, and Go, see Appointment Webhooks — Signature Verification.

Expected Response

Return any 2xx status code to acknowledge receipt. The response body is ignored. Non-2xx responses trigger a retry.

Retry Behavior

Failed deliveries (non-2xx response or timeout) are retried automatically:

  • Up to 5 retry attempts at approximately 60-second intervals
  • 30-second timeout per delivery attempt
  • After all retries are exhausted, the event is moved to a dead letter queue for investigation

When an account has multiple matching endpoints, a retry re-delivers the event to all of them, so an endpoint that already responded 2xx may receive the event again. Combined with at-least-once delivery, this is why you should handle events idempotently using the id field.

Ordering

Delivery preserves the order in which we publish events for an account. Events for different accounts may be delivered in parallel — do not assume ordering across accounts.

Publication order is not the same as the call's lifecycle order. call.* events are published by the call-processing tier as the call progresses, while recording.* and voicemail.* are published separately once the underlying media has been processed. The two families are not ordered relative to each other.

In particular, recording.available can arrive before call.end for the same call, because a recording tracks the conversation rather than the call: whenever a call outlives its conversation, the recording is complete and delivered while the call is still up. The common example is voicemail — the recording ends when the call reaches the mailbox, and call.end follows only when the caller hangs up, potentially tens of seconds later. Treat this as the general rule rather than a voicemail special case; other flows can separate the two the same way.

Correlate related events by call_id and treat each event as independently meaningful. Do not treat the arrival of one event type as evidence that another has already been delivered, and do not hold up processing of one family while waiting for the other.

Timing

EventTypical Latency
call.initiated< 1 second
call.incoming< 1 second
call.ringing< 1 second
call.mobile_push_wakeup< 1 second
call.answered< 1 second
call.end< 1 second
call.transfer< 1 second
queue.call.*< 1 second
voicemail.new< 2 seconds
recording.available< 5 seconds after the recording ends — the call ending, or the call reaching voicemail (see Ordering); on redaction-enabled accounts, after redaction completes (transcription + audio rewrite)
recording.failedOn redaction-enabled accounts, when redaction cannot complete
recording.transcription.complete~10 seconds after recording
recording.summary.complete~15 seconds after recording
voicemail.transcription.complete~10 seconds after voicemail
voicemail.summary.complete~15 seconds after voicemail
fax.received< 1 second after receipt completes
fax.delivered< 1 second after transmission completes
fax.failed< 1 second after the final attempt

Best Practices

  • Verify signatures on every request to prevent spoofing
  • Return 200 quickly and defer processing — long-running handlers risk timeouts
  • Handle events idempotently using the id field — events may be delivered more than once
  • Don't rely on strict cross-account ordering — events from different accounts may arrive in any order
  • Correlate by call_id, not arrival orderrecording.* and voicemail.* are not ordered against call.*, so recording.available may precede call.end (see Ordering)