Webhooks
Webhooks let you receive HTTP POST callbacks for phone number 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 incoming-call event stays a per-number control-plane callback, configured on the phone number resource via incoming_call_webhook_url.
Event types
| Event | Description |
|---|---|
phone.incoming_call | An inbound call hit a phone number with incoming_call_action set to "webhook" |
text.received | An inbound SMS or MMS arrived on a phone number |
text.sent | An outbound SMS or MMS was accepted by the carrier |
text.delivered | The carrier confirmed delivery to the recipient handset |
text.delivery_failed | The carrier reported a permanent delivery failure |
text.delivery_unconfirmed | The 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.
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
| Field | Type | Description |
|---|---|---|
incoming_call_action | string | Set to "webhook" to enable webhook callbacks for incoming calls |
incoming_call_webhook_url | string | HTTPS endpoint to receive incoming-call webhook POSTs |
Request example
Code examples
Configuring text webhooks
Text events are delivered via the Webhook Subscriptions API. Two steps:
- Have a phone number provisioned (see Phone numbers).
- 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
Code examples
See Webhook Subscriptions for the full CRUD surface, validation rules, and per-route error codes.
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
Payload fields
| Field | Type | Description |
|---|---|---|
id | UUID | Unique call identifier |
local_phone_number | string | Your phone number that the call hit (E.164) |
remote_phone_number | string | The caller (E.164) |
direction | string | Always "inbound" for this webhook |
status | string | Call status at the time the webhook fires (e.g. "initiated", "ringing") |
client_websocket_url | string | null | The phone number's configured fallback WebSocket URL, if any |
use_inkbox_tts | boolean | null | Whether the call uses Inkbox-managed TTS |
use_inkbox_stt | boolean | null | Whether the call uses Inkbox-managed STT |
hangup_reason | string | null | Always null for inbound webhooks (call hasn't ended yet) |
started_at | string | null | Always null for inbound webhooks |
ended_at | string | null | Always null for inbound webhooks |
created_at | string | ISO 8601 timestamp |
updated_at | string | ISO 8601 timestamp |
rate_limit | object | null | Optional rate-limit snapshot for the organization |
contacts | array | Address-book matches for remote_phone_number. Always present, possibly empty. See Peer resolution below. |
agent_identities | array | Internal-agent matches for remote_phone_number. Always present, possibly empty. |
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:
Use the phone number's configured fallback by omitting the field:
Response fields
| Field | Type | Required | Description |
|---|---|---|---|
action | string | Yes | "answer" to accept the call, "reject" to decline. |
client_websocket_url | string | null | No | WebSocket 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". |
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.
Inbound — text.received
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 block — delivery_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.
Example: outbound group text.delivered for one recipient.
The three other outbound events have the same shape with different terminal fields:
text.sent— carrier accepted the message.delivery_statusis"sent",sent_atis populated,delivered_atandfailed_atarenull.text.delivered— carrier confirmed handset delivery.delivery_statusis"delivered", bothsent_atanddelivered_atare populated.text.delivery_unconfirmed— carrier could not confirm delivery within its reporting window.delivery_statusis"delivery_unconfirmed",sent_atandfailed_atare populated,delivered_atisnull.
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.
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
| Field | Type | Description |
|---|---|---|
id | UUID | Unique text-message identifier |
direction | string | "inbound" or "outbound" |
local_phone_number | string | Your phone number (E.164) |
remote_phone_number | string | null | The other party (E.164). Populated on inbound and 1:1 outbound; null on group outbound (no single remote party). |
text | string | null | Message body. May be null for media-only MMS. |
type | string | "sms" or "mms" |
media | array | null | Attached media items on MMS |
delivery_status | string | null | Latest 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_detail | string | null | Carrier error details when delivery failed (1:1 only) |
sent_at / delivered_at / failed_at | string | null | Lifecycle timestamps (1:1 only) |
conversation_id | UUID | null | Parent conversation thread |
sender_phone_number | string | null | The sender (inbound only; outbound rows have no sender — implicit from local_phone_number) |
recipients | array | null | Per-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
| Field | Type | Description |
|---|---|---|
recipient_phone_number | string | The recipient (E.164) |
delivery_status | string | null | Lifecycle status for this leg |
carrier | string | null | Carrier reported by the upstream MNO lookup |
line_type | string | null | E.g. "mobile", "landline" |
error_code / error_detail | string | null | Per-leg carrier error details |
sent_at / delivered_at / failed_at | string | null | Per-leg lifecycle timestamps |
Peer resolution
Inbound-call payloads carry top-level contacts and agent_identities. Text 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: restricted to the identity that owns the receiving phone number. Contacts not visible to that identity are absent; internal agents are resolved against the per-identity
identity_accesstable plus self-visibility. - Inbound calls: match key is top-level
remote_phone_number. - Inbound texts: match key is
data.text_message.remote_phone_number, also available asdata.text_message.sender_phone_number. - Outbound 1:1 text lifecycle events: match key is
data.text_message.remote_phone_number;data.recipient_phone_numberisnull. - Outbound group text lifecycle events: match key is
data.recipient_phone_number, the recipient this lifecycle event is about. Usedata.text_message.recipients[]to inspect every recipient in the conversation send. - Tiebreak (contacts): if multiple visible contacts share the same phone, the oldest by
created_atwins. Receivers needing disambiguation can callGET /contacts/lookup. - Hydration: feed
contacts[i].idintoGET /api/v1/contacts/{contact_id}. For identities the endpoint is keyed by handle, not UUID: feedagent_identities[i].agent_handleintoGET /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 your organization's signing key. Create or rotate your key via the Signing Keys guide. Three headers are included with each request:
| Header | Description |
|---|---|
X-Inkbox-Request-ID | Unique request identifier |
X-Inkbox-Timestamp | Unix timestamp (seconds) |
X-Inkbox-Signature | sha256=<hex_digest> HMAC-SHA256 signature |
The signature input is: {request_id}.{timestamp}.{raw_body}
To verify a webhook:
- Check the timestamp is within 300 seconds of the current time
- Reconstruct the signed message:
{X-Inkbox-Request-ID}.{X-Inkbox-Timestamp}.{raw_body} - Compute the HMAC-SHA256 using your organization's signing key
- Compare the hex digest with the
sha256=...value from the header
Python verification example
Disabling webhooks
To stop receiving text 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: