Skip to content
Inkbox

Inkbox

DocsPricingBlogContact
GuidesAPI ReferenceChangelog

Ctrl K

GuidesAPI ReferenceChangelog

Jump to

Webhooks

Webhooks let you receive HTTP POST callbacks for phone events. Text events are delivered via the Webhook Subscriptions API — attach a subscription to the phone number with the subset of text.* events you want. The call-lifecycle event (call.ended) is delivered the same way, via a subscription on the agent identity. The incoming-call event stays a per-number control-plane callback, configured on the phone number resource via incoming_call_webhook_url.

Event types

EventDescription
phone.incoming_callAn inbound call hit a phone number with incoming_call_action set to "webhook"
call.endedA call terminated — fired once per call, via an agent-identity subscription. See when it fires
text.receivedAn inbound SMS or MMS arrived on a phone number
text.sentAn outbound SMS or MMS was accepted by the carrier
text.deliveredThe carrier confirmed delivery to the recipient handset
text.delivery_failedThe carrier reported a permanent delivery failure
text.delivery_unconfirmedThe carrier could not confirm delivery within the carrier's reporting window

phone.incoming_call is not subscribable. Its handling is synchronous (your response decides whether Inkbox answers the call), so it can't fan out. Configure it via incoming_call_webhook_url on the phone number resource.

call.ended is owned by the agent identity rather than a phone number — a call may ride the identity's shared iMessage line rather than a number you own, and the identity is the stable owner either way. Subscribe with agent_identity_id.

Inbound texts rejected by a contact-rule block or by default-block in whitelist mode are retained for admin review but do not emit text.received webhooks.


Configuring incoming-call webhooks PATCH

PATCH /numbers/{phone_number_id}

To enable incoming-call webhooks, set incoming_call_action to "webhook" and provide an incoming_call_webhook_url on the phone number.

Request body

FieldTypeDescription
incoming_call_actionstringSet to "webhook" to enable webhook callbacks for incoming calls
incoming_call_webhook_urlstringHTTPS endpoint to receive incoming-call webhook POSTs

Request example

JSONJSON

Code examples


Configuring text webhooks

Text events are delivered via the Webhook Subscriptions API. Two steps:

  1. Have a phone number provisioned (see Phone numbers).
  2. Create a subscription naming that phone number, the HTTPS destination URL, and the subset of text.* event types you want delivered.

You can attach up to 20 active subscriptions per number — split events across URLs, or fan one URL out across many numbers from many subscription rows. Creating a 21st returns 409; delete an existing one first.

Request example

JSONJSON

Code examples

See Webhook Subscriptions for the full CRUD surface, validation rules, and per-route error codes.


Configuring call-lifecycle webhooks

call.ended is delivered via the same Webhook Subscriptions API, but the subscription is owned by the agent identity — pass agent_identity_id instead of phone_number_id. A subscription carries one channel, so call.ended cannot share a row with imessage.* events; create a second subscription on the same identity if you want both.

Request example

JSONJSON

Delivery is fire-and-forget: the response status is logged but never affects call processing. See the Webhooks guide for SDK examples and typed handlers.


Incoming call webhook

When a phone.incoming_call event fires, Inkbox sends a signed POST request to the configured webhook URL. The payload is flat — there is no {event_type, timestamp, data} envelope, and contacts + agent_identities sit at the top level alongside the call fields. Your endpoint must respond with an action instructing Inkbox how to handle the call.

Payload shape

JSONJSON

Payload fields

