Webhooks
Webhooks let you receive HTTP POST callbacks for mailbox events. Configure delivery by creating one or more webhook subscriptions against the mailbox — each subscription names one HTTPS URL and the subset of message.* events you want delivered there.
When an event fires, Inkbox sends a signed POST request to every subscription URL whose event_types list includes that event. The request includes headers you can use to verify the payload's authenticity.
Event types
| Event | Description |
|---|---|
message.received | An inbound email was ingested into the mailbox |
message.sent | An outbound email was successfully sent. Does not fire for forwards. |
message.forwarded | An outbound forward of a previously stored message via POST /messages/{message_id}/forward |
message.bounced | An outbound email bounced or received a complaint |
message.failed | An outbound email failed after exhausting all retry attempts |
message.delivered | Delivery to the recipient's mail server was confirmed |
Payload shape
Every event uses the same envelope. data.message carries the event's message payload. Two parallel peer-resolution lists ride alongside it:
data.contacts— address-book matches.data.agent_identities— internal-agent matches (other agents in your organization that match a recipient).
On message.received, a subscription that configures conversation context also receives an additive data.context block; it is absent otherwise.
Both lists are always present and possibly empty, never null. Each entry is tagged with the bucket (from / to / cc / bcc) it came from. A peer that's both a contact and an internal agent appears once in each list. See Peer resolution below for the full pairing rules.
Inbound example — message.received
Outbound example — message.sent
Both data.contacts and data.agent_identities are always present on the wire and sparse — only matched recipients appear. An empty list means nothing matched. The same recipient can appear in both lists (when a peer is both a contact and an internal agent in your org); receivers decide precedence per row.
data.message.bcc_addresses is symmetric with to_addresses and cc_addresses and is populated on outbound events only. Inbound payloads always carry "bcc_addresses": null, since BCC headers are not visible to recipients.
Peer resolution
data.contacts and data.agent_identities are parallel lists of address-book and internal-agent matches respectively — one entry per matched recipient per list. Each entry is self-describing about which recipient bucket it pairs back to.
contactsentry shape:{ "bucket": "from" | "to" | "cc" | "bcc", "address": <wire-form recipient>, "id": <uuid>, "name": <preferred name> }.agent_identitiesentry shape:{ "bucket": "from" | "to" | "cc" | "bcc", "address": <wire-form recipient>, "id": <uuid>, "agent_handle": <handle>, "display_name": <preferred name> | null }.- Match coverage per direction:
- Inbound (
message.received) —from_addressplus every entry ofcc_addresses. - Outbound (
message.sent,message.delivered,message.forwarded,message.bounced,message.failed) — every entry ofto_addresses,cc_addresses, andbcc_addresses.
- Inbound (
- Scope. Contacts are organization-wide, so contact matches are the same for every agent. Agent-identity visibility follows Agent visibility, with self-visibility (agents always match themselves).
- Pair on
(bucket, address), not onaddressalone. The same address can appear in multiple buckets on a single send (e.g. the same recipient in bothto_addressesandcc_addresses). When that happens, the matched entry appears twice in the list — once per bucket. Pairing onaddressalone produces phantom duplicates or attributes the match to the wrong recipient slot. - Intra-bucket dedupe. Duplicate addresses inside the same bucket collapse to a single entry. Case-only intra-bucket duplicates also collapse, with the first occurrence's wire form preserved in
address. - Address casing.
addressechoes the original wire form of the matched recipient — the same casing that appears in the correspondingdata.message.{from_address|to_addresses|cc_addresses|bcc_addresses}field. Resolution itself runs against the lowercased canonical form, so receivers parsing arbitrary inbound mail may want to use a case-insensitive compare when pairing. - Sparse lists. Only matched recipients appear — there is no placeholder entry for unmatched recipients.
"contacts": []and"agent_identities": []each mean nothing matched in that list. - Wire ordering.
from→to→cc→bcc, and within each bucket the order matches the source field's order (first occurrence wins on intra-bucket dedupe). - Per-event cap. Up to 50 distinct normalized addresses are resolved per event. Over-cap inputs and transient resolver failures fall back to empty lists; the message webhook itself still fires unchanged.
- Tiebreak. If multiple contacts share the same email, the oldest by
created_atwins. Receivers needing disambiguation can callGET /contacts/lookup. Agent-identity matches are 1:1 on the canonical email. - Hydration. Feed
contacts[i].idintoGET /api/v1/contacts/{contact_id}to fetch the full contact record — see Manage contacts. For identities, the endpoint is keyed by handle, not UUID: feedagent_identities[i].agent_handleintoGET /api/v1/identities/{agent_handle}— see Manage identities.
Treat empty lists as "no matches," never as errors.
Conversation context
When a subscription sets context_config, message.received payloads gain an additive data.context object keyed by the configured classes (email, texts, calls), so a handler can act on recent history without a follow-up API call. Delivery-status events never carry it.
Each class is a block:
| Field | Type | Description |
|---|---|---|
scope | string | thread (the triggering mail thread), conversation (a texts conversation), or contact (cross-channel history resolved through the matched contact). |
mode | string | count or window. Present only when the class returned items. |
requested | integer | The configured count (count mode only). |
hours | integer | The configured window (window mode only). |
items | array | The history, oldest first, excluding the triggering item. |
truncated | boolean | true if items were dropped to fit the 256 KB cap. |
skipped | string | Present only on an empty block: no_contact, no_resource, or unavailable. A genuinely empty history is items: [] with no skipped. |
Item shapes by class:
email—id,direction,from_address,to_addresses,subject,snippet,created_at(metadata and snippet only; fetch full bodies via the messages API).texts—id,channel(smsorimessage),direction,sender,text(capped at 4,096 chars, withtext_truncated),status,media,created_at.calls—call_id,direction,remote_number,duration,started_at,abridged, andtranscript. Each transcript entry is a turn (party,text,ts_ms) or, past 12,000 chars, an abridgment marker{"marker": "abridged", "omitted_turns": N, "omitted_ms": M}replacing the omitted middle — discriminate onmarker.
Nullable item fields are present as null, not omitted. The whole block is capped at 256 KB; over the cap, the oldest items are dropped class by class (at least one per configured class survives) and that class's truncated is set.
The full configuration shape and bounds live on the Webhook Subscriptions page.
Configuring webhooks
Mail webhook delivery is configured via the Webhook Subscriptions API. Two steps:
- Have a mailbox provisioned (it's created atomically with its agent identity).
- Create a subscription naming that mailbox, the HTTPS destination URL, and the subset of
message.*event types you want delivered.
You can attach up to 20 active subscriptions per mailbox — split events across URLs, or fan one URL out across many mailboxes 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.
Verifying webhook signatures
Inkbox signs every webhook payload with the agent identity's signing key. The secret used is the one for the identity that owns the mailbox the subscription is on. Create or rotate a key via the Signing Keys guide. Each webhook request includes three headers:
| Header | Description |
|---|---|
X-Inkbox-Request-ID | Unique request identifier |
X-Inkbox-Timestamp | Unix timestamp in seconds when the request was sent |
X-Inkbox-Signature | sha256=<hex_digest> — HMAC-SHA256 of the signed content |
The signature input is constructed as:
{request_id}.{timestamp}.{raw_body}Verification steps
- Check the timestamp — reject the request if
X-Inkbox-Timestampis more than 300 seconds from the current time. - Reconstruct the message — concatenate
{X-Inkbox-Request-ID}.{X-Inkbox-Timestamp}.{raw_body}. - Compute the HMAC — use HMAC-SHA256 with your signing key over the reconstructed message.
- Compare digests — the resulting hex digest should match the value after
sha256=in theX-Inkbox-Signatureheader.
Python verification example
Disabling webhooks
To stop receiving webhooks at a URL, delete the subscription that owns it. Delivery stops immediately.