FieldTypeDescription
idUUIDUnique call identifier
originstring"dedicated_number" or "shared_imessage_number"
local_phone_numberstring | nullYour number that the call hit (E.164) for dedicated_number calls. null for shared_imessage_number calls — the shared iMessage line is never surfaced to the webhook.
remote_phone_numberstringThe caller (E.164)
directionstringAlways "inbound" for this webhook
statusstringCall status at the time the webhook fires (e.g. "initiated", "ringing")
client_websocket_urlstring | nullThe phone number's configured fallback WebSocket URL, if any
use_inkbox_ttsboolean | nullWhether the call uses Inkbox-managed TTS
use_inkbox_sttboolean | nullWhether the call uses Inkbox-managed STT
hangup_reasonstring | nullAlways null for inbound webhooks (call hasn't ended yet)
started_atstring | nullAlways null for inbound webhooks
ended_atstring | nullAlways null for inbound webhooks
created_atstringISO 8601 timestamp
updated_atstringISO 8601 timestamp
rate_limitobject | nullOptional rate-limit snapshot for the organization
contactsarrayAddress-book matches for remote_phone_number. Always present, possibly empty. See Peer resolution below.
agent_identitiesarrayInternal-agent matches for remote_phone_number. Always present, possibly empty.

When origin is "shared_imessage_number", the inbound call arrived on the identity's shared iMessage line and local_phone_number is null.

contacts and agent_identities are always present on the wire and possibly empty. A peer that's both a contact and an internal agent appears once in each list.

Blocked calls do not dispatch webhooks — receivers only ever see calls that survived the inbound filter.

Response

Your endpoint must respond with an action instructing Inkbox how to handle the call. client_websocket_url is optional — omit it (or send null) to fall back to the phone number's configured client_websocket_url.

Override the fallback by supplying a per-call URL:

JSONJSON

Use the phone number's configured fallback by omitting the field:

JSONJSON

Response fields

FieldTypeRequiredDescription
actionstringYes"answer" to accept the call, "reject" to decline.
client_websocket_urlstring | nullNoWebSocket URL (wss://) that Inkbox connects to for the call. Can carry text or audio, depending on how the connection is configured. Omit, or send null, to fall back to the phone number's configured client_websocket_url. Ignored when action is "reject".

Call-ended webhook

call.ended fires once per call, after a connected call terminates. Calls that never connect (unanswered, rejected, or failed before pickup) don't emit the event — with one exception: Inkbox Voice AI calls report every terminal state, inbound and placed alike, so the event arrives even when the call never connected, with outcome saying what happened. A Voice AI call is never silent. The payload uses the standard {event_type, timestamp, data} envelope, plus a stable top-level id (evt_...) that survives retries and replays — use it as your idempotency key.

Payload shape

JSONJSON

Envelope fields

FieldTypeDescription
idstringStable event identifier (evt_...). Identical across retries and replays — dedupe on it.
event_typestringAlways "call.ended"
timestampstringISO 8601 dispatch timestamp
dataobjectThe event body — see below

data.call fields

The webhook wire shape of the call — the call object minus is_blocked (blocked calls never dispatch lifecycle webhooks) and the connection-config fields, plus a derived duration_seconds. Post-call action items don't ride inside data.call — they arrive at data.post_call_action_items (below).

FieldTypeDescription
idUUIDUnique call identifier
originstring"dedicated_number" or "shared_imessage_number"
local_phone_numberstring | nullYour number (E.164) for dedicated_number calls. null for shared_imessage_number calls — the shared line is never surfaced.
remote_phone_numberstringThe other party's number (E.164)
directionstring"inbound" or "outbound"
statusstringTerminal status. "completed" for every connected call; "canceled" or "failed" when the call never connected
modestringWho drove the call: "client_websocket" or "hosted_agent". Always present
reasonstring | nullThe voice agent's task brief on outbound hosted_agent calls; null on inbound and client_websocket calls
hangup_reasonstring | nullWhy the call ended: "local", "remote", "max_duration", "voicemail", "rejected", or "failed"
started_atstring | nullWhen the call was answered (ISO 8601); null when the call never connected
ended_atstring | nullWhen the call ended (ISO 8601)
created_atstringCreation timestamp (ISO 8601)
updated_atstringLast updated timestamp (ISO 8601)
duration_secondsinteger | nullWhole seconds from started_at to ended_at

data fields

FieldTypeDescription
callobjectThe terminated call — see above
outcomestring | nullHow an Inkbox Voice AI call turned out: "completed", "no_answer", "declined", or "failed". null on client_websocket calls — see below
post_call_action_itemsarrayAction items the voice agent recorded during the call, in order. Always present, empty for client_websocket calls and Voice AI calls with no items — see below
contactsarrayAddress-book matches for remote_phone_number. Always present, possibly empty. See Peer resolution.
agent_identitiesarrayInternal-agent matches for remote_phone_number. Always present, possibly empty.
transcriptobject | nullInline abridged transcript block, present when the call has transcribed turns at dispatch time; null otherwise
transcript_urlstringAlways present. Points at GET /phone/calls/{call_id}/transcripts — the authoritative verbatim record (requires an API key).

outcome values

outcome describes how an Inkbox Voice AI call turned out; it is null whenever data.call.mode is "client_websocket".

ValueMeaning
completedThe call connected and ran to a normal end. A call that reaches voicemail also reports completed — the transcript shows what happened
no_answerThe call was never answered — an outbound call rang out, or an inbound caller hung up before the agent picked up
declinedThe call was rejected, or the line was busy
failedThe Voice AI session hit an error and the call was torn down, or the call could not be completed at all — e.g. an undeliverable destination or a dial that failed before ringing

post_call_action_items items

Ordered by seq, ascending. Items the agent canceled during the call are omitted from the payload. The call.ended event delivers the items atomically with the transcript and outcome — and like every webhook it carries a stable ID and is replayable, so a missed delivery can be re-fetched. The same open items are also readable off the call object (post_call_action_items) at any time.

FieldTypeDescription
idUUIDUnique action-item identifier
seqintegerStable 1-based ordering within the call
actionstringShort imperative title of the action item
detailsstring | nullFree-form context — amounts, dates, verbatim quotes
statusstringAlways "open" in the payload (canceled items are omitted)

transcript block

entries is an ordered mix of transcript turns and, when the transcript was cut to fit the size budget, a single middle-cut marker:

EntryFieldsDescription
Turnparty, text, ts_msOne transcribed turn. party is "local" (your agent) or "remote" (the other party); ts_ms is milliseconds from the start of the call.
Markermarker, omitted_turns, omitted_msmarker is always "abridged"; omitted_turns / omitted_ms count the turns and milliseconds cut from the middle.
FieldTypeDescription
entriesarrayOrdered turns, with at most one {"marker": "abridged"} entry where the middle was cut
abridgedbooleantrue when anything was omitted
urlstringSame value as data.transcript_url

The inline block reads transcripts as of dispatch time and may lag the very last turn — treat transcript_url as the source of truth when you need the complete transcript.

mode, reason, outcome, and post_call_action_items are additive — consumers written before they existed keep parsing unchanged, and the SDK payload types default them (SDK 0.4.22).


Text webhooks

All five text.* events share the standard {event_type, timestamp, data} envelope. data.text_message carries the stored text-message record. Two parallel peer-resolution lists ride alongside it:

All five events share the standard {event_type, timestamp, data} envelope. data.text_message carries the stored text-message record. For 1:1 traffic, the legacy remote_phone_number field stays populated. For outbound group lifecycle events, remote_phone_number is null; use data.recipient_phone_number for the recipient this event is about and data.text_message.recipients[] for the full per-recipient state.

On text.received, a subscription that configures conversation context also receives an additive data.context block; it is absent otherwise.

Inbound — text.received

JSONJSON

On inbound rows, sender_phone_number carries the remote sender (same value as remote_phone_number); recipients is null; data.recipient_phone_number is null.

The four outbound events share the same envelope and message shape as text.received. The headline value of these events is the delivery-state block. For 1:1 messages, delivery_status, error_code, error_detail, sent_at, delivered_at, and failed_at are mirrored on text_message; for group messages, delivery_status is a message-level rollup while per-recipient lifecycle details live in text_message.recipients[].

For groups, the top-level text_message.delivery_status is a rollup across recipients[] — the lowest lifecycle rank present, with "delivered" preferred at the terminal tier on mixed-outcome ties. Outbound events fan out one webhook per per-recipient transition, so a text.delivered event for one leg can fire while other legs are still "sent" — the rollup will still read "sent" until every leg reaches a terminal state. Treat recipients[].delivery_status as authoritative per-leg; use the rollup only for at-a-glance UI.

The four outbound events share the same envelope and message shape as text.received. The headline value is the delivery-state blockdelivery_status, error_code, error_detail, sent_at, delivered_at, failed_at — which lets receivers act on outbound failures without a follow-up API call. On 1:1 outbound rows, those fields are hoisted out of recipients[0] for back-compat.

Example: text.delivery_failed on a 1:1 outbound send.

JSONJSON

Example: outbound group text.delivered for one recipient.

JSONJSON

The three other outbound events have the same shape with different terminal fields:

  • text.sent — carrier accepted the message. delivery_status is "sent", sent_at is populated, delivered_at and failed_at are null.
  • text.delivered — carrier confirmed handset delivery. delivery_status is "delivered", both sent_at and delivered_at are populated.
  • text.delivery_unconfirmed — carrier could not confirm delivery within its reporting window. delivery_status is "delivery_unconfirmed", sent_at and failed_at are populated, delivered_at is null.

Outbound group lifecycle (group MMS)

When you send a group MMS (2+ recipients), each lifecycle event fires once per recipient, with data.recipient_phone_number identifying which leg the event is about. The text_message row carries remote_phone_number: null (no single remote party) and a top-level delivery_status rollup; legacy top-level timestamp/error fields stay null, and per-leg state lives in recipients[].

Example: text.delivered for one leg of a 3-recipient group send.

JSONJSON

Receivers should detect group outbound by text_message.direction === "outbound" && text_message.remote_phone_number === null, then read data.recipient_phone_number to identify the leg and find the matching entry in text_message.recipients[] for that leg's delivery state.

Per-recipient × per-subscription fan-out

A 3-recipient group send with 2 active text subscriptions produces 6 POSTs per lifecycle stage — one per recipient leg, per subscription. Each POST carries the full group state in text_message.recipients[] plus the leg-identifying data.recipient_phone_number. Per-URL POSTs are independent: a slow receiver doesn't block delivery to the others. See Phone texts for the API surface around sending into a conversation_id or addressing a group send.

text_message field reference

FieldTypeDescription
idUUIDUnique text-message identifier
directionstring"inbound" or "outbound"
local_phone_numberstringYour phone number (E.164)
remote_phone_numberstring | nullThe other party (E.164). Populated on inbound and 1:1 outbound; null on group outbound (no single remote party).
textstring | nullMessage body. May be null for media-only MMS.
typestring"sms" or "mms"
mediaarray | nullAttached media items on MMS
delivery_statusstring | nullLatest lifecycle status ("queued", "sent", "delivered", "delivery_failed", "delivery_unconfirmed"). null on inbound. On group outbound this is a rollup across recipients[]; treat recipients[].delivery_status as authoritative per leg.
error_code / error_detailstring | nullCarrier error details when delivery failed (1:1 only)
sent_at / delivered_at / failed_atstring | nullLifecycle timestamps (1:1 only)
conversation_idUUID | nullParent conversation thread
sender_phone_numberstring | nullThe sender (inbound only; outbound rows have no sender — implicit from local_phone_number)
recipientsarray | nullPer-recipient delivery rows. null on inbound; one entry on outbound 1:1 (with per-leg fields hoisted to the top-level lifecycle fields for back-compat); ≥2 entries on group outbound.

recipients[i] shape

FieldTypeDescription
recipient_phone_numberstringThe recipient (E.164)
delivery_statusstring | nullLifecycle status for this leg
carrierstring | nullCarrier reported by the upstream MNO lookup
line_typestring | nullE.g. "mobile", "landline"
error_code / error_detailstring | nullPer-leg carrier error details
sent_at / delivered_at / failed_atstring | nullPer-leg lifecycle timestamps

Conversation context

When a subscription sets context_config, text.received payloads gain an additive data.context object keyed by the configured classes (email, texts, calls). The texts class is conversation-scoped (the triggering conversation, with SMS and iMessage merged); email and calls resolve contact-scoped history through the matched contact. Delivery-status events never carry context.

The block shape — per-class fields, item shapes, transcript abridgment, skip reasons, and the 256 KB cap — is documented on the Mail webhooks page, and the configuration shape on the Webhook Subscriptions page.

Peer resolution

Inbound-call payloads carry top-level contacts and agent_identities. Text and call-ended payloads carry data.contacts and data.agent_identities. Both are lists — always present and possibly empty — never null.

  • Field shapes: contacts are { "id": "<uuid>", "name": "<preferred name>" }; agent identities are { "id": "<uuid>", "agent_handle": "<handle>", "display_name": "<name or null>" }.
  • Scope: contacts are organization-wide, so contact matches are the same for every identity. Agent-identity matches are filtered by Agent visibility for the identity that owns the receiving phone number or, for call-lifecycle events, the subscription. Agents always match themselves.
  • Inbound calls: match key is top-level remote_phone_number.
  • Call-ended events: match key is data.call.remote_phone_number.
  • Inbound texts: match key is data.text_message.remote_phone_number, also available as data.text_message.sender_phone_number.
  • Outbound 1:1 text lifecycle events: match key is data.text_message.remote_phone_number; data.recipient_phone_number is null.
  • Outbound group text lifecycle events: match key is data.recipient_phone_number, the recipient this lifecycle event is about. Use data.text_message.recipients[] to inspect every recipient in the conversation send.
  • Tiebreak (contacts): if multiple contacts share the same phone, the oldest by created_at wins. Receivers needing disambiguation can call GET /contacts/lookup.
  • Hydration: feed contacts[i].id into GET /api/v1/contacts/{contact_id}. For identities the endpoint is keyed by handle, not UUID: feed agent_identities[i].agent_handle into GET /api/v1/identities/{agent_handle}.

Both lists are always present on the wire. Treat empty lists as "no matches," never as errors.


Verifying webhook signatures

Inkbox signs every webhook payload with the agent identity's signing key — specifically, the key for the identity that owns the phone number the subscription (or incoming-call callback) is on, or, for call-lifecycle subscriptions, the identity that owns the subscription itself. Create or rotate that key via the Signing Keys guide. Three headers are included with each request:

HeaderDescription
X-Inkbox-Request-IDUnique request identifier
X-Inkbox-TimestampUnix timestamp (seconds)
X-Inkbox-Signaturesha256=<hex_digest> HMAC-SHA256 signature

The signature input is: {request_id}.{timestamp}.{raw_body}

To verify a webhook:

  1. Check the timestamp is within 300 seconds of the current time
  2. Reconstruct the signed message: {X-Inkbox-Request-ID}.{X-Inkbox-Timestamp}.{raw_body}
  3. Compute the HMAC-SHA256 using the agent identity's signing key
  4. Compare the hex digest with the sha256=... value from the header

Python verification example

PythonPython

Disabling webhooks

To stop receiving text or call-lifecycle webhooks at a URL, delete the subscription that owns it. Delivery stops immediately.

To stop receiving incoming-call webhooks, update the phone number to clear the URL and switch the action:

JSONJSON

Inkbox

Copyright © 2026 Inkbox

This site is protected by reCAPTCHA.

Google Privacy Policy and Terms of Service apply.

Website

Inkbox

Copyright © 2026 Inkbox

This site is protected by reCAPTCHA.

Google Privacy Policy and Terms of Service apply.

Website

Y CombinatorBacked by Y Combinator
Webhooks