# AI Inbx
> Email infrastructure for software that holds conversations. Send, receive, thread, and reply over one API.
# AI Inbx
Source: https://docs.aiinbx.com/
AI Inbx provides APIs for sending email, receiving messages, and managing conversations. Use it to build transactional email, customer communication, or email agents with verified domains and connected Gmail or Outlook accounts.
Each email belongs to a [thread](/guides/threads). Reply with a thread ID and message content; the API derives the sender, recipients, subject, and reply headers. [Signed webhooks](/webhooks) notify your application about incoming messages and delivery outcomes.
**[Quickstart](/quickstart)**
Create a key, configure a sender, and send your first email.
**[API reference](/api)**
Every endpoint, generated from the schemas the API validates against.
**[TypeScript SDK](/sdks/typescript)**
Dependency-free, typed, works on Node, Bun, Deno, and edge runtimes.
**[Python SDK](/sdks/python)**
Sync and async clients with the same resource surface.
Read [Core concepts](/concepts) for the resource model, or choose a framework from [Integrations](/integrations).
## What it does
**Send**
HTML or text, attachments, custom headers, idempotent retries, and sends
scheduled up to 30 days out.
**Receive**
Inbound mail on any domain you can publish DNS for, or through a
connected Gmail or Outlook mailbox, delivered as webhooks.
**Thread**
Replies land on the conversation they belong to, matched on headers and
quoted content rather than subject alone.
**Find conversations**
List threads by subject or participant, filter by mailbox or space,
and paginate through their messages.
**Suppress**
Bounces, complaints, and unsubscribes stop the next send automatically,
per list or across the org.
**Pace**
Sending windows, rate ceilings, and a queue you can inspect and release
by hand.
## Choose your starting point
| You want to | Start here |
| --- | --- |
| Send from a domain you control | [Quickstart](/quickstart) |
| Connect an existing Gmail or Outlook account | [Mailboxes](/guides/mailboxes) |
| Provide email for multiple customers | [Spaces](/guides/spaces) |
| Build an automated email workflow | [Email agents](/integrations/agents) |
| Prepare an integration for production | [Production guide](/guides/production) |
| Give a coding agent the API contract | [Documentation for agents](/reference/agents) |
## Send an email
Install an [SDK](/sdks), set `AI_INBX_API_KEY`, and configure a [verified sending domain](/guides/domains) or [connected mailbox](/guides/mailboxes). Replace the example addresses with your sender and a recipient you control.
```ts TypeScript
import AIInbx from "aiinbx"
const aiinbx = new AIInbx() // reads AI_INBX_API_KEY
const email = await aiinbx.emails.send({
from: { name: "Ada", address: "ada@example.com" },
to: "grace@example.com",
subject: "Quick question",
text: "Does Thursday still work for the review?",
})
console.log(email.id, email.thread_id)
```
```python Python
from aiinbx import AIInbx
with AIInbx() as client: # reads AI_INBX_API_KEY
email = client.emails.send(
{
"from_": {"name": "Ada", "address": "ada@example.com"},
"to": ["grace@example.com"],
"subject": "Quick question",
"text": "Does Thursday still work for the review?",
}
)
print(email["id"], email["thread_id"])
```
```bash curl
curl https://api.aiinbx.com/api/v2/emails \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Ada ",
"to": "grace@example.com",
"subject": "Quick question",
"text": "Does Thursday still work for the review?"
}'
```
When Grace replies, an [`email.received`](/webhooks/events/email-received) webhook arrives carrying the same `thread_id`, and [`threads.reply`](/guides/threads#replying) continues the conversation without you rebuilding a single header.
## Where to go next
**[Sending](/guides/sending)**
Addresses, content, attachments, and idempotency.
**[Receiving](/guides/receiving)**
Domains and mailboxes as two ways in.
**[Webhooks](/webhooks)**
Endpoints, routing, retries, and signature verification.
**[Conventions](/reference/conventions)**
IDs, pagination, request IDs, and idempotency in one place.
---
# API reference
Source: https://docs.aiinbx.com/api
**Base URL** · `https://api.aiinbx.com/api/v2`
New to AI Inbx? Follow the [quickstart](/quickstart) for a complete send-and-reply flow. For an existing integration, start with [authentication](/authentication), [request conventions](/reference/conventions), or [errors](/reference/errors).
## Email and conversations [#email]
**[Emails](/api/emails)**
Send, retrieve, schedule, and cancel messages.
**[Threads](/api/threads)**
Read conversations, reply, and forward a transcript.
**[Attachments](/api/attachments)**
Download original files and prepared text.
## Sending identities [#identities]
**[Domains](/api/domains)**
Configure domains, verify DNS, and diagnose setup.
**[Mailboxes](/api/mailboxes)**
Connect and manage Gmail or Outlook accounts.
**[OAuth apps](/api/oauth-apps)**
Manage the apps used for mailbox authorization.
## Platform and delivery [#platform]
**[Spaces](/api/spaces)**
Group customer identities, messages, and controls.
**[Webhooks](/api/webhooks)**
Register endpoints, inspect deliveries, and replay failures.
**[Suppressions](/api/suppressions)**
Manage recipient exclusions and subscription preferences.
**[Pacing](/api/pacing)**
Set sending rules and inspect or release held mail.
**[API keys](/api/api-keys)**
Create scoped credentials and manage their lifecycle.
**[Webhook events](/webhooks/events)**
Understand event payloads and delivery outcomes.
## SDKs and tools [#tools]
Use the official [TypeScript](/sdks/typescript) or [Python](/sdks/python) SDK, or call the API over HTTP. The [OpenAPI document](https://api.aiinbx.com/api/v2/openapi.json) defines the wire contract. [Documentation for agents](/reference/agents) explains Markdown access and implementation conventions.
For a complete list across resources, see [All endpoints](/api/endpoints).
---
# API Keys API
Source: https://docs.aiinbx.com/api/api-keys
{/* Generated by scripts/openapi.ts from the shared API contract. */}
Create and delete credentials for API access.
## Endpoints
| Method | Operation | Path |
| --- | --- | --- |
| `GET` | [List API keys](/api/endpoints/api-keys/list-api-keys) | `/api-keys` |
| `POST` | [Create an API key](/api/endpoints/api-keys/create-api-key) | `/api-keys` |
| `DELETE` | [Delete an API key](/api/endpoints/api-keys/delete-api-key) | `/api-keys/{key_id}` |
## Request essentials [#request-essentials]
Use `https://api.aiinbx.com/api/v2` as the base URL and send your API key in `Authorization: Bearer `. Keep credentials on your server.
See [Authentication](/authentication) for scopes, [Conventions](/reference/conventions) for pagination and idempotency, and [Errors](/reference/errors) for recovery guidance. Each endpoint page includes its parameters, response schemas, and copyable curl, JavaScript, and Python examples.
---
# Attachments API
Source: https://docs.aiinbx.com/api/attachments
{/* Generated by scripts/openapi.ts from the shared API contract. */}
Download original and prepared attachment content.
## Endpoints
| Method | Operation | Path |
| --- | --- | --- |
| `GET` | [Download an attachment](/api/endpoints/attachments/download-attachment) | `/attachments/{attachment_id}` |
| `HEAD` | [Inspect attachment metadata](/api/endpoints/attachments/inspect-attachment) | `/attachments/{attachment_id}` |
| `GET` | [Download prepared attachment text](/api/endpoints/attachments/retrieve-attachment-content) | `/attachments/{attachment_id}/content` |
## Request essentials [#request-essentials]
Use `https://api.aiinbx.com/api/v2` as the base URL and send your API key in `Authorization: Bearer `. Keep credentials on your server.
See [Authentication](/authentication) for scopes, [Conventions](/reference/conventions) for pagination and idempotency, and [Errors](/reference/errors) for recovery guidance. Each endpoint page includes its parameters, response schemas, and copyable curl, JavaScript, and Python examples.
---
# Domains API
Source: https://docs.aiinbx.com/api/domains
{/* Generated by scripts/openapi.ts from the shared API contract. */}
Manage sending domains and DNS checks.
## Endpoints
| Method | Operation | Path |
| --- | --- | --- |
| `GET` | [List domains](/api/endpoints/domains/list-domains) | `/domains` |
| `POST` | [Create a domain](/api/endpoints/domains/create-domain) | `/domains` |
| `GET` | [Retrieve a domain](/api/endpoints/domains/retrieve-domain) | `/domains/{domain_id}` |
| `DELETE` | [Delete a domain](/api/endpoints/domains/delete-domain) | `/domains/{domain_id}` |
| `PATCH` | [Update domain tracking settings](/api/endpoints/domains/update-domain) | `/domains/{domain_id}` |
| `POST` | [Verify domain DNS records now](/api/endpoints/domains/verify-domain) | `/domains/{domain_id}/verify` |
| `GET` | [Diagnose domain DNS configuration](/api/endpoints/domains/retrieve-domain-diagnostics) | `/domains/{domain_id}/diagnostics` |
## Request essentials [#request-essentials]
Use `https://api.aiinbx.com/api/v2` as the base URL and send your API key in `Authorization: Bearer `. Keep credentials on your server.
See [Authentication](/authentication) for scopes, [Conventions](/reference/conventions) for pagination and idempotency, and [Errors](/reference/errors) for recovery guidance. Each endpoint page includes its parameters, response schemas, and copyable curl, JavaScript, and Python examples.
---
# Emails API
Source: https://docs.aiinbx.com/api/emails
{/* Generated by scripts/openapi.ts from the shared API contract. */}
Send, inspect, and schedule email.
## Endpoints
| Method | Operation | Path |
| --- | --- | --- |
| `GET` | [List emails](/api/endpoints/emails/list-emails) | `/emails` |
| `POST` | [Send an email](/api/endpoints/emails/send-email) | `/emails` |
| `GET` | [Retrieve an email](/api/endpoints/emails/retrieve-email) | `/emails/{email_id}` |
| `PATCH` | [Reschedule an email](/api/endpoints/emails/reschedule-email) | `/emails/{email_id}` |
| `POST` | [Cancel a scheduled email](/api/endpoints/emails/cancel-email) | `/emails/{email_id}/cancel` |
## Request essentials [#request-essentials]
Use `https://api.aiinbx.com/api/v2` as the base URL and send your API key in `Authorization: Bearer `. Keep credentials on your server.
See [Authentication](/authentication) for scopes, [Conventions](/reference/conventions) for pagination and idempotency, and [Errors](/reference/errors) for recovery guidance. Each endpoint page includes its parameters, response schemas, and copyable curl, JavaScript, and Python examples.
---
# AI Inbx API
Source: https://docs.aiinbx.com/api/endpoints
Email infrastructure for software that holds conversations.
## Spaces
Group a workspace's domains, mailboxes and mail by customer. Optional: a workspace that is not a platform never needs one.
## API Keys
Create and delete credentials for API access.
## Emails
Send, inspect, and schedule email.
## Threads
Read email conversations.
## Attachments
Download original and prepared attachment content.
## Domains
Manage sending domains and DNS checks.
## Mailboxes
Connect and manage provider mailboxes.
## OAuth Apps
Manage customer-owned Google and Microsoft OAuth apps.
## Webhooks
Configure event delivery and inspect delivery attempts.
## Suppressions
Prevent delivery to opted-out or unsafe recipients.
## Pacing
Control send rate, hours, and queue flow.
---
# Create an API key
Source: https://docs.aiinbx.com/api/endpoints/api-keys/create-api-key
Creates an organization-wide API key and returns its secret exactly once, in the `key` field. Store it before the response is discarded — it cannot be recovered. Choose the narrowest scope the caller needs: `read`, `sending`, or `full`. The key reaches the whole workspace, spaces included.
---
# Delete an API key
Source: https://docs.aiinbx.com/api/endpoints/api-keys/delete-api-key
Deletes a key immediately. It authenticates nothing afterwards and cannot be recovered; issue a new key instead.
---
# List API keys
Source: https://docs.aiinbx.com/api/endpoints/api-keys/list-api-keys
Lists the workspace's API keys, newest first. Keys are returned as metadata only — the prefix, scope, creation time, and `last_used_at`; the secret itself is never readable after creation.
---
# Download an attachment
Source: https://docs.aiinbx.com/api/endpoints/attachments/download-attachment
Redirects to a short-lived signed URL for the attachment's original bytes. Follow the redirect; the signed URL expires, so resolve it on demand rather than storing it.
---
# Inspect attachment metadata
Source: https://docs.aiinbx.com/api/endpoints/attachments/inspect-attachment
Returns the attachment's media type, size, and filename disposition in response headers without transferring the file — the cheap way to check a size before committing to a download.
---
# Download prepared attachment text
Source: https://docs.aiinbx.com/api/endpoints/attachments/retrieve-attachment-content
Redirects to a short-lived signed URL for the attachment's prepared text — Markdown where the document has structure, plain text otherwise. Returns 404 `not_prepared` when the file type has no text to extract or preparation failed.
---
# Create a domain
Source: https://docs.aiinbx.com/api/endpoints/domains/create-domain
Registers a sending domain and returns the DNS records to publish: SPF, DKIM, DMARC, a return path, and an MX record for receiving. `region` selects the regional mail infrastructure for this domain and cannot be changed afterwards.
A wildcard — `*.acme.dev` — is verified once and covers every subdomain: the MX record goes at `*.acme.dev`, the rest at `acme.dev`, and the apex keeps whatever mail setup it has. A name one label under a wildcard of yours — `hi.acme.dev` under `*.acme.dev` — is created as a subdomain of it: no records, verified as soon as the wildcard is, in whatever `space_id` you give it. That is how a platform hands each customer an address of their own without touching DNS again.
A domain name can be connected in multiple workspaces, but sending is enabled for the workspace whose DKIM key is published. To move sending, add the domain in the destination workspace and publish its DNS records. The next verification check updates sending readiness; existing mail remains in its original workspace. Duplicate apex or wildcard identities within one workspace fail with `domain_taken`.
Every workspace already has one domain it did not add: `.aiinbx.app`, listed with `provided: true`. It is verified through our zone, has no records and cannot be deleted; a name under `aiinbx.app` cannot be added by hand and fails with `reserved_domain`.
---
# Delete a domain
Source: https://docs.aiinbx.com/api/endpoints/domains/delete-domain
Deletes a domain and stops future sends from it. Remove the published DNS records afterwards. A wildcard's subdomains are deleted with it, in whatever space they sit; a subdomain is removed on its own, with nothing to unpublish. The provided domain cannot be deleted and fails with `domain_provided`.
---
# List domains
Source: https://docs.aiinbx.com/api/endpoints/domains/list-domains
Lists the workspace's sending domains with their verification state and tracking settings.
---
# Retrieve a domain
Source: https://docs.aiinbx.com/api/endpoints/domains/retrieve-domain
Returns one domain with its DNS records and the state of each — `pending`, `verified`, or `missing` — along with when each was last checked.
---
# Diagnose domain DNS configuration
Source: https://docs.aiinbx.com/api/endpoints/domains/retrieve-domain-diagnostics
Reads the domain's live DNS zone and reports what is wrong with it — a duplicate SPF record, a permissive policy, a missing return path. Each finding carries a severity, an explanation, the fix, and the records it was drawn from. Nothing is stored; the result always reflects DNS as it is right now.
---
# Update domain tracking settings
Source: https://docs.aiinbx.com/api/endpoints/domains/update-domain
Updates the domain's open and click tracking preferences. At least one field is required.
---
# Verify domain DNS records now
Source: https://docs.aiinbx.com/api/endpoints/domains/verify-domain
Checks the domain's DNS records against live DNS now. `verified_at` stays null until the sending identity’s DKIM verification succeeds; inbound MX, SPF, and DMARC readiness are separate. DNS propagation usually takes minutes and occasionally hours, so poll at a sane interval or subscribe to the `domain.verified` webhook instead. Domains are also rechecked in the background: a domain whose DKIM record stops resolving loses `verified_at` again and `domain.lost` is sent.
---
# Cancel a scheduled email
Source: https://docs.aiinbx.com/api/endpoints/emails/cancel-email
Cancels a scheduled email before it is composed and sent. Cancellation is terminal — a canceled message cannot be rescheduled back into flight.
---
# List emails
Source: https://docs.aiinbx.com/api/endpoints/emails/list-emails
Lists emails in the workspace, newest first. Filter by `direction`, delivery `status`, a single `thread_id`, or a literal `query` over subjects and addresses. The query is a literal filter; the API does not provide semantic email search.
---
# Reschedule an email
Source: https://docs.aiinbx.com/api/endpoints/emails/reschedule-email
Moves a scheduled email to a new time, at most 30 days out. Only a message still in `scheduled` status can move; one that has begun sending returns 409 `not_scheduled`.
---
# Retrieve an email
Source: https://docs.aiinbx.com/api/endpoints/emails/retrieve-email
Returns one email in full: both bodies, every header, attachments, the delivery event history, and tracked engagements. Pass `include=attachment_content` to inline each attachment's prepared text, which saves a request per attachment before handing a message to a model.
---
# Send an email
Source: https://docs.aiinbx.com/api/endpoints/emails/send-email
Sends an email. Requires a `from` address on a verified domain or a connected mailbox, and at least one of `html` or `text`. Recipients on a matching suppression list are dropped and reported in `suppressed`; a pacing rule that holds the message is reported in `pacing`. Pass `Idempotency-Key` so a retry after a timeout replays the original send instead of sending twice.
---
# Create a mailbox connection URL
Source: https://docs.aiinbx.com/api/endpoints/mailboxes/connect-mailbox
Creates a single-use, short-lived URL that walks a customer through authorizing their Gmail or Outlook mailbox. Redirect them to it rather than emailing it. Pass `ref` to carry your own identifier through to the `mailbox.connected` webhook, which — not the browser returning to `return_to` — is the authoritative signal that the mailbox is live. `return_to` may be any URL here, since the request is authenticated; a connect link opened from a browser is held to the app's `return_urls` instead. `space_id` files the mailbox in a space; the returned URL carries it, so a platform mints one link per customer server-side rather than handing out the bare hosted link.
---
# Disconnect a mailbox
Source: https://docs.aiinbx.com/api/endpoints/mailboxes/disconnect-mailbox
Disconnects a mailbox, stopping sync and dropping its stored credentials. Messages already received stay on their threads.
---
# List mailboxes
Source: https://docs.aiinbx.com/api/endpoints/mailboxes/list-mailboxes
Lists connected Gmail and Outlook mailboxes with their sync state.
---
# Retrieve a mailbox
Source: https://docs.aiinbx.com/api/endpoints/mailboxes/retrieve-mailbox
Returns one mailbox, including its `state` and the provider's `state_reason` when there is one. A mailbox that is not `active` cannot send.
---
# Queue a mailbox sync
Source: https://docs.aiinbx.com/api/endpoints/mailboxes/sync-mailbox
Queues a catch-up sync for a mailbox. Mail normally arrives on its own through provider push notifications, so this is a repair tool rather than a way to poll. Messages found surface as ordinary `email.received` events.
---
# Create an OAuth app
Source: https://docs.aiinbx.com/api/endpoints/oauth-apps/create-o-auth-app
Registers your own OAuth app so the mailbox consent screen carries your product's name rather than AI Inbx. The response returns the `redirect_uri` to register with the provider, a hosted `connect_url`, and — for Google — the `push_endpoint` your own Pub/Sub topic should publish to. The `slug` is claimed globally because it forms the connect URL. `return_urls` lists the pages of yours a connect link (`connect_url` + `/start?return_to=…`) may send a person back to; without one, only the hosted page works.
---
# Delete an OAuth app
Source: https://docs.aiinbx.com/api/endpoints/oauth-apps/delete-o-auth-app
Deletes an OAuth app. Mailboxes already connected through it keep working until their authorization lapses.
---
# List OAuth apps
Source: https://docs.aiinbx.com/api/endpoints/oauth-apps/list-o-auth-apps
Lists the workspace's own Google and Microsoft OAuth apps.
---
# Retrieve an OAuth app
Source: https://docs.aiinbx.com/api/endpoints/oauth-apps/retrieve-o-auth-app
Returns one OAuth app. The client secret is never returned.
---
# Update an OAuth app
Source: https://docs.aiinbx.com/api/endpoints/oauth-apps/update-o-auth-app
Updates an OAuth app's credentials, branding or return URLs. At least one field is required. A rotated secret replaces the old one immediately, so update the provider first. `return_urls` replaces the whole list.
---
# Create a pacing rule
Source: https://docs.aiinbx.com/api/endpoints/pacing/create-pacing-rule
Creates a pacing rule. An `hours` rule holds mail outside the weekly windows you declare, evaluated in the rule's own timezone. A `limit` rule caps sends per rolling window against a scope — the rule as a whole, or per sender address, sending domain, recipient, or recipient domain. An empty `match` array applies the rule to every send. `space_id` makes the rule read one space's mail alone; workspace rules apply to every space and stack on top.
---
# Delete a pacing rule
Source: https://docs.aiinbx.com/api/endpoints/pacing/delete-pacing-rule
Deletes a pacing rule. Messages it was holding become eligible to send as soon as the remaining rules allow.
---
# List pacing rules
Source: https://docs.aiinbx.com/api/endpoints/pacing/list-pacing-rules
Lists the workspace's pacing rules, both sending-hours and rate-limit kinds.
---
# Release emails from the pacing queue
Source: https://docs.aiinbx.com/api/endpoints/pacing/release-pacing-queue
Releases up to 500 held emails immediately. `count_toward_limits` defaults to true; setting it to false means the sends do not consume rule budget, which makes the rule under-count — reserve it for genuinely exceptional releases. For mail that should never queue, set `pacing.skip` on the send instead.
---
# Retrieve the pacing queue
Source: https://docs.aiinbx.com/api/endpoints/pacing/retrieve-pacing-queue
Returns a snapshot of everything pacing is currently holding: a sample of queued messages, the rules in effect, and per-rule counts of how many messages each matched and how many it is holding. The per-rule counts are the fastest way to identify which rule is responsible.
---
# Retrieve a pacing rule
Source: https://docs.aiinbx.com/api/endpoints/pacing/retrieve-pacing-rule
Returns one pacing rule with its match patterns and its schedule or limit.
---
# Retrieve pacing spread
Source: https://docs.aiinbx.com/api/endpoints/pacing/retrieve-pacing-spread
Returns the workspace's send-timing spread, a value from 0 to 100.
---
# Update a pacing rule
Source: https://docs.aiinbx.com/api/endpoints/pacing/update-pacing-rule
Updates a pacing rule. At least one field is required. Setting `enabled` to false parks a rule without losing its configuration, which is the quickest way to confirm which rule is holding mail.
---
# Update pacing spread
Source: https://docs.aiinbx.com/api/endpoints/pacing/update-pacing-spread
Sets how much randomness is added to send timing. At 0 a rate-limited stream goes out on a metronome; higher values vary the gaps, costing a little throughput in exchange for traffic that does not look machine-generated. Applies to limit rules.
---
# Create a space
Source: https://docs.aiinbx.com/api/endpoints/spaces/create-space
Creates a space — one per customer, typically. Domains, mailboxes, rules and suppressions are put in a space with `space_id` when they are created, and every email and thread inherits the space of the domain or mailbox it went through. Anything created without a `space_id` belongs to the workspace itself and `space_id` reads as null. API keys and webhook endpoints belong to the workspace. `external_id` is your own id for the customer: unique per workspace, so a second space for the same customer is `409 external_id_taken`, and the space can be found by it with `GET /spaces?external_id=`.
---
# Delete a space
Source: https://docs.aiinbx.com/api/endpoints/spaces/delete-space
Marks the space for deletion and starts its teardown in the background. The space immediately stops accepting work and no longer appears in the API; its domains then come off DNS verification and sending, its mailboxes are disconnected, and its emails, threads, rules and suppressions are removed. A wildcard domain in the space is deleted with its subdomains, including any filed in other spaces.
---
# List spaces
Source: https://docs.aiinbx.com/api/endpoints/spaces/list-spaces
Lists the workspace's spaces, oldest first. Filter by `external_id` to find the one that stands for a customer of yours.
---
# Retrieve a space
Source: https://docs.aiinbx.com/api/endpoints/spaces/retrieve-space
Returns one space by id, with its name and the `external_id` you gave it.
---
# Update a space
Source: https://docs.aiinbx.com/api/endpoints/spaces/update-space
Changes the space's name or `external_id`; only the fields given change, and `external_id: null` clears it. Nothing in the space moves.
---
# Create suppressions
Source: https://docs.aiinbx.com/api/endpoints/suppressions/create-suppressions
Adds up to 1000 addresses to a suppression list. `key` defaults to the workspace-wide `*` list and `scope` to `all`; a `scope` of `optional` blocks only sends marked as unsubscribable, so a marketing opt-out does not stop a password reset. Adding an address that is already listed updates the existing entry rather than duplicating it, and never narrows a wider scope. `space_id` puts the entries on a space's list, which stops that space's sends alone; without it they stop every space's.
---
# Delete a suppression
Source: https://docs.aiinbx.com/api/endpoints/suppressions/delete-suppression
Removes a suppression entry, allowing sends to that address again. Removing a `bounce` or `complaint` entry means mailing an address that already rejected you or reported you as spam — do it only with specific evidence the address was fixed.
---
# List suppressions
Source: https://docs.aiinbx.com/api/endpoints/suppressions/list-suppressions
Lists suppression entries, filterable by list `key`, `reason`, or a substring of the `address`. `blocks` and `last_blocked_at` show how often each entry has actually stopped a send.
---
# Retrieve a suppression
Source: https://docs.aiinbx.com/api/endpoints/suppressions/retrieve-suppression
Returns one suppression entry, including how it was created and how many sends it has stopped.
---
# Forward a thread
Source: https://docs.aiinbx.com/api/endpoints/threads/forward-thread
Sends the whole thread as one email to recipients who were not on it. Every message is rendered into one transcript, oldest first, each turn as what its author wrote with quoted tails and signatures removed; the thread's attachments ride along unless `include_attachments` is false, and `note` is placed above the transcript. Only `to` is required: the sender defaults to the thread's mailbox and the subject to `Fwd:` and the thread's. The forward opens a thread of its own, linked back through `forward_of`, so a later reply on the original still goes to its participants and replies to the forward land on the forward. Suppression, pacing, scheduling, and idempotency behave exactly as on a send.
---
# List threads
Source: https://docs.aiinbx.com/api/endpoints/threads/list-threads
Lists conversations, most recently active first. `query` matches subjects, mailboxes, and participants literally; `mailbox` narrows to one address.
---
# Reply in a thread
Source: https://docs.aiinbx.com/api/endpoints/threads/reply-to-thread
Replies within a thread. The sender, recipients, subject, and the `In-Reply-To` and `References` headers are inferred from the conversation, so only the content is required — set `reply_all` or the individual fields to override what is inferred. Suppression, pacing, and idempotency behave exactly as on a send.
---
# Retrieve a thread
Source: https://docs.aiinbx.com/api/endpoints/threads/retrieve-thread
Returns a thread with its messages inline, oldest first. Messages paginate independently of the thread list via `message_limit` and `message_cursor`, so a long conversation can be walked without loading it whole.
---
# Create a webhook endpoint
Source: https://docs.aiinbx.com/api/endpoints/webhooks/create-webhook-endpoint
Registers an organization-wide endpoint and returns its signing secret exactly once, in the `secret` field. `subscriptions` must name at least one event type. Optional routing rules narrow delivery by sender or recipient: a matching `block` rule always wins, and an empty allow list means no opinion. The endpoint receives every space's events, each stamped with its `space_id`.
---
# Delete a webhook endpoint
Source: https://docs.aiinbx.com/api/endpoints/webhooks/delete-webhook-endpoint
Deletes an endpoint. Pending deliveries to it stop.
---
# List webhook deliveries
Source: https://docs.aiinbx.com/api/endpoints/webhooks/list-webhook-deliveries
Lists deliveries to an endpoint, filterable by state. Each carries the event envelope it sends and every attempt made: timestamp, status code, duration, error, and the beginning of your response, which is usually what explains a rejection. `body` is null for a `v1` endpoint — its legacy body is built from the message at send time, not stored — and for a delivery nothing was sent for.
---
# List webhook endpoints
Source: https://docs.aiinbx.com/api/endpoints/webhooks/list-webhook-endpoints
Lists the workspace's webhook endpoints with their subscriptions and routing rules.
---
# Retrieve a webhook endpoint
Source: https://docs.aiinbx.com/api/endpoints/webhooks/retrieve-webhook-endpoint
Returns one endpoint, including when a rotated previous secret stops being accepted.
---
# Retry webhook deliveries
Source: https://docs.aiinbx.com/api/endpoints/webhooks/retry-webhook-deliveries
Replays up to 100 deliveries. A replay carries the same event `id` as the original, so a handler that deduplicates on it stays correct.
---
# Rotate a webhook signing secret
Source: https://docs.aiinbx.com/api/endpoints/webhooks/rotate-webhook-secret
Issues a new signing secret while the previous one keeps verifying for `grace_seconds`, so a deployment can roll over without dropping events. Set the grace period to 0 only when a secret has leaked and the exposure matters more than the gap.
---
# Send a test webhook
Source: https://docs.aiinbx.com/api/endpoints/webhooks/test-webhook-endpoint
Delivers a synthetic event to the endpoint and reports what it actually did — status code, duration, error, and the first 2 KB of its response. The test event is signed like a real one, so it exercises signature verification too. `payload` is sent verbatim and does not have to match the real shape for that event type.
---
# Update a webhook endpoint
Source: https://docs.aiinbx.com/api/endpoints/webhooks/update-webhook-endpoint
Updates an endpoint's URL, subscriptions, routing rules, or enabled state. At least one field is required. A disabled endpoint receives nothing, and events that occur while it is off are not queued for later.
---
# Mailboxes API
Source: https://docs.aiinbx.com/api/mailboxes
{/* Generated by scripts/openapi.ts from the shared API contract. */}
Connect and manage provider mailboxes.
## Endpoints
| Method | Operation | Path |
| --- | --- | --- |
| `GET` | [List mailboxes](/api/endpoints/mailboxes/list-mailboxes) | `/mailboxes` |
| `POST` | [Create a mailbox connection URL](/api/endpoints/mailboxes/connect-mailbox) | `/mailboxes/connect` |
| `GET` | [Retrieve a mailbox](/api/endpoints/mailboxes/retrieve-mailbox) | `/mailboxes/{mailbox_id}` |
| `DELETE` | [Disconnect a mailbox](/api/endpoints/mailboxes/disconnect-mailbox) | `/mailboxes/{mailbox_id}` |
| `POST` | [Queue a mailbox sync](/api/endpoints/mailboxes/sync-mailbox) | `/mailboxes/{mailbox_id}/sync` |
## Request essentials [#request-essentials]
Use `https://api.aiinbx.com/api/v2` as the base URL and send your API key in `Authorization: Bearer `. Keep credentials on your server.
See [Authentication](/authentication) for scopes, [Conventions](/reference/conventions) for pagination and idempotency, and [Errors](/reference/errors) for recovery guidance. Each endpoint page includes its parameters, response schemas, and copyable curl, JavaScript, and Python examples.
---
# OAuth Apps API
Source: https://docs.aiinbx.com/api/oauth-apps
{/* Generated by scripts/openapi.ts from the shared API contract. */}
Manage customer-owned Google and Microsoft OAuth apps.
## Endpoints
| Method | Operation | Path |
| --- | --- | --- |
| `GET` | [List OAuth apps](/api/endpoints/oauth-apps/list-o-auth-apps) | `/oauth-apps` |
| `POST` | [Create an OAuth app](/api/endpoints/oauth-apps/create-o-auth-app) | `/oauth-apps` |
| `GET` | [Retrieve an OAuth app](/api/endpoints/oauth-apps/retrieve-o-auth-app) | `/oauth-apps/{app_id}` |
| `DELETE` | [Delete an OAuth app](/api/endpoints/oauth-apps/delete-o-auth-app) | `/oauth-apps/{app_id}` |
| `PATCH` | [Update an OAuth app](/api/endpoints/oauth-apps/update-o-auth-app) | `/oauth-apps/{app_id}` |
## Request essentials [#request-essentials]
Use `https://api.aiinbx.com/api/v2` as the base URL and send your API key in `Authorization: Bearer `. Keep credentials on your server.
See [Authentication](/authentication) for scopes, [Conventions](/reference/conventions) for pagination and idempotency, and [Errors](/reference/errors) for recovery guidance. Each endpoint page includes its parameters, response schemas, and copyable curl, JavaScript, and Python examples.
---
# Pacing API
Source: https://docs.aiinbx.com/api/pacing
{/* Generated by scripts/openapi.ts from the shared API contract. */}
Control send rate, hours, and queue flow.
## Endpoints
| Method | Operation | Path |
| --- | --- | --- |
| `GET` | [List pacing rules](/api/endpoints/pacing/list-pacing-rules) | `/pacing-rules` |
| `POST` | [Create a pacing rule](/api/endpoints/pacing/create-pacing-rule) | `/pacing-rules` |
| `GET` | [Retrieve a pacing rule](/api/endpoints/pacing/retrieve-pacing-rule) | `/pacing-rules/{rule_id}` |
| `DELETE` | [Delete a pacing rule](/api/endpoints/pacing/delete-pacing-rule) | `/pacing-rules/{rule_id}` |
| `PATCH` | [Update a pacing rule](/api/endpoints/pacing/update-pacing-rule) | `/pacing-rules/{rule_id}` |
| `GET` | [Retrieve pacing spread](/api/endpoints/pacing/retrieve-pacing-spread) | `/pacing-rules/spread` |
| `PATCH` | [Update pacing spread](/api/endpoints/pacing/update-pacing-spread) | `/pacing-rules/spread` |
| `GET` | [Retrieve the pacing queue](/api/endpoints/pacing/retrieve-pacing-queue) | `/pacing-queue` |
| `POST` | [Release emails from the pacing queue](/api/endpoints/pacing/release-pacing-queue) | `/pacing-queue/release` |
## Request essentials [#request-essentials]
Use `https://api.aiinbx.com/api/v2` as the base URL and send your API key in `Authorization: Bearer `. Keep credentials on your server.
See [Authentication](/authentication) for scopes, [Conventions](/reference/conventions) for pagination and idempotency, and [Errors](/reference/errors) for recovery guidance. Each endpoint page includes its parameters, response schemas, and copyable curl, JavaScript, and Python examples.
---
# Spaces API
Source: https://docs.aiinbx.com/api/spaces
{/* Generated by scripts/openapi.ts from the shared API contract. */}
Group a workspace's domains, mailboxes and mail by customer. Optional: a workspace that is not a platform never needs one.
## Endpoints
| Method | Operation | Path |
| --- | --- | --- |
| `GET` | [List spaces](/api/endpoints/spaces/list-spaces) | `/spaces` |
| `POST` | [Create a space](/api/endpoints/spaces/create-space) | `/spaces` |
| `GET` | [Retrieve a space](/api/endpoints/spaces/retrieve-space) | `/spaces/{space_id}` |
| `DELETE` | [Delete a space](/api/endpoints/spaces/delete-space) | `/spaces/{space_id}` |
| `PATCH` | [Update a space](/api/endpoints/spaces/update-space) | `/spaces/{space_id}` |
## Request essentials [#request-essentials]
Use `https://api.aiinbx.com/api/v2` as the base URL and send your API key in `Authorization: Bearer `. Keep credentials on your server.
See [Authentication](/authentication) for scopes, [Conventions](/reference/conventions) for pagination and idempotency, and [Errors](/reference/errors) for recovery guidance. Each endpoint page includes its parameters, response schemas, and copyable curl, JavaScript, and Python examples.
---
# Suppressions API
Source: https://docs.aiinbx.com/api/suppressions
{/* Generated by scripts/openapi.ts from the shared API contract. */}
Prevent delivery to opted-out or unsafe recipients.
## Endpoints
| Method | Operation | Path |
| --- | --- | --- |
| `GET` | [List suppressions](/api/endpoints/suppressions/list-suppressions) | `/suppressions` |
| `POST` | [Create suppressions](/api/endpoints/suppressions/create-suppressions) | `/suppressions` |
| `GET` | [Retrieve a suppression](/api/endpoints/suppressions/retrieve-suppression) | `/suppressions/{suppression_id}` |
| `DELETE` | [Delete a suppression](/api/endpoints/suppressions/delete-suppression) | `/suppressions/{suppression_id}` |
## Request essentials [#request-essentials]
Use `https://api.aiinbx.com/api/v2` as the base URL and send your API key in `Authorization: Bearer `. Keep credentials on your server.
See [Authentication](/authentication) for scopes, [Conventions](/reference/conventions) for pagination and idempotency, and [Errors](/reference/errors) for recovery guidance. Each endpoint page includes its parameters, response schemas, and copyable curl, JavaScript, and Python examples.
---
# Threads API
Source: https://docs.aiinbx.com/api/threads
{/* Generated by scripts/openapi.ts from the shared API contract. */}
Read email conversations.
## Endpoints
| Method | Operation | Path |
| --- | --- | --- |
| `GET` | [List threads](/api/endpoints/threads/list-threads) | `/threads` |
| `GET` | [Retrieve a thread](/api/endpoints/threads/retrieve-thread) | `/threads/{thread_id}` |
| `POST` | [Reply in a thread](/api/endpoints/threads/reply-to-thread) | `/threads/{thread_id}/reply` |
| `POST` | [Forward a thread](/api/endpoints/threads/forward-thread) | `/threads/{thread_id}/forward` |
## Request essentials [#request-essentials]
Use `https://api.aiinbx.com/api/v2` as the base URL and send your API key in `Authorization: Bearer `. Keep credentials on your server.
See [Authentication](/authentication) for scopes, [Conventions](/reference/conventions) for pagination and idempotency, and [Errors](/reference/errors) for recovery guidance. Each endpoint page includes its parameters, response schemas, and copyable curl, JavaScript, and Python examples.
---
# Webhooks API
Source: https://docs.aiinbx.com/api/webhooks
{/* Generated by scripts/openapi.ts from the shared API contract. */}
Configure event delivery and inspect delivery attempts.
## Endpoints
| Method | Operation | Path |
| --- | --- | --- |
| `GET` | [List webhook endpoints](/api/endpoints/webhooks/list-webhook-endpoints) | `/webhook-endpoints` |
| `POST` | [Create a webhook endpoint](/api/endpoints/webhooks/create-webhook-endpoint) | `/webhook-endpoints` |
| `GET` | [Retrieve a webhook endpoint](/api/endpoints/webhooks/retrieve-webhook-endpoint) | `/webhook-endpoints/{endpoint_id}` |
| `DELETE` | [Delete a webhook endpoint](/api/endpoints/webhooks/delete-webhook-endpoint) | `/webhook-endpoints/{endpoint_id}` |
| `PATCH` | [Update a webhook endpoint](/api/endpoints/webhooks/update-webhook-endpoint) | `/webhook-endpoints/{endpoint_id}` |
| `POST` | [Rotate a webhook signing secret](/api/endpoints/webhooks/rotate-webhook-secret) | `/webhook-endpoints/{endpoint_id}/rotate-secret` |
| `POST` | [Send a test webhook](/api/endpoints/webhooks/test-webhook-endpoint) | `/webhook-endpoints/{endpoint_id}/test` |
| `GET` | [List webhook deliveries](/api/endpoints/webhooks/list-webhook-deliveries) | `/webhook-endpoints/{endpoint_id}/deliveries` |
| `POST` | [Retry webhook deliveries](/api/endpoints/webhooks/retry-webhook-deliveries) | `/webhook-endpoints/{endpoint_id}/deliveries/retry` |
## Request essentials [#request-essentials]
Use `https://api.aiinbx.com/api/v2` as the base URL and send your API key in `Authorization: Bearer `. Keep credentials on your server.
See [Authentication](/authentication) for scopes, [Conventions](/reference/conventions) for pagination and idempotency, and [Errors](/reference/errors) for recovery guidance. Each endpoint page includes its parameters, response schemas, and copyable curl, JavaScript, and Python examples.
---
# Authentication
Source: https://docs.aiinbx.com/authentication
Every request carries an API key as a bearer token:
```bash
curl https://api.aiinbx.com/api/v2/threads \
-H "Authorization: Bearer $AI_INBX_API_KEY"
```
Both SDKs read `AI_INBX_API_KEY` from the environment, so the common case needs no argument:
```ts TypeScript
import AIInbx from "aiinbx"
const aiinbx = new AIInbx() // or new AIInbx({ apiKey: "..." })
```
```python Python
from aiinbx import AIInbx
with AIInbx() as client: # or AIInbx(api_key="...")
...
```
A key belongs to one workspace, reaches every [space](/guides/spaces) in it, and can never cross into another workspace. Keys are for your servers; a customer who needs the API goes through your product, never through AI Inbx directly.
## Base URL
```
https://api.aiinbx.com/api/v2
```
Both [SDKs](/sdks) default to it. Override with `baseURL` (TypeScript) or `base_url` (Python) when you're pointing at a local instance.
:::warning[Missing or invalid keys]
A request with no `Authorization` header, a malformed one, or a deleted key gets `401` with the code [`unauthorized`](/reference/errors#unauthorized) and a `WWW-Authenticate: Bearer` header. A valid key that lacks the scope for the operation gets `403` [`forbidden`](/reference/errors#forbidden) — a different failure, and one retrying won't fix.
:::
## Scopes
A key is created with one of three scopes. `full` satisfies every requirement; the other two are deliberate narrowings for keys that live somewhere you'd rather not put a `full` key.
| Prop | Type | Default | Description |
| - | - | - | - |
| `full` | `scope` | - | Everything. Required for anything that changes configuration — keys, domains, mailboxes, OAuth apps, webhook endpoints, suppressions, and pacing rules. |
| `sending?` | `scope` | - | Reads, plus sending: send an email, reply or forward a thread, reschedule, and cancel. Cannot change configuration. |
| `read?` | `scope` | - | Reads only: list and retrieve emails, threads, domains, mailboxes, deliveries, suppressions, and pacing state. |
Concretely:
| Operation | `read` | `sending` | `full` |
| --------------------------------------------------------------------------------------------------------- | :----: | :-------: | :----: |
| List and retrieve emails, threads, attachments | ✓ | ✓ | ✓ |
| Read domains, mailboxes, webhook endpoints and deliveries, suppressions, pacing | ✓ | ✓ | ✓ |
| [Send an email](/guides/sending), [reply on a thread](/guides/threads#replying) | | ✓ | ✓ |
| [Reschedule](/guides/scheduling#rescheduling) or [cancel](/guides/scheduling#canceling) a scheduled email | | ✓ | ✓ |
| Create, update, delete domains, mailboxes, OAuth apps, webhook endpoints | | | ✓ |
| Manage [suppressions](/guides/suppressions) and [pacing rules](/guides/pacing) | | | ✓ |
| Create and delete API keys | | | ✓ |
:::tip
A worker that only sends should hold a `sending` key, and an analytics job that only reads should hold a `read` one. Both are ordinary keys — the narrowing is enforced server-side, so a leaked `read` key cannot send.
:::
## Managing keys
Keys are created in the [console](https://aiinbx.com/app) or through the API with a `full` key. The plaintext key is returned **once**, on the create call:
```ts TypeScript
const created = await aiinbx.apiKeys.create({
name: "background worker",
scope: "sending",
})
console.log(created.key) // the only time you see it
```
```python Python
created = client.api_keys.create(name="background worker", scope="sending")
print(created["key"]) # the only time you see it
```
```bash curl
curl https://api.aiinbx.com/api/v2/api-keys \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "background worker", "scope": "sending" }'
```
Afterwards a key lists as metadata only — its `prefix`, `scope`, `created_at`, and `last_used_at`. `last_used_at` is the field to check before deleting a key you're no longer sure about.
### Rotating
Rotate a key by creating a replacement, deploying it, and deleting the old key after the cutover.
1. **Create the replacement**
Same scope, a name that says what it replaces.
2. **Deploy it**
Roll it out everywhere the old key was configured.
3. **Watch last_used_at**
List keys until the old one stops advancing.
4. **Delete the old key**
`apiKeys.delete(id)` — takes effect immediately.
Deletion is permanent. The key disappears from the list and authenticates nothing.
## Request IDs
Every response carries `X-Request-ID`, and every error body repeats it as `request_id`. If you send your own `X-Request-ID` — up to 128 characters of `A-Za-z0-9._:-` — it's echoed back instead of a generated one, which is what you want when you already have a trace ID to correlate against. See [Conventions](/reference/conventions#request-ids).
---
# Core concepts
Source: https://docs.aiinbx.com/concepts
A workspace contains your API keys, sending identities, messages, and configuration. Use the resources below to configure where mail comes from, work with conversations, and handle delivery outcomes.
## The two halves
Everything here divides cleanly. On one side are the objects that establish **who you can send as**: a domain you publish DNS for, or a mailbox a customer authorized. On the other are the objects that carry **what you sent and what came back**: emails, threads, and the events they emit.
You need exactly one thing from the first half before anything in the second half works.
**Identity**
**Domain** and **Mailbox** — the two ways an address becomes yours to send
from. Pick one; most products end up with both.
**Traffic**
**Email**, **Thread**, **Attachment** — the messages themselves, and the
conversation each one belongs to.
## Identity
### Domain
A domain you control the DNS for. Creating one returns the records to publish — SPF, DKIM, DMARC, a return path, and an MX record if you want to receive on it — and the domain can send once its DKIM identity is verified. Receiving also requires the inbound MX record.
A domain does not have to be _yours_. Publishing the records at a customer's registrar makes their domain send through you, which is how a product sends as `support@theircompany.com`.
```
dom_… → verified_at, region, records[]
```
**[Domains](/guides/domains)**
Each record, what `diagnostics` reports when one won't resolve, and regions.
### Mailbox
A Gmail or Outlook account a customer authorized over OAuth. No DNS, no verification — they click through a consent screen and the mailbox is live. In exchange you get one address rather than a whole domain, and the authorization can expire.
```
mbx_… → address, provider, app_id, state
```
**[Mailboxes](/guides/mailboxes)**
The connect flow, `ref` for matching a mailbox to your user, and reauth.
:::note
Domain and mailbox aren't "yours" versus "your customer's" — they're two different permissions. A domain needs DNS access; a mailbox needs an OAuth grant. Which one you can get is usually decided by the customer, not by you.
:::
## Traffic
### Email
One message, inbound or outbound, with its addresses, bodies, headers, and attachments. Sending returns one; receiving one fires a webhook that names it.
An email's `status` reports **acceptance**, not delivery — `queued`, `sending`, `sent`, `scheduled`. What actually happened at the far end arrives later, as events.
```
eml_… → thread_id, status, suppressed[], pacing
```
### Thread
The conversation an email belongs to, and the reason this API exists. A reply is matched onto its thread by RFC headers first, then by quoted content — never by subject line alone, which is what makes unrelated "Re: Hi" messages collide elsewhere.
Because the thread is a real object, replying takes the thread ID and your text. The sender, the recipients, the subject, and the `In-Reply-To`/`References` headers are inferred from what's already on the thread.
```
thr_… → subject, mailbox, messages[]
```
**[Threads](/guides/threads)**
How matching decides, and what `threads.reply` fills in for you.
### Attachment
A file on an email. Outbound, you pass base64 content. Inbound, you get metadata plus a `preparation` field — AI Inbx extracts a received PDF to Markdown so an agent can read it without you running a parser.
```
att_… → filename, content_type, size, preparation
```
## Delivery outcomes
### Event and webhook endpoint
Everything that happens after acceptance is an **event**: delivered, bounced, complained, opened, clicked, unsubscribed, plus the inbound `email.received` that starts most applications. A **webhook endpoint** is a URL subscribed to some of them.
Subscribe to webhooks for delivery outcomes as they occur. You can also retrieve an email and inspect its `events` array. A provider accepting a message does not establish that it reached the recipient’s inbox.
```
whk_… → url, subscriptions[], secret
evt_… → type, created_at, data
```
**[Webhooks](/webhooks)**
Endpoints, subscriptions, retries, and replay.
**[Event types](/webhooks/events)**
Each event type and its payload fields.
## Sending controls
Two objects exist only to stop a send that shouldn't happen.
### Suppression
An address that must not be mailed, on a named list. Bounces and complaints create these automatically; unsubscribes and your own calls add more. Every send is checked against the list named by `suppression_key`, plus the org-wide `*` list.
Suppression is why a send can succeed with recipients missing — read `suppressed` on the response.
```
sup_… → address, key, scope, reason
```
### Pacing rule
A ceiling on how fast a lane sends: a window, a rate, a spread. A held message comes back with a non-null `pacing` object naming the rule and projecting when it will go.
Transactional mail shouldn't queue behind a campaign, so a send can set `pacing.skip`.
```
pace_… → window, rate, spread
```
**[Suppressions](/guides/suppressions)**
Lists, scopes, and one-click unsubscribe.
**[Pacing](/guides/pacing)**
Lanes, holds, and releasing a queue by hand.
## Grouping by customer
### Space
Optional. A group inside the workspace — one per customer, typically — that owns domains and mailboxes, and with them every email and thread that went through those. Pacing rules and suppression lists can be put in a space too, and then reach that space alone. API keys and webhook endpoints are workspace-wide.
Mail is never told its space: an email is in the space of the domain or mailbox it went through, and a reply is in its thread's. Any space-aware resource created without a space belongs to the workspace itself and reads `space_id: null`.
```
spc_… → name
```
**[Spaces](/guides/spaces)**
When you need one, and a platform walkthrough end to end.
## A message, through all of it
1. **You send**
`POST /emails` checks the `from` address against a verified **domain** or an
active **mailbox**, checks each recipient against the **suppression** lists,
and asks the **pacing** rules whether the lane has room.
2. **An email and a thread exist**
You get an **email** back with a `thread_id` — a new **thread** if nothing
matched, an existing one if `thread_id` was passed. `suppressed` lists who was
dropped; `pacing` is non-null if the message is held.
3. **Events arrive**
`email.sent`, then `email.delivered` — or `email.bounced`, which writes a
**suppression** so the next send to that address never leaves.
4. **They reply**
Their message is matched onto the same **thread** and arrives as
`email.received`, carrying the `thread_id` and a `category` that tells you
how the message was classified. Classification is a signal for your application, not proof of sender identity.
5. **You reply**
`threads.reply` with the thread ID and your text. Every header that makes it a
real reply is filled in from the thread.
## Everything has an ID
Prefixed, opaque, stable, safe to store. The prefix is part of the ID — an ID of the wrong type is a `404`, never a silent match against another resource.
**[Conventions](/reference/conventions)**
The full prefix table, pagination, timestamps, request IDs, and idempotency.
---
# Attachments
Source: https://docs.aiinbx.com/guides/attachments
Inbound attachments come back two ways. There's the **original file**, byte for byte, and there's the **prepared text** — the same document extracted to Markdown or plain text so it can go straight into a prompt.
Sending attachments is covered in [Sending](/guides/sending#attachments); this page is about what arrives.
## What's on a message
Retrieve an email and each attachment carries its metadata plus two URLs:
```ts
const email = await aiinbx.emails.retrieve("eml_...")
for (const attachment of email.attachments) {
console.log(attachment.filename, attachment.content_type, attachment.size)
console.log(attachment.preparation?.status, attachment.preparation?.format)
}
```
| Prop | Type | Default | Description |
| - | - | - | - |
| `id` | `string` | - | att_… |
| `filename` | `string` | - | As the sender named it. |
| `content_type` | `string` | - | The declared media type. |
| `size` | `number` | - | Bytes. |
| `cid` | `string \| null` | - | Content-ID when the attachment is inline in the HTML body. |
| `download_url` | `string` | - | Short-lived signed URL for the original file. |
| `preparation` | `object \| null` | - | Extraction result, or null when nothing was attempted. |
:::warning
`download_url` and `content_url` are signed and expire. Fetch them when you need the bytes — don't persist them in your database and expect them to work tomorrow. Store the `id` instead and resolve it on demand.
:::
## Downloading the original
Both SDKs resolve the redirect for you:
```ts TypeScript
const response = await aiinbx.attachments.download("att_...")
const bytes = new Uint8Array(await response.arrayBuffer())
```
```python Python
response = client.attachments.download("att_...")
data = response.content
```
```bash curl
curl -L https://api.aiinbx.com/api/v2/attachments/att_... \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-o report.pdf
```
The endpoint answers `307` with a `Location` header pointing at a signed URL — hence `-L` on the curl. A `HEAD` on the same path returns the metadata in headers (`Content-Type`, `Content-Length`, `Content-Disposition`) without transferring the file, which is the cheap way to check a size before committing to a download.
## Prepared text
Documents that can be read as text are extracted on arrival. `preparation` tells you how that went:
| Prop | Type | Default | Description |
| - | - | - | - |
| `status` | `"ready" \| "partial" \| "unsupported" \| "failed"` | - | Whether the extraction produced usable text. |
| `format` | `"markdown" \| "text" \| null` | - | Markdown keeps document structure; text is a flat rendering. |
| `pages` | `number \| null` | - | Page count, where the format has pages. |
| `warnings` | `string[]` | - | What was skipped or approximated — worth logging on partial results. |
| `content_url` | `string \| null` | - | Short-lived signed URL for the extracted text. |
| `text?` | `string \| null` | - | The extracted text inline — only present when you ask for it. |
| Status | What it means |
| --- | --- |
| `ready` | The whole document was extracted. |
| `partial` | Some of it came through; `warnings` says what didn't. Usable, with care. |
| `unsupported` | The file type has no text to extract — an image, an archive, a binary. |
| `failed` | Extraction was attempted and didn't work. The original is still downloadable. |
### Reading it
Fetch the text on its own:
```ts TypeScript
const response = await aiinbx.attachments.content("att_...")
const markdown = await response.text()
```
```python Python
markdown = client.attachments.content("att_...").text
```
```bash curl
curl -L https://api.aiinbx.com/api/v2/attachments/att_.../content \
-H "Authorization: Bearer $AI_INBX_API_KEY"
```
Or pull every attachment's text down with the message in one call, which is usually what you want before a model call:
```ts TypeScript
const email = await aiinbx.emails.retrieve("eml_...", {
include: ["attachment_content"],
})
const documents = email.attachments
.filter((a) => a.preparation?.text)
.map((a) => `## ${a.filename}\n\n${a.preparation!.text}`)
```
```python Python
email = client.emails.retrieve("eml_...", include=["attachment_content"])
documents = [
f"## {a['filename']}\n\n{a['preparation']['text']}"
for a in email["attachments"]
if a.get("preparation", {}).get("text")
]
```
```bash curl
curl "https://api.aiinbx.com/api/v2/emails/eml_...?include=attachment_content" \
-H "Authorization: Bearer $AI_INBX_API_KEY"
```
An attachment with no prepared text — because it's `unsupported`, `failed`, or still being worked on — comes back with `text: null`. Asking for content on one that was never prepared returns `404` [`not_prepared`](/reference/errors#not_prepared).
## Handling an incoming document
```ts
if (event.type !== "email.received") return
const email = await aiinbx.emails.retrieve(event.data.email_id, {
include: ["attachment_content"],
})
for (const attachment of email.attachments) {
const prep = attachment.preparation
if (prep?.status === "ready" || prep?.status === "partial") {
if (prep.warnings.length) {
console.warn(attachment.filename, prep.warnings)
}
await ingest(attachment.filename, prep.text!)
continue
}
// No text to read — keep the original for a human.
const file = await aiinbx.attachments.download(attachment.id)
await archive(attachment.filename, await file.arrayBuffer())
}
```
The `email.received` webhook also carries a compact attachment list, so you can decide whether a message is worth fetching before you fetch it.
## Timing
Preparation runs as the message is ingested, so by the time `email.received` reaches you the result is normally already there. A large document can still be in flight — `preparation` is `null` in that case rather than an error. If you're handling big files, re-fetch the email rather than treating `null` as "no text".
---
# Domains
Source: https://docs.aiinbx.com/guides/domains
Adding a domain makes every address on it usable for sending, and — if you publish the MX record — for receiving too. Adding one returns the DNS records to publish; verification checks that they resolve.
The domain does not have to be yours. A multi-tenant product adds a customer's domain, shows them the returned records to publish at their registrar, and polls `verify` or waits for [`domain.verified`](/webhooks/events/domain-verified). The only requirement is that _someone_ can publish DNS for it. Where nobody can, connect a [mailbox](/guides/mailboxes) instead.
## Your provided domain
Every workspace comes with one domain: `.aiinbx.app`, where `` is the workspace's URL handle — like a `.vercel.app` domain. It works right away, every address on it sends and receives, and there is no DNS to set up. Handy for a side project, or for getting going before you bring a domain of your own.
```ts
const domains = await aiinbx.domains.list().all()
const provided = domains.find((domain) => domain.provided)
await aiinbx.emails.send({
from: `hello@${provided.name}`,
to: "someone@example.com",
subject: "Sent before a single DNS record",
text: "It works out of the box.",
})
```
It is listed with `provided: true`, has no `records`, and cannot be deleted — it goes when the workspace does. Renaming the workspace does not rename it. A name under `aiinbx.app` cannot be added by hand, and fails with [`reserved_domain`](/reference/errors#reserved_domain).
## Adding a domain
```ts TypeScript
const domain = await aiinbx.domains.create({
name: "example.com",
region: "eu-central-1",
})
for (const record of domain.records ?? []) {
console.log(
record.purpose,
record.type,
record.name,
record.value,
record.ttl
)
}
```
```python Python
domain = client.domains.create(name="example.com", region="eu-central-1")
for record in domain.get("records", []):
print(record["purpose"], record["type"], record["name"], record["value"])
```
```bash curl
curl https://api.aiinbx.com/api/v2/domains \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "example.com", "region": "eu-central-1" }'
```
Creating, updating, and deleting domains needs a `full` [scope](/authentication#scopes) key.
### Regions
`region` selects the regional mail infrastructure for this domain: `eu-central-1` (the default) or `us-east-1`. It is fixed at creation — moving a domain between regions means deleting and re-adding it, and republishing DNS. Do not treat this setting alone as a guarantee that all application data, logs, or subprocessors stay in that region. Confirm your residency requirements separately before choosing a region.
Adding the same identity twice in one workspace fails with `409` [`domain_taken`](/reference/errors#domain_taken). That includes the apex and wildcard forms of one name, such as `example.com` and `*.example.com`. See [Moving a domain between workspaces](#moving-a-domain-between-workspaces).
### Spaces
`space_id` puts the domain in a [space](/guides/spaces), and with it every email and thread that goes through the domain from then on. Omit it and the domain belongs to the workspace itself, `space_id: null`. There is no move afterwards; a space is decided when the domain is created.
## The records
`records` lists everything to publish, each with the `purpose` it serves and a `state` of `pending`, `verified`, or `missing`.
| Purpose | Type | What it does |
| ------------- | ------- | ---------------------------------------------------------------------------------------------------- |
| `SPF` | `TXT` | Authorizes AI Inbx to send for the domain. |
| `DKIM` | `TXT` | Publishes the keys that sign your outgoing mail. |
| `DMARC` | `TXT` | Tells receivers what to do when SPF or DKIM fails, and where to send reports. |
| `RETURN_PATH` | `MX` | Routes bounces and complaints back so delivery status is accurate. |
| `INBOUND` | `MX` | Delivers mail _to_ the domain into AI Inbx. Only needed if you want to [receive](/guides/receiving). |
:::warning[The MX record is a commitment]
Publishing the `INBOUND` MX record routes **all** mail for the domain to AI Inbx. If the domain already receives mail somewhere — a Google Workspace or Microsoft 365 tenant, say — that takes the existing inboxes away. Use a dedicated subdomain (`mail.example.com`) for inbound instead, or connect the existing mailbox over OAuth. See [Mailboxes](/guides/mailboxes).
This is the single most important thing to get right when you're onboarding someone else's domain: they will almost always already be receiving mail on it.
:::
Publish the exact names, types, and values returned by the API. Check existing records before making changes, especially an existing DKIM selector or DMARC policy. Sending requires DKIM verification; the return-path, SPF, and DMARC records configure authentication and bounce handling. Receiving separately requires the inbound MX record.
## Verifying
Publishing DNS doesn't tell us anything by itself, so ask for a check:
```ts TypeScript
const domain = await aiinbx.domains.verify("dom_...")
if (domain.verified_at) {
console.log("ready to send")
} else {
const missing = domain.records?.filter((r) => r.state !== "verified")
console.log(
"still waiting on",
missing?.map((r) => r.purpose)
)
}
```
```python Python
domain = client.domains.verify("dom_...")
print(domain["verified_at"])
```
```bash curl
curl -X POST https://api.aiinbx.com/api/v2/domains/dom_.../verify \
-H "Authorization: Bearer $AI_INBX_API_KEY"
```
`verified_at` is `null` until the sending identity’s DKIM verification succeeds. It does not certify inbound MX, SPF, or DMARC configuration. Each record's `last_checked_at` tells you when it was last looked at. Propagation is usually minutes and occasionally hours — poll with a sane interval rather than in a tight loop.
Sending from an unverified domain fails with `409` [`domain_unverified`](/reference/errors#domain_unverified).
## Diagnostics
When a record won't verify and you can't see why, `diagnostics` reads the live zone and reports what it finds. It's a read-only check — nothing is stored, so it always reflects DNS as it is right now.
```ts TypeScript
const { data } = await aiinbx.domains.diagnostics("dom_...")
for (const finding of data) {
console.log(`[${finding.severity}] ${finding.title}`)
console.log(finding.detail)
console.log("fix:", finding.fix)
}
```
```python Python
findings = client.domains.diagnostics("dom_...")["data"]
for finding in findings:
print(finding["severity"], finding["title"], finding["fix"])
```
```bash curl
curl https://api.aiinbx.com/api/v2/domains/dom_.../diagnostics \
-H "Authorization: Bearer $AI_INBX_API_KEY"
```
Each finding carries a `severity`, the `purpose` it concerns, a `detail` explaining what's wrong, a `fix` describing what to change, and `evidence` — the actual records read from the zone.
| Prop | Type | Default | Description |
| - | - | - | - |
| `breaking` | `severity` | - | Mail will fail or be rejected. Fix before sending. |
| `risk?` | `severity` | - | Deliverability is likely to suffer — a too-permissive SPF policy, a DMARC record set to none. |
| `tip?` | `severity` | - | A hardening suggestion. Nothing is broken. |
Findings also carry `when`: `adding` applies while you're setting the domain up, `added` to one already live, and `both` either way.
The classic breaking finding is two SPF records on the same name — receivers treat that as a permanent error and ignore both. Diagnostics names the offending records so you know which to merge.
## Wildcards and subdomains
A wildcard — `*.saas.com` — is one domain that covers every subdomain. Publish its records once and `anything.saas.com` can send and receive, without touching DNS again. It is how a platform hands each customer an address of their own.
```ts
const wildcard = await aiinbx.domains.create({ name: "*.saas.com" })
```
The records go in the `saas.com` zone, but the MX record sits at the wildcard rather than the apex — `saas.com` keeps whatever mail setup it already has, and only its subdomains route to AI Inbx:
| Purpose | Type | Name |
| ------------- | ----- | -------------------------- |
| `DKIM` | `TXT` | `aibx._domainkey.saas.com` |
| `INBOUND` | `MX` | `*.saas.com` |
| `RETURN_PATH` | `MX` | `bounces.saas.com` |
| `SPF` | `TXT` | `bounces.saas.com` |
| `DMARC` | `TXT` | `_dmarc.saas.com` |
`saas.com` and `*.saas.com` are the same identity on the mail provider, so only one of the two can exist here: adding the second fails with [`domain_taken`](/reference/errors#domain_taken). Pick the apex if you send from `saas.com` itself; pick the wildcard if the addresses are `.saas.com`.
:::warning[An existing wildcard CNAME]
A name that holds a `CNAME` may hold nothing else. If `*.saas.com` is already a wildcard `CNAME` — customer sites on `.saas.com`, typically — the MX record cannot be published beside it. Use a level down, `*.mail.saas.com`, and give customers `.mail.saas.com`. `diagnostics` reports a zone that already answers for unset names as a `wildcard-zone` finding, which is the sign to look for.
:::
### Subdomains
Once a wildcard is yours, a name one label under it — `acme.saas.com` under `*.saas.com` — is created as a **subdomain** of it:
```ts TypeScript
const domain = await aiinbx.domains.create({
name: "acme.saas.com",
space_id: "spc_...",
})
console.log(domain.parent_id) // the wildcard's id
console.log(domain.records) // []
```
```python Python
domain = client.domains.create(name="acme.saas.com", space_id="spc_...")
print(domain["parent_id"], domain["verified_at"])
```
```bash curl
curl https://api.aiinbx.com/api/v2/domains \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "acme.saas.com", "space_id": "spc_..." }'
```
A subdomain has no records of its own and nothing to verify — `verified_at` is set the moment the wildcard's is, and follows it. `parent_id` names the wildcard. `region` and the tracking flags are inherited. It is usually created in a [space](/guides/spaces), so that the customer's mail is theirs.
What is not a subdomain: `a.b.saas.com` is two labels down and is an ordinary domain with records of its own, and so is `acme.saas.com` in a workspace that has no `*.saas.com` yet.
One name is refused: `bounces.saas.com` is the wildcard's return path, and fails with `409` [`reserved_subdomain`](/reference/errors#reserved_subdomain).
### Deleting
A subdomain is a row: deleting it unpublishes nothing. Deleting a wildcard deletes every subdomain filed under it, in whatever [space](/guides/spaces) they sit — they are names under its identity and its records, and nothing is left to publish for them. Mail already sent or received is kept either way. Remove DNS records that belonged to the deleted connection after checking that they are no longer needed.
## Tracking settings
`PATCH /domains/{id}` toggles open and click tracking:
```ts
await aiinbx.domains.update("dom_...", {
track_opens: false,
track_clicks: true,
})
```
The values are returned on the domain as `tracking.opens` and `tracking.clicks`.
:::warning[Not yet enforced at send time]
These flags are stored and reported, but the send path does not currently read them — engagement tracking is applied to every send regardless of the per-domain setting. Treat them as a stated preference, not an enforced one, and don't rely on `track_opens: false` to suppress open tracking today.
:::
## Listing and deleting
```ts TypeScript
const domains = await aiinbx.domains.list().all()
await aiinbx.domains.delete("dom_...")
```
```python Python
for domain in client.domains.iter():
print(domain["name"], domain["verified_at"])
client.domains.delete("dom_...")
```
Deleting a domain stops future sends from it. Remove the DNS records afterwards — an SPF or DKIM record pointing at a domain that no longer exists is dead weight in your zone. The [provided domain](#your-provided-domain) cannot be deleted: the call fails with [`domain_provided`](/reference/errors#domain_provided).
## Knowing when it's ready
Rather than polling, subscribe to [`domain.verified`](/webhooks/events/domain-verified):
```ts
if (event.type === "domain.verified") {
console.log(event.data.name, "is ready in", event.data.region)
}
```
## Staying verified
Verification is not a one-off. Domains are rechecked in the background, and one whose DKIM record stops resolving loses `verified_at` again: sends from it fail with [`domain_unverified`](/reference/errors#domain_unverified) until the record is back, and [`domain.lost`](/webhooks/events/domain-lost) tells you it happened. Nothing else changes — the domain, its records and its mail stay where they were, and the next check that finds the key restores it.
Both transitions are also mailed to the workspace's owners and admins, batched per workspace: one mail lists everything that verified or was lost since the last one, lost first. Domains connected inside a [space](/guides/spaces) are never mailed — the webhook has them. Each admin sets their own level — Everything, Problems only, or Off — under Settings → Notifications, or from the link at the bottom of any of these mails.
## Moving a domain between workspaces
A domain name can be connected in multiple workspaces, but only the workspace whose DKIM key is currently published can send from it. Add the domain in the destination workspace, then publish the DNS records returned by that connection. The next verification check transfers sending to it and marks the previous connection as lost. Existing mail remains in the workspace that sent or received it.
Within one workspace, the apex and wildcard are one connection: `example.com` and `*.example.com` cannot both be added there. Where multiple workspaces need to send at the same time, give each one a distinct subdomain.
---
# Mailboxes
Source: https://docs.aiinbx.com/guides/mailboxes
A **mailbox** is an existing Gmail or Outlook account, connected once over OAuth. Its mail keeps flowing through Google or Microsoft exactly as before; AI Inbx syncs it, and can send and receive as that address.
That's the difference from a [domain](/guides/domains), which needs DNS access and takes over mail for every address on it. A mailbox needs nothing but the account holder clicking approve, changes nothing about their existing setup, and covers one address.
Reach for it when the address already has an inbox somewhere — a rep's work account, a founder's inbox — and the mail should read as coming from that person.
## Connecting a mailbox
Three ways in, ordered by how much of the flow you own. They all end the same way: the person approves at Google or Microsoft, the mailbox lands in your workspace, and [`mailbox.connected`](/webhooks/events/mailbox-connected) fires.
| | You write | Your users see | Needs |
| --- | --- | --- | --- |
| [Hosted page](#hosted-page) | Nothing — share a link | A page in your brand, then "you're connected" | Your own [OAuth app](#your-own-oauth-app) |
| [Connect link](#connect-link) | A URL on a button | Only the provider's consent screen, then your page | Your own OAuth app, plus a registered return URL |
| [API](#api) | A server-side call per connection | Only the provider's consent screen, then your page | A `full` API key |
### Hosted page
Every OAuth app of yours has a page at its `connect_url` — `https://aiinbx.com/connect/` — wearing your name, logo, accent and support address, with nothing of AI Inbx on it. Send people there, or put it in an email; it's a permanent link, not a one-time one.
```
https://aiinbx.com/connect/acme?ref=user_8812
```
| Prop | Type | Default | Description |
| - | - | - | - |
| `ref?` | `string` | - | Your own identifier for the person, up to 200 characters. Echoed on mailbox.connected. |
| `return_to?` | `string` | - | One of the app's registered return URLs. Adds a "Continue to " button to the page they end on, carrying the same query as a connect link does. |
Brand the page from the app's settings in the console, or through `oauthApps.create` — `accent`, `tagline`, `support_email`, `logo_url`.
### Connect link
The same handoff with no page of ours in it. The person clicks a button on your site, lands on the consent screen, and comes back to a page of yours. Build the URL yourself; there's nothing to call first:
```
https://aiinbx.com/connect//start?return_to=&ref=
```
| Prop | Type | Default | Description |
| - | - | - | - |
| `return_to` | `string` | - | Where they land afterwards. Must match one of the app's return_urls on origin and path; the query is yours to fill. |
| `ref?` | `string` | - | Your own identifier for the person, up to 200 characters. Echoed on mailbox.connected. |
1. **Register where the link may return to**
A connect link is opened with no credential, so it will only send someone back to a page you've listed on the app — the same idea as the redirect URIs you register with Google. Add them in the console, or on the app:
```ts
await aiinbx.oauthApps.update(app.id, {
return_urls: [
"https://app.example.com/settings/mailboxes",
"http://localhost:3000/settings/mailboxes",
],
})
```
Entries are matched on origin and path, so one covers every query string that page is opened with. `https` only, except on `localhost`. An unregistered `return_to` fails with [`return_url_not_registered`](/reference/errors#return_url_not_registered) before anyone reaches the provider.
2. **Put the link on a button**
```tsx React
const connectUrl = new URL(`https://aiinbx.com/connect/acme/start`)
connectUrl.searchParams.set("return_to", "https://app.example.com/settings/mailboxes")
connectUrl.searchParams.set("ref", user.id)
Connect your Gmail
```
```html HTML
Connect your Gmail
```
3. **Read the outcome off the query**
They come back to `return_to` with the result appended:
| Query | When | Value |
| --- | --- | --- |
| `mailbox` | Connected | The new mailbox's `mbx_…` id |
| `connected` | Connected | The address they authorized |
| `error` | Declined or failed | What went wrong, e.g. `access_denied` |
Fine for showing a "connected" state on the page. Not proof — see the last step.
4. **Wait for mailbox.connected**
The webhook is the authoritative signal, and carries your `ref`. The browser landing on `return_to` only says it came back; a person who closes the tab at the consent screen never triggers it.
```ts
if (event.type === "mailbox.connected") {
await markMailboxReady(event.data.ref, event.data.mailbox_id)
// `reconnected` is true when this replaced an existing authorization.
}
```
### API
For when your server should decide — the region, how much history to import, or the shared AI Inbx app rather than your own. The URL it returns is single-use and short-lived: redirect to it, don't email it.
```ts
const { url } = await aiinbx.mailboxes.connect({
provider: "google",
return_to: "https://app.example.com/settings/mailboxes",
ref: "user_8812",
backfill_days: 30,
})
// Send the customer to `url`.
```
| Prop | Type | Default | Description |
| - | - | - | - |
| `provider` | `"google" \| "microsoft"` | - | Which provider to authorize against. |
| `return_to` | `string` | - | Where the customer lands after approving or declining. Any URL — the call is authenticated, so it isn't held to the app's return_urls. |
| `ref?` | `string` | - | Your own identifier, echoed back on mailbox.connected so you know which user finished. |
| `app_id?` | `string` | - | Use one of your own OAuth apps instead of the shared AI Inbx app — see below. |
| `space_id?` | `string` | - | The space the mailbox lands in, and with it everything synced from it. Omit for the workspace itself. |
| `region?` | `"eu-central-1" \| "us-east-1"` | - | Where this mailbox's mail is processed and stored. Defaults to eu-central-1. |
| `backfill_days?` | `number` | - | How much history to import on first sync, 0–90 days. Omit to start from the connection forward. |
The return carries the same query as a connect link, and the same rule applies: wait for `mailbox.connected` rather than trusting the redirect. A stale URL fails with [`expired_state`](/reference/errors#expired_state). Creating connection URLs requires a `full` [scope](/authentication#scopes) key.
`space_id` is the reason a platform uses this path: it is the only one of the three that can put the mailbox in a [space](/guides/spaces). The hosted page and a connect link are opened with no credential, so they cannot take a space — anyone could file a mailbox into another customer's. A mailbox lands in a space only through this call, which binds the space inside the encrypted OAuth state of the URL it returns. So a platform mints the connect URL server-side, per customer, and never hands out the bare hosted link.
## Mailbox state
```ts TypeScript
const mailbox = await aiinbx.mailboxes.retrieve("mbx_...")
console.log(mailbox.address, mailbox.state, mailbox.last_sync_at)
```
```python Python
mailbox = client.mailboxes.retrieve("mbx_...")
print(mailbox["address"], mailbox["state"])
```
| State | Meaning | What to do |
| --- | --- | --- |
| `active` | Syncing normally. | Nothing. |
| `needs_reauth` | The grant expired or was revoked — password change, admin policy, manual revocation. | Prompt the customer through the connect flow again. |
| `disconnected` | Removed, by you or by them. | Reconnect if they want it back. |
`state_reason` carries the provider's explanation when there is one. Both transitions are also webhooks — [`mailbox.needs_reauth`](/webhooks/events/mailbox-needs-reauth) and [`mailbox.disconnected`](/webhooks/events/mailbox-disconnected) — which is the right place to trigger a re-authorization prompt rather than discovering it on the next failed send. The workspace's owners and admins also get an email when a mailbox needs re-authorization, one per batch rather than per mailbox — each of them at the level they picked under Settings → Notifications.
Sending from a mailbox that isn't `active` fails with `409` [`mailbox_inactive`](/reference/errors#mailbox_inactive).
## Syncing
Mail arrives on its own — Gmail through push notifications, Outlook through Graph subscriptions. `sync` forces a catch-up when you have a reason to think something was missed:
```ts TypeScript
await aiinbx.mailboxes.sync("mbx_...") // 202, queued
```
```python Python
client.mailboxes.sync("mbx_...")
```
```bash curl
curl -X POST https://api.aiinbx.com/api/v2/mailboxes/mbx_.../sync \
-H "Authorization: Bearer $AI_INBX_API_KEY"
```
It returns `202` with `{ "status": "queued" }` — the work happens in the background, and the messages surface as ordinary `email.received` events. It isn't a way to poll for mail; it's a repair tool.
## Disconnecting
```ts
const mailbox = await aiinbx.mailboxes.disconnect("mbx_...")
console.log(mailbox.state) // "disconnected"
```
Sync stops and the stored credentials are dropped. Messages already received stay on their threads.
## Your own OAuth app
By default the consent screen says AI Inbx. Register your own Google or Microsoft app and it says your product's name instead — which is usually a requirement, not a preference, once you're asking a business customer for mailbox access.
```ts
const app = await aiinbx.oauthApps.create({
provider: "google",
name: "Acme Assistant",
slug: "acme",
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
tagline: "Let Acme handle your inbox",
support_email: "support@acme.com",
return_urls: ["https://app.acme.com/settings/mailboxes"],
})
console.log(app.redirect_uri) // register this with the provider
console.log(app.connect_url) // the hosted page; + "/start?return_to=…" is the connect link
console.log(app.push_endpoint) // Google only — the Pub/Sub push endpoint
```
| Prop | Type | Default | Description |
| - | - | - | - |
| `provider` | `"google" \| "microsoft"` | - | Which provider this app is registered with. |
| `name` | `string` | - | Shown to the customer during consent. |
| `slug` | `string` | - | 3–64 characters, unique across AI Inbx; forms the hosted connect URL. |
| `client_id` | `string` | - | From the provider's console. |
| `client_secret` | `string` | - | Stored encrypted; never returned. |
| `tenant?` | `string` | - | Microsoft only — restrict to a single Entra tenant. |
| `pubsub_topic?` | `string` | - | Google only — your own Pub/Sub topic for push notifications. |
| `accent?` | `string` | - | Accent color for the hosted connect page. |
| `tagline?` | `string` | - | One line shown under the app name. |
| `support_email?` | `string` | - | Where a confused customer should write. |
| `return_urls?` | `string[]` | - | Pages of yours a connect link may return to, up to 20. Matched on origin and path. |
1. **Register the app with the provider**
A Google Cloud OAuth client, or an Entra app registration.
2. **Create it here and read back redirect_uri**
Add that exact URI to the provider's allowed redirect list.
3. **Google only: create a Pub/Sub topic**
Grant publish rights, then set `pubsub_topic`. Push notifications go to the
returned `push_endpoint`. A bring-your-own app needs its own topic — it
cannot share the shared app's.
4. **Connect through it**
Share the hosted `connect_url`, put a [connect link](#connect-link) on your
site, or pass `app_id` to `mailboxes.connect`.
`slug` is claimed globally; a taken one fails with `409` [`slug_taken`](/reference/errors#slug_taken). A workspace configured to require its own app returns [`shared_apps_disabled`](/reference/errors#shared_apps_disabled) if you try to connect without an `app_id`.
Secrets can be rotated with `oauthApps.update`; the old value is replaced immediately, so update the provider first.
## Domains or mailboxes?
Not a question of whose address it is — either one can be yours or your customer's. A domain you add on a customer's behalf, walking them through the DNS records, is an ordinary domain. What differs is what you need from them and what you get.
| | [Domain](/guides/domains) | Mailbox |
| --- | --- | --- |
| Needs | Access to the domain's DNS | One person clicking approve |
| Covers | Every address on the domain | One existing inbox |
| Mail is handled by | AI Inbx | The existing Google or Microsoft account, synced |
| Setup | Publish records, wait for DNS | OAuth consent, live in seconds |
| Receiving | The `INBOUND` MX record | Provider sync |
| Breaks when | The records change or lapse | The grant is revoked |
The deciding question is usually whether the address already receives mail somewhere. An address on a live Google Workspace or Microsoft 365 domain has an inbox already, and pointing the MX record at AI Inbx would take that over for the whole domain — connect the mailbox instead. A domain with nowhere for its mail to go yet, or a subdomain created for the purpose, is the domain case.
Plenty of products use both, and often both on behalf of the same customer: a subdomain for anything the product sends, connected mailboxes for anything written from a person's own inbox.
---
# Pacing
Source: https://docs.aiinbx.com/guides/pacing
Pacing decides *when* an accepted message actually goes out. Two kinds of rule — sending **hours** and rate **limits** — and everything they hold sits in a queue you can read and release from.
Warming a new domain, keeping a shared mailbox under a provider's ceiling, or simply not sending business mail at 3am on a Sunday: all the same mechanism.
## How a send meets the rules
1. **Match**
A rule's `match` patterns are tested against the message's sender and
recipients. No patterns means the rule matches everything.
2. **Hold or go**
Every matching rule gets a say. If any of them says no, the message waits.
3. **Report**
The send response's `pacing` field names the rule that's holding it and
projects when it will go.
4. **Release**
The queue drains on its own as windows open and budget frees up — or you
release messages by hand.
```ts
const email = await aiinbx.emails.send(payload)
if (email.pacing && email.pacing.held_by.kind !== "ready") {
console.log(
"held by rule",
email.pacing.held_by.rule_id,
"until ~",
email.pacing.estimated_send_at
)
}
```
`held_by.kind` is `ready` (going now), `hours` (outside a sending window), or `limit` (a rate ceiling is full). `estimated_send_at` is a projection from the current queue, not a promise.
## Matching
```json
{
"match": [
{ "field": "from", "pattern": "*@newsletter.example.com" },
{ "field": "to", "pattern": "*@bigcorp.com" }
]
}
```
`field` is `from`, `to`, or `either`. Patterns match addresses, and `*` wildcards a segment — so `*@example.com` is a domain and `sales@*` is a local part across domains. An empty `match` array applies the rule to every send.
## Sending hours
An `hours` rule holds mail outside the windows you declare:
```ts TypeScript
await aiinbx.pacingRules.create({
name: "Business hours only",
kind: "hours",
match: [{ field: "from", pattern: "*@sales.example.com" }],
schedule: {
timezone: "Europe/Berlin",
windows: [
{ days: [1, 2, 3, 4, 5], start_minute: 540, end_minute: 1020 },
],
},
})
```
```python Python
client.pacing_rules.create(
name="Business hours only",
kind="hours",
match=[{"field": "from", "pattern": "*@sales.example.com"}],
schedule={
"timezone": "Europe/Berlin",
"windows": [
{"days": [1, 2, 3, 4, 5], "start_minute": 540, "end_minute": 1020}
],
},
)
```
```bash curl
curl https://api.aiinbx.com/api/v2/pacing-rules \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Business hours only",
"kind": "hours",
"match": [{ "field": "from", "pattern": "*@sales.example.com" }],
"schedule": {
"timezone": "Europe/Berlin",
"windows": [{ "days": [1,2,3,4,5], "start_minute": 540, "end_minute": 1020 }]
}
}'
```
| Prop | Type | Default | Description |
| - | - | - | - |
| `timezone` | `string` | - | IANA name, e.g. Europe/Berlin. Windows are evaluated in it, so DST is handled for you. |
| `days` | `number[]` | - | Days the window applies to. 0 is Sunday, 6 is Saturday. |
| `start_minute` | `number` | - | Minutes past local midnight. 540 is 09:00. |
| `end_minute` | `number` | - | Minutes past local midnight. 1020 is 17:00; 1440 is end of day. |
Several windows can coexist — weekday mornings plus Saturday afternoon, say. A message is free to go when it falls inside any of them.
## Rate limits
A `limit` rule caps sends per rolling window:
```ts
await aiinbx.pacingRules.create({
name: "Warm the new domain",
kind: "limit",
match: [{ field: "from", pattern: "*@new.example.com" }],
limit: { scope: "from_domain", amount: 200, per_seconds: 3600 },
})
```
`amount` per `per_seconds`, counted against a **scope** — which is what the ceiling is per:
| Scope | The ceiling is per… |
| --- | --- |
| `rule` | The rule as a whole. One shared budget for everything it matches. |
| `from_address` | Each distinct sender address. |
| `from_domain` | Each distinct sending domain. |
| `to_address` | Each distinct recipient. Good for "never more than one a day to the same person". |
| `to_domain` | Each distinct recipient domain. Good for staying under one company's gateway limits. |
```json
{ "limit": { "scope": "to_address", "amount": 1, "per_seconds": 86400 } }
```
The window rolls continuously — it isn't a bucket that resets on the hour.
## Spaces
A rule created with `space_id` reads that [space](/guides/spaces)'s mail alone. A rule without one — the workspace's — applies to every space, and stacks on top of whatever the spaces set: every matching rule gets a say, and any one of them can hold the message.
That is the shape a platform wants. Your rule caps every customer at once; a customer's own rule, created in their space, can only tighten what they send, never loosen your cap.
```ts
// Yours: nobody sends more than this, whichever space they are in.
await aiinbx.pacingRules.create({
name: "Platform ceiling",
kind: "limit",
match: [],
limit: { scope: "from_domain", amount: 5000, per_seconds: 86_400 },
})
// Theirs: business hours for one customer's mail only.
await aiinbx.pacingRules.create({
name: "Acme business hours",
kind: "hours",
space_id: "spc_...",
match: [],
schedule: { timezone: "Europe/Berlin", windows: [{ days: [1, 2, 3, 4, 5], start_minute: 540, end_minute: 1020 }] },
})
```
`match` still applies inside a space: an empty array is "everything in this space", not "everything in the workspace". `pacingRules.list` takes `space`; the queue is the workspace's, and each held message is held by whichever rule — of its space or of the workspace — said no.
## Spread
`spread` is a workspace-wide dial from 0 to 100 that adds randomness to send timing. At `0`, a rate-limited stream goes out on a metronome. Turn it up and each send is recorded a little later than it happened, so the gaps vary instead of being identical.
```ts TypeScript
await aiinbx.pacingRules.spread({ spread: 40 })
```
```python Python
client.pacing_rules.spread(spread=40)
```
```bash curl
curl -X PATCH https://api.aiinbx.com/api/v2/pacing-rules/spread \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "spread": 40 }'
```
Perfectly regular timing is one of the cheaper signals of automation. A moderate spread costs a little throughput and buys traffic that doesn't look metronomic. It applies to limit rules; hours rules have nothing to jitter.
## The queue
```ts TypeScript
const queue = await aiinbx.pacing.retrieve({ limit: 100 })
console.log(queue.total, "held as of", queue.as_of)
for (const rule of queue.rule_counts) {
console.log(rule.rule_id, `${rule.held} held of ${rule.matches} matched`)
}
for (const item of queue.data) {
console.log(item.queued_at, item.from, "→", item.to, item.subject)
}
```
```python Python
queue = client.pacing.retrieve(limit=100)
print(queue["total"], queue["as_of"])
```
```bash curl
curl "https://api.aiinbx.com/api/v2/pacing-queue?limit=100" \
-H "Authorization: Bearer $AI_INBX_API_KEY"
```
| Prop | Type | Default | Description |
| - | - | - | - |
| `total` | `number` | - | Everything currently held. |
| `sampled` | `number` | - | How many are in `data` — `limit` caps this at 2000, so it can be less than total. |
| `as_of` | `string` | - | When the snapshot was taken. |
| `spread` | `number` | - | The workspace spread in effect. |
| `rules` | `PacingRule[]` | - | The rules the snapshot was evaluated against. |
| `rule_counts` | `object[]` | - | Per rule: how many messages it matched, and how many it is holding. |
| `data` | `object[]` | - | A sample of held messages, oldest first. |
`rule_counts` is the diagnostic worth looking at first: it tells you *which* rule is doing the holding, so you tune the one that matters instead of guessing.
## Releasing by hand
When something urgent is stuck behind a queue, push it out:
```ts TypeScript
const { data } = await aiinbx.pacing.release({
email_ids: ["eml_...", "eml_..."],
count_toward_limits: false,
})
```
```python Python
released = client.pacing.release(
email_ids=["eml_..."],
count_toward_limits=False,
)
```
```bash curl
curl https://api.aiinbx.com/api/v2/pacing-queue/release \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "email_ids": ["eml_..."], "count_toward_limits": false }'
```
Up to 500 IDs per call; the response lists the ones actually released. `count_toward_limits` defaults to `true` — leave it there unless the release is genuinely exceptional, because `false` means the send doesn't consume budget and the rule under-counts.
Better than releasing repeatedly: set `pacing.skip` on the messages that should never queue in the first place.
```json
{ "pacing": { "skip": true, "count": false } }
```
Password resets, verification codes, and security alerts belong in that category.
## Managing rules
```ts TypeScript
const rules = await aiinbx.pacingRules.list().all()
await aiinbx.pacingRules.update("pace_...", { enabled: false })
await aiinbx.pacingRules.delete("pace_...")
```
```python Python
for rule in client.pacing_rules.iter():
print(rule["name"], rule["kind"], rule["enabled"])
client.pacing_rules.update("pace_...", enabled=False)
```
`enabled: false` parks a rule without losing its configuration — the right move when you're diagnosing which rule is holding mail. Creating and changing rules requires a `full` [scope](/authentication#scopes) key; reading the queue does not.
## Warming a new domain
A staged limit is the usual pattern. Start conservative and raise `amount` as reputation builds:
| Week | `amount` | `per_seconds` |
| --- | --- | --- |
| 1 | 50 | 86400 |
| 2 | 200 | 86400 |
| 3 | 1000 | 86400 |
| 4 | 5000 | 86400 |
```ts
await aiinbx.pacingRules.update(warmupRuleId, {
limit: { scope: "from_domain", amount: 200, per_seconds: 86_400 },
})
```
Watch `email.bounced` and `email.complained` between steps. Rising complaints mean hold, not advance.
---
# Going to production
Source: https://docs.aiinbx.com/guides/production
A production integration needs to recover from uncertain send results, duplicate events, and provider failures. This guide describes the application responsibilities around the [API conventions](/reference/conventions).
## Understand what success means
| Signal | Meaning | Application action |
| --- | --- | --- |
| Successful send response | An email resource was created or an existing request was replayed | Store the email and thread IDs; inspect `status`, `suppressed`, and `pacing` |
| `email.sent` | The sending provider accepted the message | Record provider acceptance; do not mark inbox delivery |
| `email.delivered` | A delivery confirmation was received | Record the reported delivery; it does not prove the recipient read the message |
| `email.bounced` | A delivery failure was reported | Inspect the recipients and whether the bounce is permanent |
| `email.complained` | A recipient complaint was reported | Update your application state and inspect suppression handling |
| HTTP timeout | Your application did not receive a complete result | Retry the same operation with the same key and payload |
Available outcomes depend on the sending provider. Check the [event reference](/webhooks/events) for provider-specific behavior. Events can arrive more than once and out of order.
## Make sends recoverable
Persist a logical operation ID, its idempotency key, and its request body before sending. Reuse them across HTTP retries, worker retries, and process restarts.
```ts
// `operation` is a persisted application record, not a new object per attempt.
const email = await aiinbx.emails.send(operation.payload, {
idempotencyKey: operation.idempotencyKey,
})
await saveSendResult(operation.id, email.id, email.thread_id)
```
The SDKs retry some failures automatically. A key still matters: neither client generates a persistent application key for you. An uncertain response is not a reason to generate a new key.
If you generate message content with a model, save the selected content before sending. Regenerating it on a retry can produce a different body and a `409 idempotency_key_reused` response. See [idempotency scope and replay behavior](/reference/conventions#idempotency).
Use bounded retries with backoff, and inspect structured [error codes](/reference/errors). Do not retry a validation error or a key/body conflict unchanged.
## Receive events durably
The webhook handler has **10 seconds** to respond. Verify the signature, store the event durably, and acknowledge it. Run model calls and other business logic in a worker.
```ts
import { verifyWebhookRequest, WebhookSignatureError } from "aiinbx/webhooks"
export async function POST(request: Request) {
let event
try {
event = await verifyWebhookRequest(
request,
process.env.AI_INBX_WEBHOOK_SECRET!
)
} catch (error) {
if (error instanceof WebhookSignatureError) {
return new Response("Invalid signature", { status: 400 })
}
throw error
}
// Application-defined: commit a durable job with a unique event ID.
// An existing ID is a no-op. Storage failures must reject this call.
await enqueueOnce(event.id, event)
return new Response(null, { status: 204 })
}
```
`enqueueOnce` is an integration point for your database or queue, not an SDK method. It must:
1. Atomically store the event with a unique constraint on `event.id` or equivalent deduplication.
2. Treat duplicate deliveries as success without creating another job.
3. Return only after durable storage succeeds. On failure, let the handler return non-2xx so AI Inbx retries.
A worker should mark a job complete only after its work succeeds. Use a recoverable claim or lease so a worker crash does not leave a job permanently in progress. If you use a database plus a separate queue, use an outbox or another recovery mechanism for the gap between committing the event and publishing the job.
Returning `204` and starting untracked background work is not durable acceptance. Once acknowledged, AI Inbx will not retry that successful delivery if your later work fails.
## Handle concurrency and ordering
Deduplicate by `event.id`, not by the signature or delivery timestamp. Signatures change across attempts; the event ID stays the same across retries and manual replays.
No webhook concurrency setting guarantees event order. Use timestamps for your event history and retrieve the resource when you need current state. Do not overwrite newer state merely because an older event arrived later.
For an automated conversation, serialize jobs per thread or use an application-level version check. Two different inbound events have different IDs and can otherwise produce overlapping replies. Before sending, check whether a person or another worker has already answered.
## Enforce customer boundaries
An API key reaches every space in its workspace within the key's operation scope. A `sending` key is not a credential restricted to one customer.
Keep keys on your servers. Resolve the authenticated customer's space in your application, validate ownership of requested senders and resources, and apply `space` filters to list operations. A resource ID supplied by a browser is not proof of ownership.
Webhook endpoints also receive events across spaces. Resolve `event.space_id` against your own customer records after verifying the signature. Treat `null` explicitly as workspace-level mail. See [Spaces](/guides/spaces).
## Monitor and recover
Record operation IDs, email IDs, thread IDs, event IDs, and `X-Request-ID` alongside your application traces. Avoid logging API keys, webhook secrets, message bodies, or signed attachment URLs by default.
Monitor these conditions:
| Condition | Where to investigate |
| --- | --- |
| Failed webhook deliveries | [Delivery attempts and replay](/webhooks#inspecting-deliveries) |
| Mail held longer than expected | [Pacing queue](/guides/pacing#the-queue) and scheduled time |
| Sending domain loses verification | [`domain.lost`](/webhooks/events/domain-lost) and domain diagnostics |
| Connected account needs authorization | [`mailbox.needs_reauth`](/webhooks/events/mailbox-needs-reauth) |
| Recipients excluded or rejected | `suppressed`, bounce events, and [suppression lists](/guides/suppressions) |
| Repeated API failures | Structured error code and request ID |
Automatic webhook retries have a bounded window. After fixing your handler, inspect failed deliveries and replay them. Disabling an endpoint does not queue events created while it is disabled.
Store the data your application needs for its own history and recovery. Attachment URLs expire; retain attachment IDs and fetch fresh URLs as needed. Confirm retention and residency requirements before relying on the service as an archive.
## Verify the integration before launch
Exercise a complete send, delivery event, inbound reply, and thread reply with addresses you control. Also check that:
- An invalid webhook signature is rejected without creating work.
- Concurrent deliveries of one event create one durable job.
- A worker can resume after crashing without generating a different send payload.
- Failed storage causes a non-2xx webhook response.
- A customer cannot read or send through another customer's resources.
- Operators can find a failed delivery and replay it after a fix.
A synthetic webhook tests your endpoint and signature handling. It does not establish that DNS, provider delivery, or inbound routing works; test those with an actual message.
---
# Receiving
Source: https://docs.aiinbx.com/guides/receiving
There are two ways inbound mail reaches your application. Both work equally well for a domain or account you run and for one belonging to a customer who connected it through your app — what differs is what the setup needs and how much it covers.
**A domain**
Point an MX record at AI Inbx and *every* address on the domain becomes an
inbox your code can read. Needs DNS access, and takes over the domain's
mail.
**A connected mailbox**
An existing Gmail or Outlook account, authorized over OAuth and synced in.
Needs no DNS, changes nothing about the account, and covers one address.
Both land in the same place: a thread, and an [`email.received`](/webhooks/events/email-received) webhook.
## Receiving on a domain
Add the `INBOUND` MX record from the domain's `records` array and verify the domain — that's the whole setup. Whose domain it is doesn't matter to the API; if it's a customer's, you show them the records and they publish them. See [Domains](/guides/domains) for the record set and how verification works.
Once the MX record resolves, mail to any address on the domain is accepted, parsed, threaded, and delivered to your [webhook endpoints](/webhooks).
:::tip[Plus addressing]
Every address on the domain is live, so you can encode routing into the local part — `reply+ord_8812@yourapp.com` — and read it back off `data.to` in the webhook. Nothing to register per address.
:::
## Receiving through a connected mailbox
When the address already has an inbox at Google or Microsoft — so pointing an MX record at AI Inbx would take mail away from it — connect the mailbox instead. AI Inbx creates a one-time authorization URL, whoever holds the account approves it, and their mail syncs in from then on:
```ts
const { url } = await aiinbx.mailboxes.connect({
provider: "google",
return_to: "https://app.example.com/settings/mailboxes",
ref: "user_8812",
})
// Send the customer to `url`.
```
`ref` is your own identifier, echoed back on the [`mailbox.connected`](/webhooks/events/mailbox-connected) event so you know which of your users just finished. [Mailboxes](/guides/mailboxes) covers the full flow — including a hosted page and a plain connect link that need no server call at all — and bringing your own OAuth app so the consent screen carries your brand.
## What arrives
A received message is delivered as an `email.received` event whose `data` carries the routing you need without a follow-up fetch:
```json
{
"id": "evt_...",
"type": "email.received",
"created_at": "2026-09-01T10:31:04Z",
"space_id": null,
"data": {
"email_id": "eml_...",
"thread_id": "thr_...",
"domain_id": "dom_...",
"mailbox_id": null,
"from": "grace@example.com",
"to": ["support@yourapp.com"],
"subject": "Re: Quick question",
"snippet": "Thursday at 10 works for me…",
"category": "human",
"verdicts": { "spam": "PASS", "spf": "PASS", "dkim": "PASS", "dmarc": "PASS" },
"attachments": []
}
}
```
Retrieve the full message — bodies, headers, attachments, delivery events — with `emails.retrieve(data.email_id)`, or fetch the whole conversation with `threads.retrieve(data.thread_id)`.
### What was written
A reply carries the whole conversation underneath it, and people often type their answers inside that quote. `stripped_text` is the part the sender actually wrote: the quoted conversation and the signature removed, and an answer typed into the quote kept under the line it answers.
```json
{
"text": "Hi Lena, answers inline.\n\nOn Mon, 1 Sep 2026 at 10:44, Lena Bergmann wrote:\n> 1. How many units per month?\n\n500, ramping to 800 in Q4.\n\n> 2. Which finish?\n\nAnodised black.\n\nBest\nTom",
"stripped_text": "Hi Lena, answers inline.\n\n> 1. How many units per month?\n500, ramping to 800 in Q4.\n\n> 2. Which finish?\nAnodised black.",
"segments": [
{ "kind": "written", "text": "Hi Lena, answers inline." },
{ "kind": "quoted", "text": "On Mon, 1 Sep 2026 at 10:44, Lena Bergmann wrote:\n1. How many units per month?" },
{ "kind": "written", "text": "500, ramping to 800 in Q4." },
{ "kind": "quoted", "text": "2. Which finish?" },
{ "kind": "written", "text": "Anodised black." },
{ "kind": "signature", "text": "Best\nTom" }
]
}
```
The cut reads the client's own markup first, then the markers around the quote, and finally the message being answered: when that message is on the thread, every line that reappears from it is quote, whatever the client marked. That last reading is how answers typed into an Outlook quote — which carries no markers at all — are found. `segments` is the whole cut in order, for a view that folds the quote or a model that wants the context too.
A message that quotes something never on file — a forwarded conversation, a reply to a mail sent before the mailbox was connected — keeps its quote in `stripped_text`: nothing underneath has been seen, so it is the content. `segments` still marks it as quoted.
Give a model `stripped_text` for what was said and the thread for what was said before it.
### Categories
Every inbound message is classified, so an autoresponder doesn't get treated as a human answer. `category` is one of:
| Category | Meaning |
| --- | --- |
| `human` | Classified as a message written by a person. |
| `out_of_office` | An away or vacation autoreply. |
| `auto_reply` | Another kind of automatic response — ticket acknowledgements, "we got your message". |
| `bounce` | A delivery failure notification that arrived as mail. |
| `verification` | A confirmation or code email. |
| `transactional` | Receipts, invoices, account notices. |
| `notification` | Alerts and system mail. |
| `marketing` | Bulk or promotional mail. |
| `spam` | Judged unsolicited. |
Gate your agent on it:
```ts
if (event.type === "email.received" && event.data.category === "human") {
await handleReply(event.data.thread_id)
}
```
This filter reduces automatic reply loops, but classification can be wrong. Also apply sender checks, conversation reply limits, and human handoff rules. See [Email agents](/integrations/agents#decide-whether-to-answer).
### Authentication verdicts
`verdicts` reports what SPF, DKIM, DMARC, and spam scanning concluded about the *sender*. Treat a `FAIL` as reason not to act on the content, particularly for anything that changes state.
## Threading
Replies are matched onto existing conversations from `In-Reply-To` and `References` first, then — when a client mangles or drops those headers — by comparing the quoted content against messages already on the thread. Subject alone is never enough to merge two conversations, so two unrelated messages that happen to share "Re: Invoice" stay apart.
The upshot for your code: trust `thread_id`. [Threads](/guides/threads) covers reading and replying.
## Routing which events go where
An endpoint subscribes to the event types it wants, and can further narrow by sender or recipient with routing rules — useful when one workspace serves several products:
```ts
await aiinbx.webhookEndpoints.create({
url: "https://app.example.com/webhooks/support",
subscriptions: ["email.received"],
routing: [{ effect: "allow", field: "to", pattern: "support@yourapp.com" }],
})
```
See [Webhooks](/webhooks#routing) for how allow and block rules combine.
## Attachments on inbound mail
Inbound attachments are stored and exposed as metadata plus a short-lived download URL. PDFs, documents, and spreadsheets are additionally prepared into Markdown or text so a model can read them without you running a parser — see [Attachments](/guides/attachments).
## Next
**[Webhooks](/webhooks)**
Endpoints, signatures, retries, replays.
**[Threads](/guides/threads)**
Reading a conversation and replying to it.
**[Domains](/guides/domains)**
DNS records, verification, diagnostics.
**[Mailboxes](/guides/mailboxes)**
Gmail and Outlook, and your own OAuth app.
---
# Scheduling
Source: https://docs.aiinbx.com/guides/scheduling
Pass `scheduled_at` on a send or a reply and the message is held until then. The API returns it immediately with `status: "scheduled"`, and it stays yours to move or cancel right up until it leaves.
```ts TypeScript
const email = await aiinbx.emails.send({
from: "Ada ",
to: "grace@example.com",
subject: "Monday reminder",
text: "The review is at 10.",
scheduled_at: "2026-09-14T09:00:00Z",
})
console.log(email.status) // "scheduled"
```
```python Python
email = client.emails.send(
{
"from_": "Ada ",
"to": ["grace@example.com"],
"subject": "Monday reminder",
"text": "The review is at 10.",
"scheduled_at": "2026-09-14T09:00:00Z",
}
)
```
```bash curl
curl https://api.aiinbx.com/api/v2/emails \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Ada ",
"to": "grace@example.com",
"subject": "Monday reminder",
"text": "The review is at 10.",
"scheduled_at": "2026-09-14T09:00:00Z"
}'
```
`scheduled_at` is an RFC 3339 timestamp **with an offset** (`2026-09-14T09:00:00Z` or `2026-09-14T11:00:00+02:00`), at most 30 days out. A naive timestamp is rejected with [`invalid_request`](/reference/errors#invalid_request) — the offset is required precisely because "9am" without one means six different instants.
:::note
Scheduling works identically on [`threads.reply`](/guides/threads#replying). A scheduled reply still resolves its recipients and headers from the thread at the moment it's composed, so a participant added in the meantime is included.
:::
## Rescheduling
`PATCH /emails/{id}` moves a scheduled message. The same 30-day bound applies from the moment of the call.
```ts TypeScript
const moved = await aiinbx.emails.reschedule("eml_...", {
scheduled_at: "2026-09-15T09:00:00Z",
})
```
```python Python
moved = client.emails.reschedule("eml_...", scheduled_at="2026-09-15T09:00:00Z")
```
```bash curl
curl -X PATCH https://api.aiinbx.com/api/v2/emails/eml_... \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "scheduled_at": "2026-09-15T09:00:00Z" }'
```
Only a message still in `scheduled` status can move. Once it has begun sending, the call fails with `409` [`not_scheduled`](/reference/errors#not_scheduled).
## Canceling
```ts TypeScript
const canceled = await aiinbx.emails.cancel("eml_...")
console.log(canceled.status) // "canceled"
```
```python Python
canceled = client.emails.cancel("eml_...")
```
```bash curl
curl -X POST https://api.aiinbx.com/api/v2/emails/eml_.../cancel \
-H "Authorization: Bearer $AI_INBX_API_KEY"
```
Cancellation is terminal — a canceled message can't be rescheduled back into flight. Send a new one instead.
Both rescheduling and cancellation need a key with the `sending` or `full` [scope](/authentication#scopes).
## Finding what's scheduled
`status: "scheduled"` on the email list is the queue of everything still pending:
```ts TypeScript
for await (const email of aiinbx.emails.list({ status: "scheduled" })) {
console.log(email.scheduled_at, email.subject, email.to)
}
```
```python Python
for email in client.emails.iter(status="scheduled"):
print(email["scheduled_at"], email["subject"], email["to"])
```
```bash curl
curl "https://api.aiinbx.com/api/v2/emails?status=scheduled&limit=100" \
-H "Authorization: Bearer $AI_INBX_API_KEY"
```
## Composed at send time, not at schedule time
A scheduled message is stored as an intent and composed at the instant it goes out. Two consequences worth designing around:
- **Suppressions are applied then, not now.** A recipient who unsubscribes between scheduling and sending is dropped, and shows up in the delivery record rather than in the response you already received.
- **Pacing rules are evaluated then, too.** A message scheduled for 3am under an [hours rule](/guides/pacing#sending-hours) that forbids 3am is held until the window opens; `scheduled_at` sets the earliest time it may go, not a guarantee of the exact instant.
If you need a message to ignore the pacing rules entirely, set `pacing.skip` on it:
```json
{
"scheduled_at": "2026-09-14T09:00:00Z",
"pacing": { "skip": true }
}
```
## Scheduling versus pacing
They solve different problems and compose fine:
| | [Scheduling](/guides/scheduling) | [Pacing](/guides/pacing) |
| --- | --- | --- |
| Set on | One message | Workspace-wide rules |
| Answers | "Not before this instant" | "Not faster than this, and not outside these hours" |
| Changed by | `reschedule` / `cancel` | Rule edits, or releasing from the queue |
Use `scheduled_at` for "send this Monday morning". Use pacing for "never more than 200 an hour from this domain".
---
# Sending
Source: https://docs.aiinbx.com/guides/sending
`POST /emails` takes a message and returns it with an ID, a thread, and a status. Everything else on this page is a detail of that one call.
```ts TypeScript
const email = await aiinbx.emails.send({
from: { name: "Ada", address: "ada@example.com" },
to: "grace@example.com",
subject: "Quick question",
text: "Does Thursday still work for the review?",
})
```
```python Python
email = client.emails.send(
{
"from_": {"name": "Ada", "address": "ada@example.com"},
"to": ["grace@example.com"],
"subject": "Quick question",
"text": "Does Thursday still work for the review?",
}
)
```
```bash curl
curl https://api.aiinbx.com/api/v2/emails \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Ada ",
"to": "grace@example.com",
"subject": "Quick question",
"text": "Does Thursday still work for the review?"
}'
```
:::note
Sending requires a key with the `sending` or `full` [scope](/authentication#scopes), and a `from` address on a [verified domain](/guides/domains) or a [connected mailbox](/guides/mailboxes).
:::
## Addresses
`from` takes either a formatted string or an object — pick whichever your code already has:
```json
{ "from": "Ada Lovelace " }
{ "from": { "name": "Ada Lovelace", "address": "ada@example.com" } }
{ "from": { "address": "ada@example.com" } }
```
A display name is 1–200 characters; surrounding quotes are stripped.
`to`, `cc`, `bcc`, and `reply_to` each take a single address or an array, so the single-recipient case stays terse:
```json
{ "to": "grace@example.com" }
{ "to": ["grace@example.com", "charles@example.com"], "cc": "ops@example.com" }
```
Each field holds at most 100 addresses, and addresses are lowercased. A send with no deliverable recipient left — every address suppressed, for instance — fails with [`no_recipients`](/reference/errors#no_recipients).
## Content
`subject` is required (up to 998 characters, the RFC line limit). At least one of `html` or `text` must be present; sending both is the right default, because it gives every client something to render:
```ts
await aiinbx.emails.send({
from: "Ada ",
to: "grace@example.com",
subject: "Your invoice",
html: "Your invoice is ready.
",
text: "Your invoice is ready: https://example.com/i/42",
})
```
Each body may be up to 2 MB.
## Attachments
Up to 20 attachments per message, each as base64 `content` with a `filename` and `content_type`:
```ts TypeScript
await aiinbx.emails.send({
from: "Ada ",
to: "grace@example.com",
subject: "The report",
text: "Attached.",
attachments: [
{
filename: "report.pdf",
content_type: "application/pdf",
content: pdfBuffer.toString("base64"),
},
],
})
```
```python Python
import base64
client.emails.send(
{
"from_": "Ada ",
"to": ["grace@example.com"],
"subject": "The report",
"text": "Attached.",
"attachments": [
{
"filename": "report.pdf",
"content_type": "application/pdf",
"content": base64.b64encode(pdf_bytes).decode(),
}
],
}
)
```
Set `cid` to reference an attachment from your HTML instead of listing it at the bottom of the message:
```json
{
"html": "
",
"attachments": [
{
"filename": "logo.png",
"content_type": "image/png",
"cid": "logo",
"content": "iVBORw0KG..."
}
]
}
```
Oversized payloads fail with [`attachments_too_large`](/reference/errors#attachments_too_large). Inbound attachments work differently — see [Attachments](/guides/attachments), which also covers the prepared-text extraction that turns a received PDF into Markdown.
## Custom headers
`headers` sets additional RFC 5322 headers — a campaign tag, a correlation ID, anything your downstream tooling reads:
```json
{
"headers": {
"X-Campaign-ID": "spring-2026",
"X-Correlation-ID": "ord_8812"
}
}
```
Names are limited to 200 characters and values to 8000.
## Idempotency
Network timeouts are the reason double-sends happen. Pass an `Idempotency-Key` and a retry with the same body returns the original email instead of sending a second one:
```ts TypeScript
await aiinbx.emails.send(payload, { idempotencyKey: "order-8812-receipt" })
```
```python Python
client.emails.send(payload, idempotency_key="order-8812-receipt")
```
```bash curl
curl https://api.aiinbx.com/api/v2/emails \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Idempotency-Key: order-8812-receipt" \
-H "Content-Type: application/json" \
-d @email.json
```
A replay returns `200` with `Idempotent-Replayed: true`, where the original returned `201`. Reusing a key with a *different* body is a mistake rather than a retry, and fails with [`idempotency_key_reused`](/reference/errors#idempotency_key_reused). Derive keys from something stable in your domain — `order-8812-receipt`, not a fresh UUID per attempt. Full semantics in [Conventions](/reference/conventions#idempotency).
## Reading the response
The send response is the email plus two fields worth checking on every call:
| Prop | Type | Default | Description |
| - | - | - | - |
| `id` | `string` | - | The email's ID (eml_…). |
| `thread_id` | `string` | - | The thread this message opened or joined. |
| `status` | `string` | - | queued, sending, sent, or scheduled at this point — delivery outcomes arrive later, by webhook. |
| `suppressed` | `string[]` | - | Recipients dropped before sending because they're on a suppression list. Not an error — read it. |
| `pacing` | `object \| null` | - | Non-null when a pacing rule is holding the message: held_by names the rule, estimated_send_at is the projection. |
```ts
const email = await aiinbx.emails.send(payload)
if (email.suppressed.length) {
console.warn("dropped:", email.suppressed)
}
if (email.pacing && email.pacing.held_by.kind !== "ready") {
console.log("held until", email.pacing?.estimated_send_at)
}
```
`status` describes the send lifecycle. Delivery, bounce, and complaint outcomes appear as [webhooks](/webhooks/events) and in the email’s `events` array when you retrieve it. These are events, not values to expect in `status`; a complaint can arrive after delivery.
### Bypassing pacing
A password reset shouldn't wait behind a marketing queue. `pacing.skip` sends immediately; `pacing.count` controls whether the send still consumes rule budget:
```json
{ "pacing": { "skip": true, "count": false } }
```
See [Pacing](/guides/pacing) for what the rules do in the first place.
## Suppression and unsubscribe
`suppression_key` names the list this send is checked against — a campaign, a product, a tenant. Omit it and the send is checked against the workspace’s `*` list and, when sending from a space, that space’s `*` list. A named key adds the matching lists at both levels.
`unsubscribe: true` marks the message as optional mail: it adds one-click unsubscribe headers, and lets a recipient opt out of *this* list without blocking your transactional mail. [Suppressions](/guides/suppressions) covers both.
```json
{ "suppression_key": "product-updates", "unsubscribe": true }
```
## Scheduling
`scheduled_at` (RFC 3339, with an offset, up to 30 days out) defers the send. The message comes back with `status: "scheduled"` and can be rescheduled or canceled until it goes out — see [Scheduling](/guides/scheduling).
```json
{ "scheduled_at": "2026-09-15T09:00:00Z" }
```
## Which space a send is in
Nothing on the request names a [space](/guides/spaces). The email is in the space of the domain or mailbox `from` is on, and its thread with it — `space_id` on the response says which, `null` for the workspace itself. A workspace that has no spaces never sees anything else there.
Lists take `space` to narrow to one space:
```ts
for await (const email of aiinbx.emails.list({ space: "spc_...", direction: "inbound" })) {
console.log(email.from.address, email.subject)
}
```
## Continuing a conversation
Pass `thread_id` to attach a new message to an existing thread. For an actual reply, use [`threads.reply`](/guides/threads#replying) instead — it infers the sender, recipients, subject, and reply headers, which is the part that's easy to get wrong by hand.
## Next
**[Threads](/guides/threads)**
Replying without rebuilding headers.
**[Scheduling](/guides/scheduling)**
Deferred sends, rescheduling, cancellation.
**[Suppressions](/guides/suppressions)**
Why a recipient came back in `suppressed`.
**[Pacing](/guides/pacing)**
Sending windows and rate ceilings.
---
# Spaces
Source: https://docs.aiinbx.com/guides/spaces
A **space** is a group inside a workspace — one per customer, typically. It owns domains and mailboxes, and with them every email and thread that went through those. Pacing rules and suppression lists can be put in a space too; then they reach that space alone. New API keys and webhook endpoints stay yours: your servers hold the credential and receive every space's events.
Spaces are optional. A workspace that is not a platform never creates one, and every space-aware resource it has reads `space_id: null` — the workspace itself. Nothing on this page applies until you create a space.
## When you need one
You are a platform if your customers each have addresses of their own and must not see each other's mail. Then:
- Each customer gets a space, and a domain or mailbox in it.
- Their mail lands in that space. Two of your customers on the same email — the same `Message-ID` — each get their own copy, and a reply is threaded inside its space and never across.
- A pacing rule and a suppression list can be per customer, while yours stay workspace-wide and apply to everyone.
- Your one key and one endpoint serve every customer. Nothing is handed to a customer — you front the API for them — and every event says which space it is about.
You do not need one if every address in the workspace is yours, or if the separation you want is between campaigns rather than between customers — [suppression keys](/guides/suppressions#keys) and [webhook routing](/webhooks#routing) already do that without a second object.
## How mail finds its space
Mail is never told its space. An email is in the space of the domain or mailbox it was sent from or received on; a reply is in its thread's space. So a space is decided once, when a domain or mailbox is put in it, and everything after that follows.
Everything created without a `space_id` belongs to the workspace itself. That includes the wildcard above: it is yours, and the subdomains under it are your customers'.
## A platform, end to end
The example is an AI email assistant sold to businesses. Each customer gets `.saas.com` to write from on day one, can bring their own domain later, and can connect a Gmail account so the assistant answers from their existing inbox. All of it with one workspace-wide key on your side.
1. **Connect the wildcard**
`*.saas.com` is one domain that covers every subdomain. It is verified once.
```ts TypeScript
const wildcard = await aiinbx.domains.create({
name: "*.saas.com",
region: "eu-central-1",
})
```
```bash curl
curl https://api.aiinbx.com/api/v2/domains \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "*.saas.com", "region": "eu-central-1" }'
```
```json
{
"id": "dom_...",
"name": "*.saas.com",
"region": "eu-central-1",
"space_id": null,
"parent_id": null,
"verified_at": null,
"records": [
{
"purpose": "DKIM",
"type": "TXT",
"name": "aibx._domainkey.saas.com",
"value": "v=DKIM1; k=rsa; p=…"
},
{
"purpose": "INBOUND",
"type": "MX",
"name": "*.saas.com",
"value": "10 inbound-smtp.eu-central-1.amazonaws.com"
},
{
"purpose": "RETURN_PATH",
"type": "MX",
"name": "bounces.saas.com",
"value": "10 feedback-smtp.eu-central-1.amazonses.com"
},
{
"purpose": "SPF",
"type": "TXT",
"name": "bounces.saas.com",
"value": "v=spf1 include:amazonses.com -all"
},
{
"purpose": "DMARC",
"type": "TXT",
"name": "_dmarc.saas.com",
"value": "v=DMARC1; p=none; rua=mailto:dmarc@saas.com"
}
]
}
```
2. **Publish the records**
Four names in the `saas.com` zone. The MX record goes at `*.saas.com`, not the apex — `saas.com` keeps whatever mail setup it already has, and only `anything.saas.com` routes to AI Inbx.
| Name | Type | Serves |
| -------------------------- | ------------ | ------------------------------------ |
| `aibx._domainkey.saas.com` | `TXT` | DKIM for every subdomain. |
| `*.saas.com` | `MX` | Inbound mail for every subdomain. |
| `bounces.saas.com` | `MX` + `TXT` | Return path and SPF. |
| `_dmarc.saas.com` | `TXT` | DMARC, inherited by every subdomain. |
Then `domains.verify` or wait for [`domain.verified`](/webhooks/events/domain-verified). The wildcard and `saas.com` are one identity on the mail provider, so only one of the two can exist in AI Inbx — connect `saas.com` on its own if you want to send from the apex too.
:::warning
If `*.saas.com` is already a wildcard `CNAME` — for customer sites on `.saas.com`, say — the MX record cannot coexist with it: a name with a `CNAME` may hold no other record. Use `*.mail.saas.com` or similar for mail then. Everything else on the list is safe beside an existing setup.
:::
3. **Create a space for the customer**
```ts TypeScript
const space = await aiinbx.spaces.create({
name: "Acme",
external_id: "cus_8812",
})
```
```bash curl
curl https://api.aiinbx.com/api/v2/spaces \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Acme", "external_id": "cus_8812" }'
```
```json
{
"id": "spc_...",
"name": "Acme",
"external_id": "cus_8812",
"created_at": "2026-09-03T09:12:41Z"
}
```
`name` is for you; nothing is derived from it. `external_id` is the customer's id in _your_ system — the key in your tenant table — and it is what ties the two sides together:
- It is unique in the workspace. Creating a second space with the same `external_id` fails with `409` [`external_id_taken`](/reference/errors#external_id_taken), so a create keyed on your customer id cannot make a duplicate on retry.
- The space can be found by it: `GET /spaces?external_id=cus_8812` returns a page with that space or nothing.
- It shows on the space in the console, and the spaces page searches it.
Store the `spc_…` id against your customer record all the same — it is what every later call names, and what every event carries. `external_id` is for the other direction: the moment you hold a customer and need their space.
4. **Give them a subdomain, in the space**
One label under a wildcard you own is created as a **subdomain** of it: no records, `parent_id` set, verified the moment the wildcard is.
```ts TypeScript
const domain = await aiinbx.domains.create({
name: "acme.saas.com",
space_id: space.id,
})
console.log(domain.parent_id, domain.verified_at) // wildcard.id, already set
```
```bash curl
curl https://api.aiinbx.com/api/v2/domains \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "acme.saas.com", "space_id": "spc_..." }'
```
```json
{
"id": "dom_...",
"name": "acme.saas.com",
"region": "eu-central-1",
"space_id": "spc_...",
"parent_id": "dom_...",
"verified_at": "2026-09-03T08:40:12Z",
"records": []
}
```
Nothing to publish, nothing to wait for. `region` is the wildcard's. See [Domains](/guides/domains#wildcards-and-subdomains) for what is and isn't a subdomain.
5. **Send as the customer**
Nothing on a send names a space. `from` decides: the send lands in the space of the address it is from, so your one key sends for every customer.
```ts TypeScript
const email = await aiinbx.emails.send({
from: { name: "Acme Assistant", address: "assistant@acme.saas.com" },
to: "grace@example.com",
subject: "Your meeting on Thursday",
text: "Confirming 10:00 at the office. Reply here if that changes.",
})
console.log(email.space_id) // "spc_..."
```
```bash curl
curl https://api.aiinbx.com/api/v2/emails \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme Assistant ",
"to": "grace@example.com",
"subject": "Your meeting on Thursday",
"text": "Confirming 10:00 at the office. Reply here if that changes."
}'
```
The email and its thread carry `space_id: "spc_..."`. A customer's request to send goes through your product, which checks that the customer owns the address and then makes this call.
6. **Read the space off the webhook**
Grace's reply arrives on the wildcard's MX, is filed under `acme.saas.com`, and is threaded inside Acme's space. Every event names it on the envelope:
```json
{
"id": "evt_...",
"type": "email.received",
"created_at": "2026-09-03T10:31:04Z",
"space_id": "spc_...",
"data": {
"email_id": "eml_...",
"thread_id": "thr_...",
"domain_id": "dom_...",
"mailbox_id": null,
"from": "grace@example.com",
"to": ["assistant@acme.saas.com"],
"subject": "Re: Your meeting on Thursday",
"snippet": "Works for me…",
"category": "human"
}
}
```
Your one endpoint receives every space's events; `space_id` is how it finds the customer, and `domain_id` / `mailbox_id` say which of the customer's identities the mail went through. A customer who wants events in a system of their own gets them from your handler. See [Webhooks](/webhooks#spaces).
```ts
if (event.type === "email.received" && event.data.category === "human") {
const customer = await customerBySpace(event.space_id)
await queueReply(customer, event.data.thread_id)
}
```
7. **They bring their own domain**
A customer who wants to write from `acme.com` goes through the ordinary [domain flow](/guides/domains) — records published at their registrar — with the domain created in their space.
```ts TypeScript
const own = await aiinbx.domains.create({
name: "acme.com",
space_id: space.id,
})
// Show `own.records` to the customer; wait for domain.verified.
```
```bash curl
curl https://api.aiinbx.com/api/v2/domains \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "acme.com", "space_id": "spc_..." }'
```
`domain.verified` arrives with `space_id: "spc_..."`, so the same handler flips the right customer's onboarding step.
8. **They connect Gmail**
For the assistant to answer from a person's existing inbox, connect the mailbox into the space. Only the server-side call takes `space_id`: the hosted page and a plain connect link are opened with no credential, so they cannot name a space — anyone could file a mailbox into another customer's. The URL this call returns carries the space inside its encrypted OAuth state, which is why a platform mints it per customer and redirects to it, rather than handing out the bare hosted link.
```ts TypeScript
const { url } = await aiinbx.mailboxes.connect({
provider: "google",
app_id: "app_...",
space_id: space.id,
return_to: "https://app.saas.com/settings/mailboxes",
ref: "user_8812",
})
// Send the customer to `url`.
```
```bash curl
curl https://api.aiinbx.com/api/v2/mailboxes/connect \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"provider": "google",
"app_id": "app_...",
"space_id": "spc_...",
"return_to": "https://app.saas.com/settings/mailboxes",
"ref": "user_8812"
}'
```
[`mailbox.connected`](/webhooks/events/mailbox-connected) fires with the same `space_id`, and from then on mail synced from that account is Acme's.
## What else a space holds
Everything below is optional — a workspace-level resource keeps working for every space. Putting one in a space narrows it.
| Resource | In a space | At the workspace (`space_id: null`) |
| ----------------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------- |
| [Pacing rule](/guides/pacing#spaces) | Reads that space's mail alone. | Applies to every space, and stacks on top — your cap holds over whatever a customer sets. |
| [Suppression list](/guides/suppressions#spaces) | Checked for that space's sends. | Checked for everyone's. |
[API keys](/authentication) and [webhook endpoints](/webhooks#spaces) belong to the workspace: a key reaches every space and an endpoint receives every space's events.
Suppressions have one asymmetry worth knowing: a complaint on a send from a space lands on the space's list; a hard bounce lands on the workspace's, because the address exists for nobody.
## Listing by space
Every list of resources that live in a space takes `space`:
```ts TypeScript
for await (const thread of aiinbx.threads.list({ space: space.id })) {
console.log(thread.subject)
}
const domains = await aiinbx.domains.list({ space: space.id }).all()
// Domains at the workspace itself, excluding domains in customer spaces:
const workspaceDomains = await aiinbx.domains.list({ space: "none" }).all()
```
```bash curl
curl "https://api.aiinbx.com/api/v2/threads?space=spc_..." \
-H "Authorization: Bearer $AI_INBX_API_KEY"
curl "https://api.aiinbx.com/api/v2/domains?space=none" \
-H "Authorization: Bearer $AI_INBX_API_KEY"
```
Omit it to include the workspace itself and every space. Pass `none` for the workspace's own resources only — the API equivalent of **Workspace only** in the console. Naming a space that is not the workspace's is `404`.
## Finding a space by your id
When you hold a customer and not the space — a job that runs per tenant, a support tool, a migration — look it up by the `external_id` you set:
```ts TypeScript
const [space] = await aiinbx.spaces.list({ external_id: "cus_8812" }).all()
```
```bash curl
curl "https://api.aiinbx.com/api/v2/spaces?external_id=cus_8812" \
-H "Authorization: Bearer $AI_INBX_API_KEY"
```
The match is exact, and the page holds one space or none. A space that never got an `external_id` reads `null` and cannot be found this way; set one with `spaces.update`.
## Updating and deleting
```ts
await aiinbx.spaces.update(space.id, { name: "Acme Corp" })
await aiinbx.spaces.update(space.id, { external_id: "cus_9001" })
await aiinbx.spaces.update(space.id, { external_id: null }) // clears it
await aiinbx.spaces.delete(space.id)
```
Only the fields given change, and an update moves nothing. Deleting returns `204` once the space is marked for deletion and its teardown is queued. It immediately stops accepting work and disappears from the API; in the background, its domains come off sending and verification, its mailboxes are disconnected, and its emails, threads, rules and suppressions are removed. There is no undo.
A wildcard in the space goes with its subdomains, including any filed in _other_ spaces — they are names under its identity, and nothing under it survives it.
## Routing rules of thumb
- **One key, on your servers.** It names a space with `space_id` on creates and `space` on lists, and sees everything. Keys are never per customer: a customer who needs the API goes through your product, which knows what they own.
- **Never `bounces.`.** That label is the wildcard's return path and is refused with [`reserved_subdomain`](/reference/errors#reserved_subdomain).
- **One endpoint, branch on `space_id`.** Endpoints are yours, like keys; a customer who needs their own delivery target gets it from your handler.
- **Workspace rules are your guardrails.** A pacing rule with no `space_id` caps every customer at once; a customer's own rule can only tighten it further.
- **`space_id: null` means "the workspace", not "unassigned".** A resource created without a space stays there; there is no move.
## Next
**[Domains](/guides/domains#wildcards-and-subdomains)**
Wildcards, subdomains, and the two errors they can raise.
**[Webhooks](/webhooks#spaces)**
`space_id` on the envelope, and one endpoint for every space.
**[Conventions](/reference/conventions#spaces)**
The `space_id: null` rule and the `space` list filter.
---
# Suppressions
Source: https://docs.aiinbx.com/guides/suppressions
A suppression is a standing instruction not to mail an address. Bounces, complaints, and unsubscribes create them automatically; you can add your own. Every send is filtered against them before it goes anywhere, and the addresses that were dropped come back in the response's `suppressed` array.
This is deliverability plumbing, not a nicety: mailbox providers judge you on whether you keep sending to addresses that have already bounced or complained.
## Two axes
Suppressions have a **key** (which list) and a **scope** (how strongly).
### Keys
Every entry belongs to a list. `*` is the workspace-wide list; any other key is one you name.
```json
{ "suppression_key": "product-updates" }
```
A send that names `product-updates` is checked against **that list and `*`**. A send that names nothing is checked against `*` alone.
That gives you the behaviour you want without any bookkeeping: someone who opts out of `product-updates` stops getting product updates and keeps getting their receipts, while a hard bounce lands on `*` and stops everything.
Use one key per thing a person could reasonably opt out of separately. A tenant identifier works well when you're sending on behalf of your own customers.
### Scopes
| Scope | Blocks |
| --- | --- |
| `all` | Every send. The default, and what bounces and complaints produce. |
| `optional` | Only sends marked `unsubscribe: true`. Marketing stops; the password reset still arrives. |
`optional` is what an unsubscribe link should create. `all` is what a complaint must create.
### Spaces
A [space](/guides/spaces) keeps lists of its own. A send from a space is checked against its space's lists **and** the workspace's — so a platform's `*` list still stops everything for every customer, while a customer's opt-outs stay theirs.
| A send from… | Is checked against |
| --- | --- |
| The workspace (`space_id: null`) | The workspace's `*` and the workspace's `key` list |
| A space | The above, plus the space's `*` and the space's `key` list |
What the automatic entries do follows from that: a **complaint** on a send from a space lands on the space's list, under the send's key; a **hard bounce** lands on the workspace's `*` list, because the address exists for nobody and no other customer should try it either.
Add to a space's list with `space_id`. Entries carry `space_id`, `null` for the workspace's own, and `suppressions.list` takes `space`.
```json
{ "addresses": ["grace@example.com"], "key": "product-updates", "scope": "optional", "space_id": "spc_..." }
```
## Marking a send as optional
The `unsubscribe` flag on a [send](/guides/sending#suppression-and-unsubscribe) or [reply](/guides/threads#replying) declares the message non-essential. It adds one-click unsubscribe headers, and it opts the message into `optional` suppressions:
```ts
await aiinbx.emails.send({
from: "Acme ",
to: "grace@example.com",
subject: "What shipped in August",
html: newsletter,
suppression_key: "product-updates",
unsubscribe: true,
})
```
:::warning
Don't set `unsubscribe: true` on transactional mail. It offers the recipient an opt-out from messages they need — receipts, password resets, security notices — and once they take it, those stop.
:::
## Adding suppressions
Up to 1000 addresses per call:
```ts TypeScript
const { data } = await aiinbx.suppressions.add({
addresses: ["grace@example.com", "charles@example.com"],
key: "product-updates",
scope: "optional",
note: "Unsubscribed from the in-app preferences page",
})
```
```python Python
created = client.suppressions.add(
addresses=["grace@example.com"],
key="product-updates",
scope="optional",
note="Unsubscribed from the in-app preferences page",
)
```
```bash curl
curl https://api.aiinbx.com/api/v2/suppressions \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"addresses": ["grace@example.com"],
"key": "product-updates",
"scope": "optional",
"note": "Unsubscribed from the in-app preferences page"
}'
```
`key` defaults to `*` and `scope` to `all` — so the terse form, `{ "addresses": [...] }`, blocks those addresses everywhere. Adding an address that's already listed updates the existing entry rather than duplicating it, and never narrows a scope that's already wider.
Managing suppressions requires a `full` [scope](/authentication#scopes) key.
## What's on an entry
| Prop | Type | Default | Description |
| - | - | - | - |
| `address` | `string` | - | The suppressed address, lowercased. |
| `key` | `string` | - | Which list. `*` is workspace-wide. |
| `space_id` | `string \| null` | - | The space whose sends the entry stops; null stops every space's. |
| `reason` | `"complaint" \| "bounce" \| "unsubscribe" \| "manual"` | - | How the entry got here. |
| `scope` | `"all" \| "optional"` | - | Everything, or optional mail only. |
| `note` | `string \| null` | - | Free text, for entries you added. |
| `blocks` | `number` | - | How many sends this entry has actually stopped. |
| `last_blocked_at` | `string \| null` | - | When it last stopped one. |
| `source_email_id` | `string \| null` | - | The message whose bounce, complaint, or unsubscribe created it. |
`blocks` and `last_blocked_at` are the honest measure of a list. An entry with hundreds of blocks means a code path is repeatedly trying to mail someone who has opted out — worth finding.
## Listing and filtering
```ts TypeScript
for await (const entry of aiinbx.suppressions.list({
key: "product-updates",
reason: "unsubscribe",
})) {
console.log(entry.address, entry.blocks)
}
```
```python Python
for entry in client.suppressions.iter(key="product-updates", reason="unsubscribe"):
print(entry["address"], entry["blocks"])
```
```bash curl
curl "https://api.aiinbx.com/api/v2/suppressions?key=product-updates&reason=unsubscribe" \
-H "Authorization: Bearer $AI_INBX_API_KEY"
```
Filter by `key`, `reason`, or `address` (a substring search across addresses).
## Removing one
```ts
await aiinbx.suppressions.remove("sup_...")
```
:::danger
Removing a `bounce` or `complaint` entry means mailing an address that already rejected you or reported you as spam. That's how sender reputation gets destroyed. Remove those only when you have specific evidence the address was fixed — a mailbox restored, a typo corrected.
:::
Removing an `unsubscribe` entry because the person asked to be re-subscribed is fine. Removing one because your open rate dropped is not.
## Reacting to the events
The three webhook events that create suppressions are worth handling in your own database too, so your UI reflects reality:
```ts
switch (event.type) {
case "email.bounced":
if (event.data.permanent) {
await markUndeliverable(event.data.recipients, event.data.reason)
}
break
case "email.complained":
await markComplained(event.data.recipients)
break
case "email.unsubscribed":
await recordOptOut(event.data.address, event.data.key, event.data.scope)
break
}
```
`email.unsubscribed` carries a `source` of `link`, `one_click`, or `reply` — the last meaning the recipient wrote back asking to stop, which was recognized as an opt-out.
## Checking before you send
You don't need to. Filtering happens server-side on every send, and the result comes back in `suppressed`:
```ts
const email = await aiinbx.emails.send(payload)
if (email.suppressed.length) {
await recordSkipped(email.suppressed)
}
```
If every recipient is suppressed, the send fails with [`no_recipients`](/reference/errors#no_recipients) rather than sending an email to nobody.
---
# Threads
Source: https://docs.aiinbx.com/guides/threads
A thread is the conversation, not a folder you put messages in. Every send opens one or joins one, every inbound reply is matched onto one, and `thread_id` is the handle your code should hold onto.
## Listing threads
```ts TypeScript
const page = await aiinbx.threads.list({ limit: 50, query: "invoice" })
for (const thread of page.data) {
console.log(thread.subject, thread.message_count, thread.last_message_at)
}
```
```python Python
page = client.threads.list(limit=50, query="invoice")
for thread in page["data"]:
print(thread["subject"], thread["message_count"], thread["last_message_at"])
```
```bash curl
curl "https://api.aiinbx.com/api/v2/threads?limit=50&query=invoice" \
-H "Authorization: Bearer $AI_INBX_API_KEY"
```
`query` matches subjects, mailboxes, and participants; `mailbox` narrows to one address. Both SDKs also expose the list as an iterator that keeps your filters across every page:
```ts TypeScript
for await (const thread of aiinbx.threads.list({
mailbox: "team@example.com",
})) {
console.log(thread.subject)
}
```
```python Python
for thread in client.threads.iter(mailbox="team@example.com"):
print(thread["subject"])
```
`query` is a literal subject/address filter for finding an operational thread, not a knowledge-base search. Persist any email data your product needs long-term in your own database.
## Retrieving one
`threads.retrieve` returns the conversation with its messages inline, each a full email: bodies, headers, attachments, delivery events, and engagements.
```ts TypeScript
const thread = await aiinbx.threads.retrieve("thr_...")
console.log(thread.participants, thread.message_count)
for (const message of thread.messages) {
console.log(message.direction, message.from.address, message.text)
}
```
```python Python
thread = client.threads.retrieve("thr_...")
for message in thread["messages"]:
print(message["direction"], message["from"]["address"], message["text"])
```
| Prop | Type | Default | Description |
| - | - | - | - |
| `id` | `string` | - | thr_… |
| `subject` | `string` | - | The conversation's subject. |
| `mailbox` | `string` | - | The address on your side of the conversation. |
| `participants` | `string[]` | - | Every address that has appeared on the thread. |
| `message_count` | `number` | - | Total messages, not just the page. |
| `last_message_at` | `string` | - | Timestamp of the newest message. |
| `messages` | `FullEmail[]` | - | Messages, oldest first. |
| `messages_next_cursor` | `string \| null` | - | Pass as message_cursor to fetch the next page of messages. |
### Long threads
Messages paginate independently of the thread list — `message_limit` (default 40, max 100) and `message_cursor`. Both SDKs wrap that in an iterator:
```ts TypeScript
for await (const message of aiinbx.threads.iterateMessages("thr_...", {
message_limit: 100,
})) {
console.log(message.subject)
}
```
```python Python
for message in client.threads.iter_messages("thr_..."):
print(message["snippet"])
```
## Replying
This is the call the API exists for. Give it the thread and the content; the sender, recipients, subject, and the `In-Reply-To` and `References` headers are derived from the conversation:
```ts TypeScript
await aiinbx.threads.reply(
"thr_...",
{ text: "Sounds good — see you Thursday." },
{ idempotencyKey: "reply-thr-123-v1" }
)
```
```python Python
client.threads.reply(
"thr_...",
text="Sounds good — see you Thursday.",
idempotency_key="reply-thr-123-v1",
)
```
```bash curl
curl https://api.aiinbx.com/api/v2/threads/thr_.../reply \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: reply-thr-123-v1" \
-d '{ "text": "Sounds good — see you Thursday." }'
```
As with a send, at least one of `html` or `text` is required, and everything else is optional.
### Overriding what's inferred
Each inferred field can be set explicitly when the default isn't what you want:
| Prop | Type | Default | Description |
| - | - | - | - |
| `reply_all?` | `boolean` | `false` | Reply to every participant rather than just the last sender. The simplest way to widen a reply. |
| `to?` | `string \| string[]` | - | Replace the inferred recipients. |
| `cc?` | `string \| string[]` | - | Carbon copies for this reply. |
| `bcc?` | `string \| string[]` | - | Blind copies for this reply. |
| `from?` | `string \| object` | - | Send as a different address on the thread — must still be a verified domain or connected mailbox. |
| `reply_to?` | `string \| string[]` | - | Where answers to this reply should go. |
| `subject?` | `string` | - | Override the inherited subject. |
| `attachments?` | `object[]` | - | Same shape as on a send — up to 20. |
| `headers?` | `object` | - | Additional RFC 5322 headers. |
| `scheduled_at?` | `string` | - | Defer the reply; up to 30 days out. |
| `suppression_key?` | `string` | - | List this reply is checked against. |
| `unsubscribe?` | `boolean` | - | Mark the reply as optional mail. |
| `pacing?` | `object` | - | { skip, count } — bypass or discount pacing rules. |
```ts
await aiinbx.threads.reply("thr_...", {
reply_all: true,
cc: "manager@example.com",
text: "Looping in Dana.",
})
```
The reply returns the same shape a send does — including `suppressed` and `pacing` — so the checks from [Sending](/guides/sending#reading-the-response) apply here too.
## Forwarding
`threads.forward` sends a whole thread to addresses that were not on it. Pass the thread and a recipient; the API renders the conversation into one email and sends it:
```ts TypeScript
await aiinbx.threads.forward(
"thr_...",
{ to: "dana@example.com", note: "See the thread below." },
{ idempotencyKey: "forward-thr-123-v1" }
)
```
```python Python
client.threads.forward(
"thr_...",
to="dana@example.com",
note="See the thread below.",
idempotency_key="forward-thr-123-v1",
)
```
```bash curl
curl https://api.aiinbx.com/api/v2/threads/thr_.../forward \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: forward-thr-123-v1" \
-d '{ "to": "dana@example.com", "note": "See the thread below." }'
```
Only `to` is required. The recipient gets one email: the note, if any, then every message in the thread, oldest first — who wrote it, when, to whom, and what they wrote. Each turn is the author's own text; quoted tails and signatures are removed, so a ten-message thread reads as ten messages rather than ten copies of the chain. The thread's attachments ride along in order, up to about 15 MB in total; anything past that is named in the transcript and stays on the thread, where the API serves it.
| Prop | Type | Default | Description |
| - | - | - | - |
| `to` | `string \| string[]` | - | Recipients of the forward. |
| `note?` | `string` | - | Text placed above the transcript. Up to 10,000 characters. |
| `from?` | `string \| object` | - | The thread's own mailbox unless set; must be a verified domain or connected mailbox. |
| `subject?` | `string` | - | `Fwd:` and the thread's subject unless set. |
| `include_attachments?` | `boolean` | `true` | Whether the thread's files ride along. They are listed in the transcript either way. |
| `cc?` | `string \| string[]` | - | Carbon copies. |
| `bcc?` | `string \| string[]` | - | Blind copies. |
| `reply_to?` | `string \| string[]` | - | Where replies to the forward should go. Defaults to `from`. |
| `attachments?` | `object[]` | - | Files of your own, on top of the thread's — same shape as on a send. |
| `headers?` | `object` | - | Additional RFC 5322 headers. |
| `scheduled_at?` | `string` | - | Defer the forward; up to 30 days out. The transcript is rendered at request time. |
| `suppression_key?` | `string` | - | List this forward is checked against. |
| `pacing?` | `object` | - | { skip, count } — bypass or discount pacing rules. |
### The forward is its own thread
A forward opens a new thread rather than joining the one it carries; the response's `thread_id` is that new thread. Its participants are the forward's recipients, not the original thread's, so the two stay apart: `threads.reply` on the original still infers the original participants, and a reply to the forward lands on the forward thread.
The two are linked. The new thread's `forward_of` names the original — on `threads.retrieve`, `threads.list`, and the [`thread.created`](/webhooks/events/thread-created) event it fires. A reply to the forward arrives as an [`email.received`](/webhooks/events/email-received) on a thread whose `forward_of` is set:
```ts
const thread = await aiinbx.threads.retrieve(event.data.thread_id)
if (thread.forward_of) {
// A reply to a forward. thread.forward_of is the thread it carried.
}
```
The forward returns the same shape a send does, and suppression, pacing, scheduling, and idempotency behave exactly as on one. An idempotency key on a forward is bound to the request — recipients, note, options — rather than to the transcript it rendered, so a retry made after the thread has grown still replays the first forward.
## Automated replies
Use a durable worker to read the conversation, decide whether to respond, and send a reply with a stable idempotency key. Thread retrieval returns one page of messages; use `iterateMessages` or `iter_messages` when you need the full available conversation.
Classification alone does not make an agent safe to run unattended. Account for duplicate and out-of-order events, concurrent replies, and human handoff. The [email agent guide](/integrations/agents) describes the workflow and the application functions it requires.
## Next
**[Receiving](/guides/receiving)**
Handle new messages as they arrive.
**[Attachments](/guides/attachments)**
Reading what came in as a PDF.
---
# Integrate AI Inbx
Source: https://docs.aiinbx.com/integrations
The [guides](/guides/sending) cover what each endpoint does. These pages cover the wiring around them — the parts that are the same for everyone and annoying to get right the first time.
There are only two integration points. Sending is an outbound HTTP call you can make from anywhere. Receiving is an inbound HTTP request you have to authenticate, and that's where the framework-specific detail lives.
**[Next.js](/integrations/nextjs)**
A route handler for webhooks, and sending from a server action.
**[Hono](/integrations/hono)**
The same handler on Workers, Bun, Deno, and Node.
**[React Email](/integrations/react-email)**
Pass a component as `react` and skip the render step.
**[Email agents](/integrations/agents)**
The reply loop, and the three ways it goes wrong.
## Anywhere else
The TypeScript SDK is dependency-free and uses only `fetch` and Web Crypto, so it runs unmodified on Node 20+, Bun, Deno, Cloudflare Workers, Vercel Edge, and Netlify. Nothing on these pages needs a Node-only API.
If your framework isn't listed, the shape is always the same:
```ts
import { verifyWebhookRequest } from "aiinbx/webhooks"
// Give the helper the whole Request — it needs the raw body, and any
// framework that has parsed the JSON for you has already destroyed it.
const event = await verifyWebhookRequest(request, process.env.AI_INBX_WEBHOOK_SECRET!)
```
**[Verifying requests](/webhooks/verifying)**
The signature scheme itself, and verifying without the SDK.
---
# Email agents
Source: https://docs.aiinbx.com/integrations/agents
An email agent receives a message, reads the conversation, decides whether to respond, and sends a reply. AI Inbx provides messages, threads, and signed events. Your application owns the decision, business context, and recovery of the workflow.
For coding-agent access to these docs, see [Documentation for agents](/reference/agents).
## Accept the event, then process it
Use the [production webhook handler](/guides/production#receive-events-durably) to verify and durably enqueue each event before returning `2xx`. Do not run a model inside the webhook request: delivery times out after 10 seconds.
The example below is a **worker outline**, not a complete runnable application. `draftOnce` and `markHandled` are application functions. The job runner must deduplicate by event ID, recover abandoned work, and serialize processing per thread.
```ts
import AIInbx from "aiinbx"
import type { WebhookEvent } from "aiinbx"
const aiinbx = new AIInbx()
async function handleEvent(event: WebhookEvent) {
if (event.type !== "email.received") return
if (event.data.category !== "human") return
// Thread retrieval returns one page. Iterate to load all available messages.
const messages = []
for await (const message of aiinbx.threads.iterateMessages(event.data.thread_id)) {
messages.push(message)
}
// Application-defined: evaluate policy, generate once, and persist the
// approved request body before sending. Return that same body on retries.
// Return null when a reply is not appropriate or needs human review.
const payload = await draftOnce(event.id, event.space_id, messages)
if (!payload) return
const email = await aiinbx.threads.reply(event.data.thread_id, payload, {
idempotencyKey: `reply-${event.id}`,
})
await markHandled(event.id, email.id)
}
```
`draftOnce` must not regenerate a different answer on every worker retry. The same key with a different body causes an idempotency conflict. Persist the decision and payload, and reuse both after a crash. For very long conversations, build a bounded context window or a persisted summary rather than loading unlimited content into a model.
## Decide whether to answer
The `category` field helps exclude out-of-office replies, automated notifications, and other messages that should not receive an automatic answer. Treat `human` as one input to your policy, not proof that a message is safe or that its sender is authorized.
Before drafting or sending:
- Resolve `event.space_id` to your customer and enforce their configured permissions.
- Check the sender, recipient, and [authentication verdicts](/guides/receiving#authentication-verdicts) against your application's policy.
- Exclude your own automated addresses and enforce a reply budget or cooldown per conversation.
- Check whether a person or another worker has already responded or taken ownership.
- Require review for actions outside the agent's authorized scope.
A valid webhook signature authenticates delivery from AI Inbx. It does not make the email body's instructions trustworthy. Keep email and attachment content separate from system instructions, and enforce tool permissions in application code.
## Read the conversation
`event.data.snippet` is a preview. Retrieve messages before answering, and account for [message pagination](/guides/threads#long-threads).
`stripped_text` removes recognized quoted history and signatures, while preserving inline answers where possible. Use it to reduce repeated context; retain access to the original text and headers when the distinction matters. See [What was written](/guides/receiving#what-was-written).
Prepared attachment text may be partial or unavailable. Check `preparation.status` and `warnings` before using it to answer a question about a document. Missing extraction is not evidence that the original file has no relevant content.
## Recover without duplicate replies
Webhook deduplication and send idempotency solve different problems:
| Mechanism | Responsibility |
| --- | --- |
| Unique event ID in durable storage | Avoid creating duplicate jobs for one event |
| Recoverable worker claim | Resume processing if a worker crashes |
| Persisted decision and payload | Keep retries consistent, including generated text |
| Stable reply idempotency key | Avoid sending another email when a send response is lost |
| Per-thread serialization or version check | Avoid conflicting replies to different events on one conversation |
Mark work complete after its effects succeed. If a worker crashes after sending but before recording completion, retry with the saved body and key. Inspect the reply response for suppressions and pacing just as you would for a new send.
## Hand off to a person
Keep drafts that require approval in your application's database or review queue. Send them only after approval is recorded. A scheduled send will eventually send without a human taking action, so it is not an approval gate.
To share a conversation with a support colleague, use `threads.forward`:
```ts
await aiinbx.threads.forward(
threadId,
{ to: "support-leads@example.com", note: savedDecision.reason },
{ idempotencyKey: `handoff-${event.id}` }
)
```
Use an authorized handoff address and persist the reason so retries keep the same body. The forward creates a separate thread linked through `forward_of`; replying to the original thread still targets the original conversation. Record the handoff in your application so automated replies stop while a person is responsible.
## Keep business context in your application
Retrieve account data, product knowledge, and customer permissions from your own systems. An email's claims about an order or account are not authorization to modify it.
A reply can use a verified domain or a connected Gmail or Outlook mailbox. When automating a person's mailbox, make the scope of the automation and the route to human review clear in your product.
Continue with [Threads](/guides/threads), [email.received](/webhooks/events/email-received), and [Going to production](/guides/production).
---
# Hono
Source: https://docs.aiinbx.com/integrations/hono
Hono passes the underlying `Request` through as `c.req.raw`, which is exactly what the verifier wants — the body is still unread.
## Receiving webhooks
```ts
import { Hono } from "hono"
import { verifyWebhookRequest, WebhookSignatureError } from "aiinbx/webhooks"
type Env = { AI_INBX_WEBHOOK_SECRET: string }
const app = new Hono<{ Bindings: Env }>()
app.post("/webhooks/aiinbx", async (c) => {
let event
try {
event = await verifyWebhookRequest(c.req.raw, c.env.AI_INBX_WEBHOOK_SECRET)
} catch (error) {
if (error instanceof WebhookSignatureError) {
return c.text("Invalid signature", 400)
}
throw error
}
// Application-defined durable insert, unique on event.id.
await enqueueOnce(event.id, event)
return c.body(null, 204)
})
export default app
```
:::warning
Use `c.req.raw`, not `await c.req.json()`. Reading the body as JSON consumes the stream and re-serializes it — the bytes that come back out are not the bytes that were signed, and every verification fails with no obvious cause.
:::
## Durable acknowledgement
`enqueueOnce` is an application function backed by a database or queue. It must atomically deduplicate on `event.id` and resolve only after the job is stored durably. If storage fails, return non-2xx so AI Inbx can retry. Run business logic in a recoverable worker.
`waitUntil` alone does not provide durable processing. If you acknowledge a delivery before storing its work, a later failure is no longer recoverable through automatic webhook retries. See [Going to production](/guides/production#receive-events-durably).
## The client on Workers
Workers have no `process.env`, so pass the key explicitly from the binding rather than relying on the constructor's default:
```ts
import AIInbx from "aiinbx"
const aiinbx = new AIInbx({ apiKey: c.env.AI_INBX_API_KEY })
```
The SDK is `fetch`-only with no Node built-ins, so nothing else changes between runtimes.
---
# Next.js
Source: https://docs.aiinbx.com/integrations/nextjs
## Install
```bash
npm install aiinbx
```
```bash .env.local
AI_INBX_API_KEY=aiinbx_...
AI_INBX_WEBHOOK_SECRET=whsec_...
```
## One client
The constructor reads `AI_INBX_API_KEY` from the environment, so a module-scoped client needs no arguments. Keep it in one file — it's cheap to construct but pointless to rebuild per request.
```ts lib/aiinbx.ts
import AIInbx from "aiinbx"
export const aiinbx = new AIInbx()
```
:::warning
Only import this from server code — a route handler, a server action, a server component. The key is a bearer token with full access to your workspace; anything that reaches a client bundle is public.
:::
## Receiving webhooks
`verifyWebhookRequest` takes the `Request` itself, reads the raw body, and checks the `aiinbx-signature` header. Hand it the request before anything else parses it.
```ts app/api/aiinbx/route.ts
import { verifyWebhookRequest, WebhookSignatureError } from "aiinbx/webhooks"
export async function POST(request: Request) {
let event
try {
event = await verifyWebhookRequest(request, process.env.AI_INBX_WEBHOOK_SECRET!)
} catch (error) {
if (error instanceof WebhookSignatureError) {
return new Response("Invalid signature", { status: 400 })
}
throw error
}
// Application-defined: atomically store a durable job by event.id.
await enqueueOnce(event.id, event)
return new Response(null, { status: 204 })
}
```
`enqueueOnce` must persist the event before resolving and treat duplicates as successful no-ops. Storage failures must produce a non-2xx response. See [durable event processing](/guides/production#receive-events-durably).
Implementation notes:
- **`route.ts`, not `pages/api`.** The App Router hands you a real `Request` with an unread body. The Pages Router parses the body before your handler runs, which changes the bytes and breaks the signature — you'd have to disable `bodyParser` and reassemble the stream.
- **A 400 on a bad signature, not a 500.** A failed check means the request could not be authenticated. A mismatched secret or clock skew can also reject a legitimate delivery. Retrying it would be pointless, and the delivery log should say *rejected*, not *your server crashed*.
- **204 quickly.** The response is an acknowledgement. Deliveries [retry](/webhooks#retries) on a non-2xx, so slow work belongs on a queue, not in the handler.
`verifyWebhookRequest` uses Web Crypto, so this route runs on the Edge runtime unchanged.
## Sending
From a server action, resolve the authenticated user on the server. `requireCurrentUser` below is an application-defined authentication function; do not accept an arbitrary recipient and user ID from the browser.
```ts app/actions.ts
"use server"
import { aiinbx } from "@/lib/aiinbx"
export async function sendWelcome() {
// Application-defined: authenticate the caller and load their verified address.
const { id: userId, email: address } = await requireCurrentUser()
await aiinbx.emails.send(
{
from: { name: "Acme", address: "hello@acme.com" },
to: address,
subject: "Welcome to Acme",
text: "Your account is ready.",
},
{ idempotencyKey: `welcome-${userId}` }
)
}
```
The idempotency key is doing real work here: server actions can be re-invoked by a retry or a double submit, and a key derived from the user ID means the second attempt returns the first email instead of sending another one. Derive it from something stable — not a fresh `crypto.randomUUID()` per call, which defeats the purpose.
**[React Email](/integrations/react-email)**
Pass a component instead of an HTML string.
**[Sending](/guides/sending)**
Attachments, headers, scheduling, and the response fields.
---
# React Email
Source: https://docs.aiinbx.com/integrations/react-email
`emails.send` takes a `react` field. Pass a component and the SDK renders it to HTML before the request goes out, so there's no render step in your code.
## Install
`@react-email/render` is an optional peer dependency — the SDK imports it lazily and only when you actually use `react`. Nothing to install if you don't.
```bash
npm install aiinbx @react-email/components
```
## Send a component
```tsx
import { aiinbx } from "@/lib/aiinbx"
import { WelcomeEmail } from "@/emails/welcome"
await aiinbx.emails.send({
from: { name: "Acme", address: "hello@acme.com" },
to: "ada@example.com",
subject: "Welcome to Acme",
react: ,
text: "Your account is ready. Visit https://acme.com to get started.",
})
```
`react` replaces `html` — pass one or the other, not both.
:::tip
Keep passing `text`. React Email renders HTML only, and a message with no text part looks worse in plain-text clients and scores worse with spam filters. It's two lines, and it's the version a screen reader is most likely to read.
:::
## Replies take HTML
`threads.reply` accepts `html` and `text`, but not `react`. Render it yourself when you need a component on a reply:
```tsx
import { render } from "@react-email/render"
await aiinbx.threads.reply(threadId, {
html: await render(),
text: answer,
})
```
## Failure mode
If `@react-email/render` isn't installed, the send throws before any request is made:
```
Failed to render React email. Install `@react-email/render` or `@react-email/components`.
```
That's a missing dependency, not an API error — it will never show up in your delivery logs, because nothing was sent.
## Inline images
React Email won't upload your images. A `
` in the component needs the matching [attachment](/guides/sending#attachments) on the same send:
```tsx
await aiinbx.emails.send({
from: "Acme ",
to: "ada@example.com",
subject: "Your receipt",
react: ,
text: receiptText,
attachments: [
{
filename: "logo.png",
content_type: "image/png",
cid: "logo",
content: logoBase64,
},
],
})
```
Absolute `https://` URLs work too and keep the message smaller — at the cost of not rendering until the client loads remote images, which many don't by default.
---
# Quickstart
Source: https://docs.aiinbx.com/quickstart
This guide sends an email from your domain and receives a reply. You need an AI Inbx workspace, access to your domain's DNS, and a recipient address you control. DNS verification can take time; there is no fixed completion time for this step.
To use an existing Gmail or Outlook account without changing DNS, start with [Mailboxes](/guides/mailboxes).
## 1. Create an API key
Open **API keys** in the [console](https://aiinbx.com/app) and create a `full` key for this setup. Configuring domains and webhooks requires `full`; a production sending worker can use a separate `sending` key.
Store the key in your server environment. It is shown only once. Do not commit it or expose it in browser code.
```bash
export AI_INBX_API_KEY="YOUR_API_KEY"
```
These examples read from the process environment. If you use a `.env` file, configure your runtime to load it before starting the application.
## 2. Install and initialize a client
Choose TypeScript, Python, or curl. The following steps reuse the initialized client.
```bash TypeScript
npm install aiinbx
```
```bash Python
pip install aiinbx
```
```ts TypeScript
import AIInbx from "aiinbx"
const aiinbx = new AIInbx()
```
```python Python
from aiinbx import AIInbx
client = AIInbx()
# Reuse this client for the steps below; call client.close() when finished.
```
## 3. Configure your sending domain
Replace `mail.example.com` with a subdomain you control. A dedicated subdomain lets you configure inbound mail without changing the routing of your existing business inboxes.
```ts TypeScript
const domain = await aiinbx.domains.create({ name: "mail.example.com" })
console.log(domain.id, domain.records)
```
```python Python
domain = client.domains.create(name="mail.example.com")
print(domain["id"], domain["records"])
```
```bash curl
curl https://api.aiinbx.com/api/v2/domains \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"mail.example.com"}'
```
Publish the DNS records returned by this call, using their exact names, types, and values. DKIM verification enables sending. Publish the `INBOUND` MX record too if you want replies to arrive through AI Inbx. Review existing DNS records before changing them; an inbound MX change affects where that domain receives mail.
Then request verification:
```ts TypeScript
const verified = await aiinbx.domains.verify(domain.id)
console.log(verified.verified_at)
```
```python Python
verified = client.domains.verify(domain["id"])
print(verified["verified_at"])
```
```bash curl
curl -X POST https://api.aiinbx.com/api/v2/domains/YOUR_DOMAIN_ID/verify \
-H "Authorization: Bearer $AI_INBX_API_KEY"
```
Continue when `verified_at` is non-null. If it remains null, use [domain diagnostics](/guides/domains#diagnostics). Sending verification alone does not confirm that your inbound MX is configured.
## 4. Send an email
Replace the sender with an address on your verified domain and the recipient with an inbox you control. Use one idempotency key for this test; use a new key when you intentionally send a different message.
```ts TypeScript
const email = await aiinbx.emails.send(
{
from: "hello@mail.example.com",
to: "you@example.net",
subject: "Your first AI Inbx email",
text: "Reply to this message to test receiving.",
},
{ idempotencyKey: "quickstart-message-1" }
)
console.log(email.id, email.thread_id, email.status)
```
```python Python
email = client.emails.send(
{
"from_": "hello@mail.example.com",
"to": ["you@example.net"],
"subject": "Your first AI Inbx email",
"text": "Reply to this message to test receiving.",
},
idempotency_key="quickstart-message-1",
)
print(email["id"], email["thread_id"], email["status"])
```
```bash curl
curl https://api.aiinbx.com/api/v2/emails \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: quickstart-message-1" \
-d '{
"from":"hello@mail.example.com",
"to":"you@example.net",
"subject":"Your first AI Inbx email",
"text":"Reply to this message to test receiving."
}'
```
The response contains an email `id`, a `thread_id`, and a send `status`. Save both IDs. A successful API response does not guarantee inbox delivery. Inspect `suppressed` for excluded recipients and `pacing` for a hold; see [Reading the response](/guides/sending#reading-the-response).
## 5. Receive a reply
Create a public HTTPS webhook endpoint in **Webhooks** in the console. Subscribe to `email.received`, `email.delivered`, and `email.bounced`, and store the signing secret as `AI_INBX_WEBHOOK_SECRET` in your server environment. For local development, expose your handler through a tunnel.
This minimal handler uses the Web `Request` and `Response` APIs. Mount it at the URL you registered; see [Next.js](/integrations/nextjs), [Hono](/integrations/hono), or [Python verification](/webhooks/verifying#python) for framework integration.
```ts
import { verifyWebhookRequest, WebhookSignatureError } from "aiinbx/webhooks"
export async function POST(request: Request) {
let event
try {
event = await verifyWebhookRequest(
request,
process.env.AI_INBX_WEBHOOK_SECRET!
)
} catch (error) {
if (error instanceof WebhookSignatureError) {
return new Response("Invalid signature", { status: 400 })
}
throw error
}
// Development only: confirm that a verified event reaches your handler.
console.log(event.id, event.type)
if (event.type === "email.received") {
console.log(event.data.email_id, event.data.thread_id)
}
return new Response(null, { status: 204 })
}
```
Reply from the recipient inbox. Your handler should receive `email.received` with the conversation's `thread_id`. If it does not, check the inbound MX record, webhook subscriptions, and delivery attempts in the console.
This handler only logs events. Before using events to perform work in production, [store them durably before acknowledging delivery](/guides/production#receive-events-durably).
## 6. Continue the conversation
Use the `thread_id` from the received event. Replace the placeholders below with the actual thread and event IDs. The API infers the reply's addressing and headers.
```ts TypeScript
await aiinbx.threads.reply(
"YOUR_THREAD_ID",
{ text: "Your reply arrived. The integration is working." },
{ idempotencyKey: "reply-YOUR_EVENT_ID" }
)
```
```python Python
client.threads.reply(
"YOUR_THREAD_ID",
text="Your reply arrived. The integration is working.",
idempotency_key="reply-YOUR_EVENT_ID",
)
client.close()
```
```bash curl
curl https://api.aiinbx.com/api/v2/threads/YOUR_THREAD_ID/reply \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: reply-YOUR_EVENT_ID" \
-d '{"text":"Your reply arrived. The integration is working."}'
```
You now have a send-and-reply flow. Use the [production guide](/guides/production) to add durable event processing, retries, tenant authorization, and operational monitoring. The [API reference](/api) lists the full request and response schemas.
---
# Documentation for agents
Source: https://docs.aiinbx.com/reference/agents
Use these resources when implementing AI Inbx with a coding agent. The same documentation is available as HTML for readers and Markdown for automated retrieval.
## Sources
| Resource | Purpose |
| --- | --- |
| [llms.txt](https://docs.aiinbx.com/llms.txt) | Index of documentation and generated API reference pages |
| [llms-full.txt](https://docs.aiinbx.com/llms-full.txt) | Combined documentation for tools that need one document |
| [OpenAPI document](https://api.aiinbx.com/api/v2/openapi.json) | API v2 paths, methods, security requirements, request schemas, and response schemas |
| [Quickstart Markdown](/quickstart.md) | Setup and a first send-and-reply flow |
| [Production guide Markdown](/guides/production.md) | Reliability and application responsibilities |
For an individual guide, append `.md` to its documentation path, for example `/guides/threads.md`. Prefer the index and relevant pages over loading the entire documentation into every prompt.
## Read in this order
1. [Authentication](/authentication) and [Conventions](/reference/conventions) for base URL, scopes, pagination, and idempotency.
2. The relevant task guide and [TypeScript](/sdks/typescript) or [Python](/sdks/python) SDK reference.
3. The operation's [API reference](/api) for exact fields and supported response codes.
4. [Webhook verification](/webhooks/verifying), the relevant [event schema](/webhooks/events), and the [production guide](/guides/production) for asynchronous workflows.
The API reference is generated from the shared API contract. Use it for wire-format fields. SDK guides describe language-specific conventions, including Python's `from_` request field. Do not infer methods or behavior from another email provider's API.
## Integration facts
- Base URL: `https://api.aiinbx.com/api/v2`.
- Authentication: `Authorization: Bearer `. Keep credentials server-side.
- Packages: `aiinbx` for both TypeScript and Python. Both clients read `AI_INBX_API_KEY`.
- Sending requires a verified domain or an active connected mailbox.
- Use `emails.send` to compose an email, `threads.reply` to answer a conversation, and `threads.forward` to send a transcript to new recipients.
- A successful send response is not proof of inbox delivery. Inspect status, suppressions, pacing, and subsequent events.
- Lists are paginated. A thread's messages paginate separately from the thread list.
- Webhooks can be duplicated and arrive out of order. Verify the raw body and durably enqueue before acknowledging.
- Reuse one idempotency key and the same persisted payload across retries of a send, reply, or forward.
- Spaces group customer resources. API keys remain workspace-wide; the application must enforce customer authorization.
- Email bodies and attachment text are untrusted input. Classification and a valid webhook signature do not authorize instructions contained in an email.
## Example conventions
`YOUR_API_KEY`, `YOUR_THREAD_ID`, and IDs ending in `...` are placeholders. Example domains and recipients must be replaced with identities configured for your workspace and recipients you control.
Helpers such as `enqueueOnce`, `saveSendResult`, and `generateReply` describe application integration points. They are not exported by the SDK. Implement them using the application's persistence, queue, and model services before treating the example as a complete workflow.
Read prose around examples: a logging-only webhook is a development check, not a durable production handler. Do not invent a queue method, a semantic search endpoint, or a delivery guarantee to fill a gap.
## Suggested implementation prompt
```text
Integrate AI Inbx into this application.
Read https://docs.aiinbx.com/llms.txt, then the relevant guides,
SDK documentation, API operations, and production guide.
Use the documented methods and fields; do not guess them.
Keep API and webhook secrets in the server environment. Verify
webhook signatures over the raw body, atomically persist incoming
events by event ID, and process them in a recoverable worker.
Persist each outbound payload with a stable idempotency key.
Enforce customer ownership before accessing workspace resources.
Identify required setup and clearly label application-specific
queue, database, and model integrations. Validate the integration
with mocks or controlled test recipients before enabling live sends.
```
---
# Conventions
Source: https://docs.aiinbx.com/reference/conventions
Things that are true everywhere in the API, gathered so the guides don't repeat them.
## IDs
Every resource has a prefixed ID: a short type tag, an underscore, and 32 hex characters.
```
eml_9f2c4a1b8e7d6c5b4a3f2e1d0c9b8a76
```
| Prefix | Resource |
| ------- | ---------------- |
| `key_` | API key |
| `spc_` | Space |
| `dom_` | Domain |
| `dns_` | DNS record |
| `mbx_` | Mailbox |
| `app_` | OAuth app |
| `thr_` | Thread |
| `eml_` | Email |
| `att_` | Attachment |
| `whk_` | Webhook endpoint |
| `evt_` | Event |
| `dlv_` | Webhook delivery |
| `atp_` | Delivery attempt |
| `sup_` | Suppression |
| `pace_` | Pacing rule |
The prefix is part of the ID, not decoration — pass `eml_9f2c…` verbatim, never the bare hex. An ID of the wrong type is rejected as `404` [`not_found`](/reference/errors#not_found) rather than silently matching another resource.
Treat IDs as opaque strings. They're stable and safe to store.
## Pagination
Every list endpoint is cursor-paginated and answers the same shape:
```json
{
"data": [],
"next_cursor": "eml_..."
}
```
| Parameter | Default | Range |
| --------- | ------- | ---------------------------------------------- |
| `limit` | 40 | 1–100 |
| `cursor` | — | An opaque cursor from a previous `next_cursor` |
`next_cursor` is `null` on the last page. Manually:
```ts
let cursor: string | undefined
do {
const page = await aiinbx.emails.list({ limit: 100, cursor })
await process(page.data)
cursor = page.next_cursor ?? undefined
} while (cursor)
```
Both SDKs wrap that up. A list call is awaitable for one page, iterable for all of them, and keeps your filters on every request:
```ts TypeScript
// One page.
const page = await aiinbx.threads.list({ limit: 50 })
// Every result, transparently paginated.
for await (const thread of aiinbx.threads.list({
mailbox: "team@example.com",
})) {
console.log(thread.subject)
}
// A small collection, in full.
const domains = await aiinbx.domains.list().all()
// Page by page, when you want to checkpoint.
for await (const page of aiinbx.emails.list().iterPages()) {
await saveCheckpoint(page.next_cursor)
}
```
```python Python
# One page.
page = client.threads.list(limit=50)
# Every result, transparently paginated.
for thread in client.threads.iter(mailbox="team@example.com"):
print(thread["subject"])
# Async works the same way.
async for thread in async_client.threads.iter():
print(thread["subject"])
```
A cursor from one endpoint is meaningless on another, and a malformed one is rejected with [`invalid_cursor`](/reference/errors#invalid_cursor) rather than silently starting from the top.
:::note
A thread's messages paginate separately from the thread list, with `message_limit` and `message_cursor`.
:::
## Spaces
Every resource that can live in a [space](/guides/spaces) carries `space_id`, and it is never omitted: a `spc_…` id, or `null`.
`null` is the workspace itself, not "unassigned". A resource created without a space belongs to the workspace and stays there — there is no move. A workspace that never creates a space reads `null` everywhere and can ignore the field.
Mail inherits rather than declares: an email's `space_id` is that of the domain or mailbox it went through, and a thread's is its messages'. Nothing on a send names one.
Lists of resources that live in a space take `space`:
| Parameter | Default | Range |
| --------- | ------- | ---------------------------------------------------------------- |
| `space` | — | A `spc_…` id, or `none` for only resources with `space_id: null` |
Omitted, the list includes the workspace itself and every space. Pass `space=none` for the workspace's own resources only. Naming a space that is not the workspace's is `404`.
## Timestamps
Every timestamp the API returns is RFC 3339 in UTC:
```
2026-09-01T10:31:04Z
```
Timestamps you send — `scheduled_at` — must carry an offset. `2026-09-14T09:00:00Z` and `2026-09-14T11:00:00+02:00` are both fine; `2026-09-14T09:00:00` is rejected, because a wall-clock time without a zone isn't an instant.
Durations are always seconds, named as such: `per_seconds`, `grace_seconds`.
## Request IDs
Every response carries `X-Request-ID`, and every error body repeats it as `request_id`. Quote it when you report a problem — it's how a specific request gets found.
Send your own and it's echoed back instead of a generated one, which is what you want when you already have a trace ID:
```bash
curl https://api.aiinbx.com/api/v2/emails \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "X-Request-ID: trace-8f2c4a1b"
```
Up to 128 characters of `A-Za-z0-9._:-`; anything else is ignored in favor of a generated ID.
Both SDKs surface it without unwrapping the response:
```ts TypeScript
const { data, response, requestId } = await aiinbx.emails
.retrieve("eml_...")
.withResponse()
```
```python Python
response = client.with_raw_response.emails.retrieve("eml_...")
print(response.request_id)
# Or, after any call:
print(client.last_request_id)
```
## Idempotency
`POST /emails`, `POST /threads/{id}/reply`, and `POST /threads/{id}/forward` accept an `Idempotency-Key` header. It makes a retry safe: the same key with the same body returns the original result instead of sending twice.
```ts TypeScript
await aiinbx.emails.send(payload, { idempotencyKey: "order-8812-receipt" })
```
```python Python
client.emails.send(payload, idempotency_key="order-8812-receipt")
```
```bash curl
curl https://api.aiinbx.com/api/v2/emails \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Idempotency-Key: order-8812-receipt" \
-H "Content-Type: application/json" \
-d @email.json
```
| Case | Result |
| --------------------------- | -------------------------------------------------------------------------- |
| First request with this key | `201`, an email is created; inspect its status |
| Same key, same body | `200` with `Idempotent-Replayed: true` and the existing email |
| Same key, different body | `409` [`idempotency_key_reused`](/reference/errors#idempotency_key_reused) |
Keys are 1–255 characters and scoped to the workspace and the resolved space, including `null` for workspace-level mail. Use a different key for each logical send, reply, or forward within that scope.
A replay returns the existing email without sending again. It does not repeat the original suppression or pacing evaluation: the replay’s `suppressed` is empty and `pacing` is null. Persist the first response if you need those original decisions. The key is stored with the email; do not rely on it as a permanent deduplication record after that email is deleted.
**Reuse one key for every attempt of the same operation.** Derive it from a stable application identifier, such as `order-8812-receipt`, or generate a UUID once and persist it. Generating a new key on every retry creates separate operations. When you're reacting to a webhook, the event ID is already the stable thing:
```ts
await aiinbx.threads.reply(
threadId,
{ text },
{ idempotencyKey: `reply-${event.id}` }
)
```
## Requests and responses
- **Request bodies are JSON.** For operations that accept a JSON body, send `Content-Type: application/json`; another content type returns `415` [`unsupported_media_type`](/reference/errors#unsupported_media_type). Bodyless operations, such as canceling an email, do not require a JSON body.
- **`PATCH` is a partial update.** Send only the fields you're changing; at least one is required.
- **Empty successes return `204`** with no body — deletions and revocations.
- **Redirects are real.** Attachment downloads answer `307` with a `Location` header; follow it (`curl -L`). Both SDKs do this for you.
- **Nothing is cached.** Every response carries `Cache-Control: private, no-store`.
- **Unknown fields are rejected**, not ignored. A typo in a request body fails validation with an `issues` array naming the field — much better than a message that silently didn't do what you meant.
## Errors
Every failure is `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)) with a machine-readable `code`. Full list and handling patterns in [Errors](/reference/errors).
```json
{
"type": "https://docs.aiinbx.com/problems/invalid_request",
"title": "Unprocessable Content",
"status": 422,
"detail": "Request body is invalid",
"code": "invalid_request",
"request_id": "...",
"issues": [{ "path": "to.0", "message": "Invalid email address" }]
}
```
## Limits
Worth knowing before you hit them:
| | Limit |
| ---------------------------------------------------- | --------------------- |
| Recipients per field (`to`, `cc`, `bcc`, `reply_to`) | 100 |
| Attachments per message | 20 |
| `html` or `text` body | 2 MB each |
| Subject | 998 characters |
| Header name / value | 200 / 8000 characters |
| `scheduled_at` horizon | 30 days |
| Page size | 100 |
| Addresses per suppression call | 1000 |
| Email IDs per pacing release | 500 |
| Delivery IDs per webhook retry | 100 |
| Webhook routing rules per endpoint | 100 |
| Webhook handler response time | 10 seconds |
## Request budgets
Every workspace has a request budget, and every request is admitted against it before it runs. A budget is counted in **cost units** rather than requests, because a search over a workspace's mail costs a few hundred times what a read by id does:
| Class | Units | Operations |
| ----------- | ----- | ------------------------------------------------------------------------------------------------------------------------- |
| `read` | 1 | Reads by id, lists without a search term, small writes |
| `send` | 2 | Sending, replying, forwarding |
| `query` | 5 | Lists with `query`, suppression batches, pacing queue reads and releases, delivery retries |
| `provision` | 10 | Creating, verifying and diagnosing domains, mailbox syncs, webhook tests — anything that talks to DNS, SES or your server |
The default budget refills at 50 units per second and holds 10 seconds of that — 500 units — for a burst. At most 32 units may be in flight at once. A request that names a space (`?space=`, `/spaces/{id}`) is held to half the workspace's budget besides, so one busy customer leaves the rest of the platform its share. Larger budgets are agreed per workspace; ask.
Every answer to an admitted request says where the budget stands — the ones that worked and the ones that failed alike, so an error is as good to pace from as a `200`:
```http
RateLimit-Limit: 500
RateLimit-Remaining: 497
RateLimit-Reset: 1
```
`RateLimit-Remaining` is in units, and `RateLimit-Reset` is the seconds until the budget is full again. Past the budget the answer is `429` with [`too_many_requests`](/reference/errors#too_many_requests) and `Retry-After` in seconds; both SDKs wait that long and retry on their own. A `429` with [`rate_limited`](/reference/errors#rate_limited) is different — an upstream mail provider throttling — and carries `Retry-After` the same way.
The platform also has a ceiling on requests in flight across every workspace. A request refused under it is a `429` too, with a `Retry-After` of a second: the budget was yours to spend, the capacity was not there to spend it on.
---
# Errors
Source: https://docs.aiinbx.com/reference/errors
Every failure answers with `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)):
```json
{
"type": "https://docs.aiinbx.com/problems/invalid_request",
"title": "Unprocessable Content",
"status": 422,
"detail": "Request body is invalid",
"code": "invalid_request",
"request_id": "8f2c4a1b-...",
"issues": [
{ "path": "to.0", "message": "Invalid email address" },
{
"path": "subject",
"message": "String must contain at most 998 character(s)"
}
]
}
```
| Prop | Type | Default | Description |
| - | - | - | - |
| `code` | `string` | - | The stable, machine-readable identifier. Branch on this, never on `detail`. |
| `status` | `number` | - | The HTTP status, repeated in the body. |
| `title` | `string` | - | The standard reason phrase for the status. |
| `detail` | `string` | - | A human-readable explanation. Wording may change — don't parse it. |
| `request_id` | `string` | - | Also on the X-Request-ID header. Quote it when reporting a problem. |
| `type` | `string` | - | A URI for the code — it resolves to this page. |
| `issues?` | `object[]` | - | Field-level validation failures: a `path` and a `message` each. |
## Handling them
```ts TypeScript
import { APIError, NotFoundError, RateLimitError } from "aiinbx"
try {
await aiinbx.emails.send(payload)
} catch (error) {
if (error instanceof NotFoundError) return null
if (error instanceof RateLimitError) return scheduleRetry()
if (error instanceof APIError) {
console.error(error.status, error.code, error.requestId)
if (error.code === "invalid_request") {
console.error(error.body.issues)
}
}
throw error
}
```
```python Python
from aiinbx import APIStatusError
try:
client.emails.send(payload)
except APIStatusError as error:
print(error.status_code, error.request_id)
print(error.body["code"], error.body.get("issues"))
```
## Retry or not
| Status | Meaning | Retry? |
| ------ | ----------------------------------- | ------------------------ |
| `400` | Malformed request | No — fix the request |
| `401` | Missing or invalid key | No |
| `403` | Key lacks the scope | No |
| `404` | No such resource | No |
| `409` | Conflicts with current state | No — read `code` first |
| `413` | Payload too large | No |
| `415` | Body isn't JSON | No |
| `422` | Failed validation | No — read `issues` |
| `429` | Budget spent or provider throttling | Yes, after `Retry-After` |
| `500` | Something broke on our side | Yes, with backoff |
| `502` | Upstream mail provider failed | Yes, with backoff |
| `503` | Temporarily unavailable | Yes, with backoff |
Both SDKs already retry network failures and `408`, `409`, `429`, and `5xx` twice with bounded exponential backoff, honoring `Retry-After`. Tune with `maxRetries` / `max_retries`.
:::warning
When you retry a send yourself, reuse the same [`Idempotency-Key`](/reference/conventions#idempotency). Without one, a retry after a timeout can send the message a second time — the request may well have succeeded before the connection dropped.
:::
## Codes
Grouped by what actually went wrong.
### Authentication and authorization
#### unauthorized
`401` — no `Authorization` header, a malformed one, or a deleted key. The response carries `WWW-Authenticate: Bearer`. Check that the key is being read from the environment and hasn't been deleted. See [Authentication](/authentication).
#### forbidden
`403` — the key is valid but its [scope](/authentication#scopes) doesn't cover this operation. A `read` key can't send; a `sending` key can't change configuration. Retrying won't help; use a key with the right scope.
#### shared_apps_disabled
`403` — the workspace requires its own OAuth app, and a mailbox connection was attempted without an `app_id`. Create an [OAuth app](/guides/mailboxes#your-own-oauth-app) and pass its ID.
### Request format
#### unsupported_media_type
`415` — an operation that accepts a JSON body was called without `Content-Type: application/json`. Bodyless operations do not require this header.
#### invalid_json
`400` — the `Content-Type` said JSON but the body didn't parse. Usually truncation or a stray trailing comma.
#### invalid_request
`422` — the body parsed but failed validation. `issues` names each offending field with a `path` and a `message`. Also `400` from a few operations that validate outside the body schema.
#### invalid_query
`400` — a query parameter was rejected: an out-of-range `limit`, an unknown `status`, a malformed address filter. `issues` names the parameter.
#### invalid_cursor
`400` — the `cursor` isn't a cursor this endpoint issued. Cursors aren't portable between endpoints; use the `next_cursor` from the same list. See [Pagination](/reference/conventions#pagination).
#### invalid_thread_id
`400` — the `thread_id` isn't a well-formed thread ID (`thr_` plus 32 hex characters).
#### invalid_idempotency_key
`400` — the `Idempotency-Key` header is empty or longer than 255 characters.
#### method_not_allowed
`405` — the path exists but not for this HTTP method.
#### no_body
`400` — a request that requires a body arrived without one.
### Content
#### no_recipients
`400` — nothing deliverable is left. Usually every recipient was [suppressed](/guides/suppressions); the send response's `suppressed` array on a partial send tells the same story less severely.
#### sender_unknown
`400` — the `from` address is on no verified [domain](/guides/domains) and no connected [mailbox](/guides/mailboxes) of the workspace. Check the domain is verified and the mailbox is `active`.
#### too_many_recipients
`400` — more than 100 addresses in `to`, `cc`, `bcc`, or `reply_to`. Split the send.
#### attachments_too_large
`413` — the attachments exceed the per-message limit. Up to 20 attachments; large files belong behind a link.
### State conflicts
#### not_found
`404` — no such resource in this workspace. Also what you get for an ID of the wrong type, since IDs are type-tagged, and for a `space_id` or `space` that names no space of the workspace.
#### thread_not_found
`404` — the thread doesn't exist or isn't yours. Check the `thread_id` came from this workspace.
#### thread_empty
`409` — a forward was asked of a thread with nothing sent or received on it yet — only scheduled or canceled messages. There is nothing to forward until a message has left or arrived.
#### not_prepared
`404` — the attachment has no prepared text. Either its type is unsupported, extraction failed, or preparation hasn't finished. The original is still downloadable — see [Attachments](/guides/attachments#prepared-text).
#### not_scheduled
`409` — reschedule or cancel was called on a message that isn't `scheduled` any more. It has already begun sending. See [Scheduling](/guides/scheduling).
#### idempotency_key_reused
`409` — this `Idempotency-Key` was used with a different body. Either the key is being reused across genuinely different sends, or the body isn't as deterministic as you think (a timestamp in the subject will do it). See [Idempotency](/reference/conventions#idempotency).
#### domain_unverified
`409` — sending from a domain whose DKIM identity is not verified. Run `domains.verify`, then `domains.diagnostics` if it still won't pass. See [Domains](/guides/domains).
#### domain_taken
`409` — the workspace already has this domain. `saas.com` and `*.saas.com` are one identity, so either one takes the other. Another workspace having the name is not an error: whoever's DKIM record DNS carries sends as it — see [Moving a domain between workspaces](/guides/domains#moving-a-domain-between-workspaces).
#### reserved_subdomain
`409` — the name is `bounces.` under a wildcard, which is where that wildcard's return path lives. Pick another label.
#### reserved_domain
`409` — the name is under `aiinbx.app`. Every workspace is given one name there, `.aiinbx.app`, and nobody adds another — see [Your provided domain](/guides/domains#your-provided-domain).
#### domain_provided
`409` — the domain is the one the workspace was given. It came with the workspace and cannot be deleted; it goes when the workspace does.
#### slug_taken
`409` — the OAuth app `slug` is claimed. Slugs are globally unique because they form the hosted connect URL.
#### external_id_taken
`409` — another space in the workspace already has that `external_id`. One space per customer is the point of the field; find the existing one with `GET /spaces?external_id=`. See [Spaces](/guides/spaces#finding-a-space-by-your-id).
#### mailbox_inactive
`409` — the mailbox isn't `active`. Check its `state`: `needs_reauth` means the customer has to reconnect, `disconnected` means it's gone. See [Mailboxes](/guides/mailboxes#mailbox-state).
#### mailbox_space_conflict
`409` — the mailbox is already connected in another space. A mailbox's space is fixed when it is first connected; reconnect it through a URL minted for that same space.
#### invalid_domain
`400` — the domain name isn't a valid registrable domain.
#### unknown_region
`400` — `region` isn't one of `eu-central-1` or `us-east-1`.
### OAuth flow
#### invalid_state
`400` — the OAuth callback's state parameter didn't validate. Usually a link opened out of context or tampered with; start the [connect flow](/guides/mailboxes#connecting-a-mailbox) again.
#### expired_state
`400` — the connection URL expired. They're short-lived and single-use — redirect the customer, don't email them the link.
#### invalid_tenant
`400` — the Microsoft tenant on the OAuth app doesn't match the account that authorized. Check the `tenant` restriction.
#### invalid_return_url
`400` — a `return_urls` entry on an OAuth app isn't one a connect link could return to: not an absolute URL, not `https` (plain `http` is allowed on `localhost`), carrying credentials, or carrying a `#fragment`. At most 20 per app.
#### return_url_not_registered
`400` — a [connect link](/guides/mailboxes#connect-link) was opened with a `return_to` the app hasn't registered. Add it to the app's `return_urls` — matched on origin and path, so one entry covers every query string. The server-side `mailboxes.connect` call isn't held to the list, since it's authenticated.
### Upstream and server
#### too_many_requests
`429` — the workspace's [request budget](/reference/conventions#request-budgets) is spent, too much of it is in flight, or the platform is at capacity; `detail` says which. The response carries `Retry-After` in seconds and the `RateLimit-*` headers. Wait that long and retry — both SDKs do so automatically. Sustained `429`s mean the integration is faster than the budget: pace it from `RateLimit-Remaining`, or ask for a larger budget.
#### rate_limited
`429` — an upstream mail provider is throttling. Not the workspace's budget: that is [`too_many_requests`](#too_many_requests). The response carries `Retry-After` in seconds. Wait that long and retry — both SDKs do so automatically.
#### send_failed
`502` — the provider rejected the message at hand-off. `detail` carries what it said. Retryable, but if it repeats for the same message the content or sender is the problem.
#### provider_error
`502` — a mail provider (Gmail, Microsoft Graph, the sending backend) failed. Retry with backoff.
#### internal_error
`500` — something broke on our side. The `request_id` is already logged; quote it if it persists. Retry with backoff.
## Debugging
1. **Read `code`, not `detail`**
`code` is stable. `detail` is prose and may be reworded.
2. **For 422, read `issues`**
Each entry has a `path` into your request body and the reason it failed.
3. **Keep `request_id`**
Log it beside your own trace ID — or send your own via
[`X-Request-ID`](/reference/conventions#request-ids) so they're the same
value.
4. **Check the scope for a 403**
A `403` after a working `200` on a read endpoint is almost always a key
scoped too narrowly.
---
# SDKs
Source: https://docs.aiinbx.com/sdks
Two official clients. They cover the same API v2 surface with the same resource names, and each is written the way its language expects rather than being a thin translation of the other.
**[TypeScript](/sdks/typescript)**
Dependency-free. Node 20+, Bun, Deno, and edge runtimes.
**[Python](/sdks/python)**
Sync and async clients on httpx. Python 3.10+.
## Install
```bash npm
npm install aiinbx
```
```bash pip
pip install aiinbx
```
Both read `AI_INBX_API_KEY` from the environment, so the common case needs no configuration:
```ts TypeScript
import AIInbx from "aiinbx"
const aiinbx = new AIInbx()
const email = await aiinbx.emails.send({
from: "Ada ",
to: "grace@example.com",
subject: "Hello",
text: "Sent with AI Inbx.",
})
```
```python Python
from aiinbx import AIInbx
with AIInbx() as client:
email = client.emails.send(
{
"from_": "Ada ",
"to": ["grace@example.com"],
"subject": "Hello",
"text": "Sent with AI Inbx.",
}
)
```
## What both give you
| | Behaviour |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| **Retries** | Network errors and HTTP 408, 409, 429, and 5xx, twice by default with bounded exponential backoff. `Retry-After` is honored. |
| **Idempotency** | A per-request key on sends and replies. |
| **Pagination** | Await a call for one page, or iterate for every page with your filters preserved. |
| **Request IDs** | Reachable without unwrapping the response. |
| **Typed errors** | A subclass per status, carrying `status`, `code`, `request_id`, headers, and the parsed body. |
| **Webhook verification** | Timing-safe HMAC checking with a replay window, and a discriminated event union. |
| **Types** | Full TypeScript types; `py.typed` and typed dicts for Python. |
## Resources
| TypeScript | Python | Covers |
| ----------------------- | ------------------------ | ------------------------------------------------------------ |
| `spaces` | `spaces` | [Customer spaces](/guides/spaces) |
| `apiKeys` | `api_keys` | [Keys and scopes](/authentication) |
| `emails` | `emails` | [Sending](/guides/sending), [scheduling](/guides/scheduling) |
| `threads` | `threads` | [Conversations and replies](/guides/threads) |
| `domains` | `domains` | [Domains, DNS, diagnostics](/guides/domains) |
| `mailboxes` | `mailboxes` | [Gmail and Outlook](/guides/mailboxes) |
| `oauthApps` | `oauth_apps` | [Your own OAuth app](/guides/mailboxes#your-own-oauth-app) |
| `webhookEndpoints` | `webhook_endpoints` | [Endpoints and deliveries](/webhooks) |
| `suppressions` | `suppressions` | [Suppression lists](/guides/suppressions) |
| `pacingRules`, `pacing` | `pacing_rules`, `pacing` | [Pacing rules and queue](/guides/pacing) |
| `attachments` | `attachments` | [Downloads and prepared text](/guides/attachments) |
## Naming
Two differences to know before you copy a snippet between them.
**`from` is a reserved word in Python**, so the Python client uses `from_`:
```ts TypeScript
{
from: "Ada "
}
```
```python Python
{"from_": "Ada "}
```
**Everything else keeps the wire name.** Request and response fields are `snake_case` in both — `thread_id`, `scheduled_at`, `suppression_key` — because they're the API's names, not the language's. Only the client's own options follow each language's convention: `baseURL` / `base_url`, `maxRetries` / `max_retries`, `idempotencyKey` / `idempotency_key`.
## Without an SDK
The API is plain HTTP with bearer auth and JSON bodies. Curl examples run throughout the guides, and the [API reference](/api) has a request builder per endpoint with generated curl, JavaScript, and Python samples.
Three things to get right if you're writing your own client:
1. **Send an Idempotency-Key on every send**
Otherwise a retry after a timeout sends twice. See
[Idempotency](/reference/conventions#idempotency).
2. **Verify webhook signatures over the raw body**
Before parsing. See [Verifying](/webhooks/verifying#any-language).
3. **Retry 429 and 5xx with backoff, and nothing else**
Handle `408` and network timeouts as uncertain outcomes; retry sends only with the same idempotency key and payload. Inspect other `4xx` errors before retrying. See
[Errors](/reference/errors#retry-or-not).
---
# Python
Source: https://docs.aiinbx.com/sdks/python
The official Python client. Sync and async on [httpx](https://www.python-httpx.org), fully typed, Python 3.10+.
```bash
pip install aiinbx
```
## Getting started
```python
from aiinbx import AIInbx
with AIInbx() as client: # reads AI_INBX_API_KEY
email = client.emails.send(
{
"from_": {"name": "Ada", "address": "ada@example.com"},
"to": ["grace@example.com"],
"subject": "Hello",
"text": "Sent with AI Inbx.",
},
idempotency_key="welcome-grace-v1",
)
print(email["id"], email["thread_id"])
```
:::note[`from_`, with an underscore]
`from` is a reserved word in Python, so the sender field is `from_`. Every other field keeps its wire name — `thread_id`, `scheduled_at`, `suppression_key`.
:::
Use the client as a context manager so its connection pool is closed. A long-lived client constructed once at startup is fine too — just close it on shutdown.
## Async
```python
from aiinbx import AsyncAIInbx
async with AsyncAIInbx() as client:
page = await client.threads.list(limit=20)
async for message in client.threads.iter_messages("thr_123"):
print(message["snippet"])
```
`AsyncAIInbx` mirrors `AIInbx` method for method. Everything below applies to both, awaiting where appropriate.
## Configuration
```python
import os
client = AIInbx(
api_key=os.environ["AI_INBX_API_KEY"],
timeout=30.0,
max_retries=2,
# base_url="http://localhost:3000/api/v2",
)
```
| Prop | Type | Default | Description |
| - | - | - | - |
| `api_key?` | `str \| None` | - | Defaults to AI_INBX_API_KEY. A missing key raises ValueError at construction. |
| `base_url?` | `str` | `"https://api.aiinbx.com/api/v2"` | Point at a local or self-hosted API. |
| `timeout?` | `float \| httpx.Timeout` | `30.0` | Seconds, or an httpx.Timeout for per-phase control. |
| `max_retries?` | `int` | `2` | 0–5. Retries network errors and 408, 409, 429, 5xx. |
| `http_client?` | `httpx.Client \| httpx.AsyncClient \| None` | - | Bring your own — for proxies, custom transports, or a shared pool. |
| `default_headers?` | `Mapping[str, str] \| None` | - | Headers added to every request. |
## Resources
| Resource | Methods |
| ------------------- | --------------------------------------------------------------------------------- |
| `api_keys` | `list`, `iter`, `create`, `delete` |
| `emails` | `send`, `list`, `iter`, `retrieve`, `reschedule`, `cancel` |
| `threads` | `list`, `iter`, `retrieve`, `iter_messages`, `reply`, `forward` |
| `domains` | `list`, `iter`, `create`, `retrieve`, `update`, `diagnostics`, `delete`, `verify` |
| `mailboxes` | `list`, `iter`, `retrieve`, `connect`, `disconnect`, `sync` |
| `oauth_apps` | `list`, `create`, `retrieve`, `update`, `delete` |
| `webhook_endpoints` | CRUD, plus `rotate_secret`, `test`, `list_deliveries`, `retry_deliveries` |
| `suppressions` | `list`, `add`, `remove` |
| `pacing_rules` | CRUD, plus `spread` |
| `pacing` | `retrieve`, `release` |
| `attachments` | `download`, `content` |
### Replying
Recipients, subject, and reply headers are inferred from the thread — pass the content as keyword arguments:
```python
reply = client.threads.reply("thr_123", text="Sounds good — see you Thursday.")
```
For a reply assembled dynamically, pass a `ThreadReplyParams` dict instead. Idempotency keys, timeouts, and raw responses work the same either way.
## Pagination
`list` returns one page; `iter` follows cursors lazily, keeping your filters:
```python
page = client.emails.list(limit=100, status="scheduled")
print(page["data"], page["next_cursor"])
for email in client.emails.iter(direction="inbound"):
print(email["subject"])
```
On `AsyncAIInbx`, `iter` is an async iterator:
```python
async for email in client.emails.iter(direction="inbound"):
print(email["subject"])
```
## Raw responses and request IDs
```python
response = client.with_raw_response.emails.retrieve("eml_123")
print(response.status_code, response.request_id)
email = response.json()
# The most recently completed response is also on the client.
print(client.last_request_id)
```
## Errors
Non-2xx responses raise typed subclasses of `APIStatusError`, each carrying `status_code`, `request_id`, response `headers`, and the decoded `body`:
```python
from aiinbx import APIStatusError, NotFoundError, RateLimitError
try:
client.domains.retrieve("dom_missing")
except NotFoundError:
domain = None
except RateLimitError as error:
schedule_retry(error.headers.get("retry-after"))
except APIStatusError as error:
print(error.status_code, error.body["code"], error.request_id)
print(error.body.get("issues"))
```
| Exception | Raised for |
| -------------------------- | -------------------------------- |
| `BadRequestError` | 400 |
| `AuthenticationError` | 401 |
| `PermissionDeniedError` | 403 |
| `NotFoundError` | 404 |
| `ConflictError` | 409 |
| `UnprocessableEntityError` | 422 |
| `RateLimitError` | 429 |
| `InternalServerError` | 5xx |
| `APIConnectionError` | The request never got a response |
| `APITimeoutError` | The request timed out |
All derive from `AIInbxError`, so one `except AIInbxError` catches everything the client raises. Codes and what to do about each are in [Errors](/reference/errors).
## Webhooks
Pass the **unmodified** request body. The verifier signs `.` with HMAC-SHA256 and rejects payloads older than five minutes by default:
```python
event = client.webhooks.verify(
request_body,
request.headers["AIInbx-Signature"],
webhook_secret,
)
if event["type"] == "email.bounced":
for recipient in event["data"]["recipients"]:
print(recipient)
```
`event` is a discriminated `WebhookEvent` union — a type checker narrows `data` from `type`.
Both `t=...,v1=...` signatures and a bare digest with a separate `timestamp=` argument are supported. Widen the replay window with `tolerance=` only as a diagnostic.
A module-level `verify_webhook` is exported too, for handlers that don't have a client to hand:
```python
from aiinbx import verify_webhook
event = verify_webhook(body, signature, secret)
```
### FastAPI
```python
from fastapi import FastAPI, Request, Response
from aiinbx import AIInbx, WebhookVerificationError
app = FastAPI()
client = AIInbx()
@app.post("/webhooks/aiinbx")
async def webhook(request: Request):
body = await request.body() # raw bytes, before any parsing
try:
event = client.webhooks.verify(
body,
request.headers["aiinbx-signature"],
WEBHOOK_SECRET,
)
except WebhookVerificationError:
return Response(status_code=400)
if event["type"] == "email.received" and event["data"]["category"] == "human":
await queue_reply(event["data"]["thread_id"])
return Response(status_code=204)
```
Take `Request` rather than a Pydantic model — a declared body model parses the request before your handler runs, and the signature covers the exact bytes. More in [Verifying](/webhooks/verifying#getting-the-raw-body).
## Typing
The package ships `py.typed`, so mypy and Pyright see everything. Resources return `TypedDict`s that match the API's JSON, and models are importable:
```python
from aiinbx.models import WebhookEvent, ThreadReplyParams
def summarize(event: WebhookEvent) -> str:
if event["type"] == "email.received":
return event["data"]["snippet"] # narrowed
return event["type"]
```
---
# TypeScript
Source: https://docs.aiinbx.com/sdks/typescript
The official TypeScript client. **No dependencies** — it uses `fetch` and Web Crypto, so it runs on Node.js 20+, Bun, Deno, Cloudflare Workers, and Vercel Edge without a polyfill.
```package-install
npm i aiinbx
```
## Getting started
```ts
import AIInbx from "aiinbx"
const aiinbx = new AIInbx() // reads AI_INBX_API_KEY
const email = await aiinbx.emails.send(
{
from: { name: "Ada", address: "ada@example.com" },
to: "grace@example.com",
subject: "Hello",
text: "Sent through AI Inbx.",
},
{ idempotencyKey: "welcome-grace-v1" }
)
```
## Configuration
```ts
const aiinbx = new AIInbx({
apiKey: process.env.AI_INBX_API_KEY!,
timeout: 30_000,
maxRetries: 2,
// baseURL: "http://localhost:3000/api/v2",
})
```
| Prop | Type | Default | Description |
| - | - | - | - |
| `apiKey?` | `string` | - | Defaults to AI_INBX_API_KEY where the runtime exposes environment variables. |
| `baseURL?` | `string` | `"https://api.aiinbx.com/api/v2"` | Point at a local or self-hosted API. |
| `timeout?` | `number` | `60000` | Per-request timeout in milliseconds. |
| `maxRetries?` | `number` | `2` | Retries for network errors and 408, 409, 429, 5xx. |
| `fetch?` | `typeof fetch` | - | Supply your own fetch — for instrumentation or a proxy agent. |
| `defaultHeaders?` | `HeadersInit` | - | Headers added to every request. |
Every request can override the client defaults:
```ts
await aiinbx.emails.send(payload, {
timeout: 10_000,
maxRetries: 0,
idempotencyKey: "order-8812-receipt",
signal: controller.signal,
headers: { "X-Request-ID": traceId },
})
```
## Resources
- `apiKeys` — `list`, `create`, `delete`
- `emails` — `send`, `list`, `retrieve`, `reschedule`, `cancel`
- `threads` — `list`, `retrieve`, `iterateMessages`, `reply`, `forward`
- `domains` — `list`, `create`, `retrieve`, `update`, `diagnostics`, `delete`, `verify`
- `mailboxes` — `list`, `retrieve`, `connect`, `sync`, `disconnect`
- `oauthApps` — `list`, `create`, `retrieve`, `update`, `delete`
- `webhookEndpoints` — `list`, `create`, `retrieve`, `update`, `delete`, `rotateSecret`, `test`, `listDeliveries`, `retryDeliveries`
- `suppressions` — `list`, `add`, `remove`
- `pacingRules` — `list`, `create`, `retrieve`, `update`, `delete`, `spread`
- `pacing` — `retrieve`, `release`
- `attachments` — `download`, `content`
### Replying
No headers to reconstruct — the sender, recipients, subject, and reply headers come from the thread:
```ts
await aiinbx.threads.reply(
"thr_123",
{ text: "Sounds good — see you then." },
{ idempotencyKey: "reply-thr-123-v1" }
)
```
## React Email
Pass a component as `react` and it's rendered to HTML before the send, the same way Resend's Node SDK does it:
```tsx
import AIInbx from "aiinbx"
import { WelcomeEmail } from "./emails/welcome"
const aiinbx = new AIInbx()
await aiinbx.emails.send({
from: "Acme ",
to: "ada@example.com",
subject: "Welcome",
react: ,
})
```
Install `@react-email/render` (or `@react-email/components`) alongside React. The renderer is imported lazily — only when `react` is present — so it costs nothing if you never use it.
:::tip
Keep passing `text` alongside `react`. A plain-text alternative renders in clients that won't show your HTML, and its absence is a small deliverability penalty.
:::
## Pagination
A list call is awaitable for one page:
```ts
const page = await aiinbx.threads.list({ limit: 50, query: "invoice" })
```
…and iterable for all of them, carrying your filters onto every request:
```ts
for await (const thread of aiinbx.threads.list({
mailbox: "team@example.com",
})) {
console.log(thread.subject)
}
// A known-small collection, in full:
const domains = await aiinbx.domains.list().all()
// Page by page, when you want to checkpoint:
for await (const page of aiinbx.emails.list().iterPages()) {
await saveCheckpoint(page.next_cursor)
}
```
A thread's messages paginate separately:
```ts
for await (const message of aiinbx.threads.iterateMessages("thr_123", {
message_limit: 100,
})) {
console.log(message.subject)
}
```
## Response metadata
Every call is awaitable. `.withResponse()` gets you the headers and request ID too:
```ts
const { data, response, requestId } = await aiinbx.emails
.retrieve("eml_123")
.withResponse()
console.log(response.status, requestId, data.subject)
```
## Errors
Non-2xx responses throw `APIError` with `status`, `code`, `requestId`, `headers`, and the parsed `body`. Subclasses let you branch without comparing numbers:
```ts
import { APIError, NotFoundError, RateLimitError } from "aiinbx"
try {
await aiinbx.domains.retrieve("dom_missing")
} catch (error) {
if (error instanceof NotFoundError) return null
if (error instanceof RateLimitError) return scheduleRetry()
if (error instanceof APIError) console.error(error.status, error.code)
throw error
}
```
| Class | Status |
| -------------------------- | ------ |
| `BadRequestError` | 400 |
| `AuthenticationError` | 401 |
| `PermissionDeniedError` | 403 |
| `NotFoundError` | 404 |
| `RequestTimeoutError` | 408 |
| `ConflictError` | 409 |
| `UnprocessableEntityError` | 422 |
| `RateLimitError` | 429 |
| `InternalServerError` | 5xx |
Codes and what to do about each are in [Errors](/reference/errors).
## Webhooks
`verifyWebhookRequest` handles the raw body, header parsing, timing-safe comparison, and the five-minute replay window:
```ts app/webhooks/aiinbx/route.ts
import { verifyWebhookRequest } from "aiinbx/webhooks"
export async function POST(request: Request) {
const event = await verifyWebhookRequest(
request,
process.env.AI_INBX_WEBHOOK_SECRET!
)
// `type` is a discriminant — TypeScript knows this event's exact data.
if (event.type === "email.received") {
console.log(event.data.thread_id, event.data.category)
}
return new Response(null, { status: 204 })
}
```
When your framework hands you the body and signature separately:
```ts
import { verifyWebhook } from "aiinbx/webhooks"
const event = await verifyWebhook(rawBody, signature, secret)
```
Both throw `WebhookSignatureError`. More, including per-framework raw-body recipes, in [Verifying](/webhooks/verifying).
## Types
Every request and response type is exported from the package root:
```ts
import type {
Email,
FullThread,
SendEmailParams,
WebhookEvent,
WebhookEventType,
} from "aiinbx"
function summarize(thread: FullThread): string {
return thread.messages.map((m) => m.snippet).join("\n")
}
```
`WebhookEvent` is a discriminated union over `type`, so a `switch` narrows `data` in each branch with no casts.
## Runtimes
| Runtime | Notes |
| ------------------ | ---------------------------------------------------------------------- |
| Node.js 20+ | `fetch` and Web Crypto are built in. |
| Bun | Works as-is. |
| Deno | Works as-is. |
| Cloudflare Workers | Works. `apiKey` must be passed explicitly — there's no `process.env`. |
| Vercel Edge | Same. |
| Browsers | Technically works; don't. It would ship your API key to every visitor. |
---
# Webhooks
Source: https://docs.aiinbx.com/webhooks
Webhooks are how anything that happens after a send reaches your code — a reply arriving, a bounce, a complaint, a mailbox losing its authorization. Everything asynchronous shows up here.
**[Verifying](/webhooks/verifying)**
Check the signature before you parse the body.
**[Events](/webhooks/events)**
Every event type and the shape of its `data`.
**[Receiving](/guides/receiving)**
How inbound mail gets here in the first place.
## Creating an endpoint
```ts TypeScript
const endpoint = await aiinbx.webhookEndpoints.create({
url: "https://app.example.com/webhooks/aiinbx",
subscriptions: [
"email.received",
"email.bounced",
"email.complained",
"email.unsubscribed",
],
})
console.log(endpoint.secret) // returned once — store it now
```
```python Python
endpoint = client.webhook_endpoints.create(
url="https://app.example.com/webhooks/aiinbx",
subscriptions=["email.received", "email.bounced", "email.complained"],
)
print(endpoint["secret"])
```
```bash curl
curl https://api.aiinbx.com/api/v2/webhook-endpoints \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://app.example.com/webhooks/aiinbx",
"subscriptions": ["email.received", "email.bounced", "email.complained"]
}'
```
`subscriptions` is required and must name at least one [event type](/webhooks/events) — an endpoint subscribed to nothing would never fire. Subscribe to what you handle; you can always widen later with `update`. `max_concurrency` sets how many deliveries may be in flight at once; see [Throughput](#throughput).
Managing endpoints requires a `full` [scope](/authentication#scopes) key.
:::warning
The signing secret is returned once, on creation. If you lose it, [rotate](#rotating-the-secret) — there's no way to read it back.
:::
## The request
Every delivery is a `POST` with a JSON envelope:
```json
{
"id": "evt_...",
"type": "email.received",
"created_at": "2026-09-01T10:31:04Z",
"space_id": null,
"data": {}
}
```
| Prop | Type | Default | Description |
| - | - | - | - |
| `id` | `string` | - | evt_… — stable across retries and replays. |
| `type` | `string` | - | An event type listed in the event reference. |
| `created_at` | `string` | - | When the event happened, RFC 3339. |
| `space_id` | `string \| null` | - | The space the resource is in — the email's, thread's, domain's or mailbox's. null is the workspace itself, and all a workspace without spaces ever sees. |
| `data` | `object` | - | Varies by type. |
| Header | Value |
| ------------------ | ------------------------------------------------------------------- |
| `Content-Type` | `application/json` |
| `AIInbx-Signature` | `t=,v1=` — see [Verifying](/webhooks/verifying) |
| `AIInbx-Event-ID` | The event ID, also `id` in the body |
| `User-Agent` | `aiinbx-webhooks/1` |
`id` is stable across retries of the same event, which makes it the right idempotency key on your side.
### Responding
Answer `2xx` and answer fast. Anything else — or no answer within **10 seconds** — counts as a failure and is retried.
```ts
export async function POST(request: Request) {
const event = await verifyWebhookRequest(request, secret)
await enqueueOnce(event.id, event) // durable, atomic insert keyed by event ID
return new Response(null, { status: 204 })
}
```
`enqueueOnce` is an application-defined operation: it must durably store the event and atomically deduplicate by `event.id`. Return `2xx` only after storage succeeds. If storage fails, return a non-2xx response so delivery can retry. Process the event in a worker with its own retry policy. See the [production guide](/guides/production#receive-events-durably).
## Routing
An endpoint can narrow what it receives by sender or recipient — useful when one workspace serves several products or tenants:
```ts
await aiinbx.webhookEndpoints.create({
url: "https://app.example.com/webhooks/support",
subscriptions: ["email.received"],
routing: [
{ effect: "allow", field: "to", pattern: "support@example.com" },
{ effect: "block", field: "from", pattern: "*@spam-source.example" },
],
})
```
`field` is `from`, `to`, or `either`. A `from` rule matches the sender and any Reply-To address; a `to` rule matches every recipient — To, Cc and Bcc — so a pattern like `*@acme.dev` follows that company's people whether they were addressed, copied or blind-copied. Two rules decide everything:
1. **Block wins**
If any `block` rule matches, the event isn't delivered — regardless of what
the allow rules say.
2. **An empty allow list means no opinion**
With no `allow` rules, everything not blocked comes through. Add one and the
endpoint receives only what matches it.
Mailbox events use the mailbox address for both `from` and `to`, so address routing also applies to `mailbox.*` events. Domain events have no addresses to match and bypass address routing; the endpoint must still subscribe to their event type.
Up to 100 rules per endpoint.
## Spaces
Every endpoint receives every [space](/guides/spaces)'s events, each stamped with its `space_id` on the envelope — `null` for the workspace itself. Branch on it to find the customer:
```ts
if (event.space_id) {
const customer = await customerBySpace(event.space_id)
await queueForCustomer(customer, event)
}
```
A customer who needs events delivered to a system of their own gets them from your handler, not from an endpoint of their own. Routing rules apply across spaces the same way.
## Retries
A failed delivery is retried on a fixed schedule, with ±20% jitter, inside a **24-hour window**:
| Attempt | Roughly after the previous |
| ------- | -------------------------- |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 24 hours, capped at the delivery deadline |
Each next attempt is capped at 24 hours after the delivery was created, so the final interval may be shorter than the table shows. After the attempt budget is exhausted, or an attempt fails at or after that deadline, the delivery is marked `failed`. Inspect and replay failed deliveries after resolving the cause.
If your endpoint returns a `Retry-After` header, it's honored instead of the standard backoff — the way to say "I'm degraded, come back in ten minutes" without being hammered.
When nothing sent to an endpoint has got through for 24 hours, the workspace's owners and admins get an email saying so — once per outage, not per delivery, and only those who have not turned these mails off under Settings → Notifications. The first `2xx` ends it.
## Throughput
Up to `max_concurrency` deliveries are in flight to an endpoint at once — 16 by default, anywhere from 1 to 128. Throughput is that number divided by your handler's latency: at 200 ms a default endpoint takes about 80 events a second, and one set to 128 about 640. A platform fronting thousands of tenants from one endpoint should raise it.
```ts
await aiinbx.webhookEndpoints.update("whk_...", { max_concurrency: 64 })
```
Nothing about order is promised at any setting, including 1 — a retry lands after events that came later, and two events about one thread can arrive in either order. Order by `created_at` on the envelope, or by the data's own timestamps, and never by arrival. Setting it to 1 is a way to go easy on a small handler, not a way to sequence events.
## Inspecting deliveries
```ts TypeScript
const page = await aiinbx.webhookEndpoints.listDeliveries("whk_...", {
state: "failed",
})
for (const delivery of page.data) {
console.log(delivery.event_type, delivery.state, delivery.next_attempt_at)
for (const attempt of delivery.attempts) {
console.log(" ", attempt.attempted_at, attempt.status_code, attempt.error)
}
}
```
```python Python
page = client.webhook_endpoints.list_deliveries("whk_...", state="failed")
for delivery in page["data"]:
print(delivery["event_type"], delivery["state"])
```
```bash curl
curl "https://api.aiinbx.com/api/v2/webhook-endpoints/whk_.../deliveries?state=failed" \
-H "Authorization: Bearer $AI_INBX_API_KEY"
```
Each delivery carries the `body` the endpoint receives and every `attempt` — timestamp, status code, duration, error, and the first 2 KB of your response. That last field is usually what tells you why your handler rejected the request.
`body` is null in two cases: for an endpoint carried over from the previous aiinbx.com (`format: "v1"`), whose legacy body is built from the message at send time rather than stored — the console rebuilds it for the delivery you open — and for a delivery nothing was sent for, which such an endpoint settles as `failed` with no attempts when the message the event was about no longer exists.
Filter by `state`: `pending`, `delivered`, or `failed`.
### Replaying
After you've fixed the handler, push the failures back through:
```ts
const { data } = await aiinbx.webhookEndpoints.retryDeliveries("whk_...", {
delivery_ids: failed.map((d) => d.id),
})
```
Up to 100 per call. A replay carries the same event `id` as the original, so a handler that deduplicates on it stays correct.
## Testing an endpoint
Fire a synthetic event without waiting for real mail — the fastest way to check a new handler or a tunnel:
```ts TypeScript
const result = await aiinbx.webhookEndpoints.test("whk_...", {
event_type: "email.received",
resource_id: "eml_test",
payload: { thread_id: "thr_test", subject: "Test", from: "test@example.com" },
})
console.log(result.state, result.status_code, result.error, result.response)
```
```python Python
result = client.webhook_endpoints.test("whk_...", event_type="email.received")
print(result["state"], result["status_code"])
```
```bash curl
curl https://api.aiinbx.com/api/v2/webhook-endpoints/whk_.../test \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "event_type": "email.received" }'
```
The response tells you what your endpoint actually did — status code, duration, and the body it returned. A test event is signed exactly like a real one, so it exercises your verification code too.
:::note
`payload` is whatever you put in it. A test event's `data` won't match the real shape unless you make it, which is deliberate — it lets you probe how your handler behaves on unexpected input.
:::
## Rotating the secret
Rotation issues a new secret while the old one keeps verifying for a grace period, so you can deploy without dropping events:
```ts
const { secret, previous_secret_expires_at } =
await aiinbx.webhookEndpoints.rotateSecret("whk_...", {
grace_seconds: 86_400,
})
```
`grace_seconds` defaults to 24 hours and can be up to 7 days. Set it to `0` to cut over immediately — only when a secret has leaked and the gap matters less than the exposure.
1. **Rotate with a grace period**
Both secrets verify.
2. **Deploy the new secret**
Your handler switches over.
3. **Let the grace period lapse**
The old secret stops working on its own.
## Pausing an endpoint
```ts
await aiinbx.webhookEndpoints.update("whk_...", { enabled: false })
```
A disabled endpoint receives nothing and accrues nothing — events that occur while it's off are not queued for later. Prefer it to deleting when you're taking a service down for maintenance, and expect a gap.
## Endpoint URLs
Endpoint URLs must be publicly reachable over HTTPS. Requests to private and loopback addresses are refused — including via redirect — so a URL that resolves inward can't be used to reach into infrastructure.
In development, use a tunnel rather than `localhost`.
---
# Event reference
Source: https://docs.aiinbx.com/webhooks/events
Every webhook event arrives in the same envelope:
```json
{
"id": "evt_...",
"type": "email.received",
"created_at": "2026-09-01T10:31:04Z",
"space_id": null,
"data": { }
}
```
`id` is stable across retries and replays — deduplicate on it. `space_id` is the [space](/guides/spaces) the resource is in, `null` for the workspace itself. `data` varies by `type`, and both SDKs discriminate on `type` so a type checker narrows it for you.
## Email
| Event | Fires when |
| --- | --- |
| [`email.received`](/webhooks/events/email-received) | Inbound mail arrives. |
| [`email.sent`](/webhooks/events/email-sent) | A message is handed to the mail provider. |
| [`email.delivered`](/webhooks/events/email-delivered) | The receiving server accepted it. |
| [`email.bounced`](/webhooks/events/email-bounced) | Delivery failed. |
| [`email.complained`](/webhooks/events/email-complained) | The recipient marked it as spam. |
| [`email.failed`](/webhooks/events/email-failed) | The provider refused it before any attempt. |
| [`email.unsubscribed`](/webhooks/events/email-unsubscribed) | The recipient opted out. |
| [`email.opened`](/webhooks/events/email-opened) | An open was tracked. |
| [`email.clicked`](/webhooks/events/email-clicked) | A tracked link was clicked. |
## Thread
| Event | Fires when |
| --- | --- |
| [`thread.created`](/webhooks/events/thread-created) | A new conversation started. |
## Domain
| Event | Fires when |
| --- | --- |
| [`domain.verified`](/webhooks/events/domain-verified) | The domain’s DKIM identity was verified. |
| [`domain.lost`](/webhooks/events/domain-lost) | A verified domain's DKIM record stopped resolving. |
## Mailbox
| Event | Fires when |
| --- | --- |
| [`mailbox.connected`](/webhooks/events/mailbox-connected) | A customer authorized a mailbox. |
| [`mailbox.needs_reauth`](/webhooks/events/mailbox-needs-reauth) | A mailbox's authorization stopped working. |
| [`mailbox.disconnected`](/webhooks/events/mailbox-disconnected) | A mailbox was removed. |
## Handle events in a worker
[Verify and durably enqueue](/guides/production#receive-events-durably) each event in your HTTP handler. In a worker, branch on `event.type` to select the appropriate application behavior:
| Type | Typical action |
| --- | --- |
| `email.received` | Load the message or thread and evaluate whether to respond |
| `email.bounced` | Record affected recipients and whether the failure is permanent |
| `email.failed` | Surface the provider's reason; the message never went out |
| `email.complained` | Record the complaint and review the recipient’s suppression state |
| `email.unsubscribed` | Update the matching preference in your application |
| `mailbox.needs_reauth` | Ask the mailbox owner to reconnect |
Make each action recoverable and idempotent. Mark work complete after it succeeds. Unknown event types should not crash the handler; record the type for diagnosis and ignore it until the application supports it.
**[Verifying requests](/webhooks/verifying)**
How to authenticate each delivery before storing or processing it.
---
# domain.lost
Source: https://docs.aiinbx.com/webhooks/events/domain-lost
A verified domain's DKIM record stopped resolving. The domain no longer sends: `verified_at` is null again and sends from it fail with `domain_unverified` until the record is back.
The event says only that the key is gone from DNS. It was removed, or replaced — a customer who moved to another platform publishes that platform's key at the same name. Which of the two is the customer's business, and the event does not tell.
## Payload
| Prop | Type | Default | Description |
| - | - | - | - |
| `domain_id` | `string` | - | dom_… |
| `name` | `string` | - | The domain name. |
| `region` | `string` | - | eu-central-1 or us-east-1. |
## Handling it
Stop offering the domain as a sender until [`domain.verified`](/webhooks/events/domain-verified) comes back, and show the customer the record they are missing. Nothing else moves: the domain, its records and the mail that went through it stay exactly where they were.
**[Domains](/guides/domains#staying-verified)**
Background rechecks, and what moving a domain between workspaces looks like.
---
# domain.verified
Source: https://docs.aiinbx.com/webhooks/events/domain-verified
The domain’s DKIM identity was verified. The domain can now send.
## Payload
| Prop | Type | Default | Description |
| - | - | - | - |
| `domain_id` | `string` | - | dom_… |
| `name` | `string` | - | The domain name. |
| `region` | `string` | - | eu-central-1 or us-east-1. |
## Handling it
Use this event to update sending readiness without continuous polling. It confirms DKIM verification, not inbound routing or every DNS setting. Check the inbound MX separately before marking receiving as ready.
Use it to flip a customer's onboarding step to done, and to start whatever you were holding back until they could send.
**[Domains](/guides/domains)**
The records, and what `diagnostics` reports when one won't resolve.
---
# email.bounced
Source: https://docs.aiinbx.com/webhooks/events/email-bounced
Delivery failed. Creates a [suppression](/guides/suppressions) automatically, so the next send to that address never leaves.
## Payload
| Prop | Type | Default | Description |
| - | - | - | - |
| `email_id` | `string` | - | eml_… |
| `thread_id` | `string` | - | The conversation. |
| `domain_id` | `string \| null` | - | The domain it was sent from, or null when sent through a mailbox. |
| `mailbox_id` | `string \| null` | - | The mailbox it was sent through, or null when sent from a domain. |
| `suppression_key` | `string \| null` | - | The list the send named, when it named one. |
| `recipients` | `string[]` | - | Which recipients bounced. |
| `permanent` | `boolean` | - | true for a hard bounce — the address doesn't exist. false for a soft one — full mailbox, temporary failure. |
| `reason` | `string` | - | What the receiving server said. |
## Handling it
```ts
if (event.type === "email.bounced" && event.data.permanent) {
await markUndeliverable(event.data.recipients, event.data.reason)
}
```
A permanent bounce means the address is wrong — stop showing it as valid in your own UI. A soft bounce is usually transient and not worth acting on.
The suppression a hard bounce writes goes on the workspace's `*` list whatever `space_id` the event carries — the address exists for nobody, so no [space](/guides/spaces) should try it either.
---
# email.clicked
Source: https://docs.aiinbx.com/webhooks/events/email-clicked
A tracked link was clicked. A much stronger signal than an open.
## Payload
| Prop | Type | Default | Description |
| - | - | - | - |
| `email_id` | `string` | - | eml_… |
| `thread_id` | `string` | - | The conversation. |
| `domain_id` | `string \| null` | - | The domain it was sent from, or null when sent through a mailbox. |
| `mailbox_id` | `string \| null` | - | The mailbox it was sent through, or null when sent from a domain. |
| `suppression_key` | `string \| null` | - | The list the send named, when it named one. |
| `url` | `string` | - | The destination that was clicked. |
## Handling it
A click is a real human action in a way an open is not, so this is the event to attribute against. Security scanners do visit links before the recipient does, though — if the click arrives within a second or two of delivery, treat it with suspicion.
---
# email.complained
Source: https://docs.aiinbx.com/webhooks/events/email-complained
The recipient marked the message as spam. Rarer than a bounce and far more serious for reputation.
## Payload
| Prop | Type | Default | Description |
| - | - | - | - |
| `email_id` | `string` | - | eml_… |
| `thread_id` | `string` | - | The conversation. |
| `domain_id` | `string \| null` | - | The domain it was sent from, or null when sent through a mailbox. |
| `mailbox_id` | `string \| null` | - | The mailbox it was sent through, or null when sent from a domain. |
| `suppression_key` | `string \| null` | - | The list the send named — where the complaint is filed, in the send's space. |
| `recipients` | `string[]` | - | Who complained. |
| `reason` | `string \| null` | - | The provider's reason, when given. |
## Handling it
Treat a complaint as final. The address is suppressed automatically; never remove that entry to keep sending.
A rising complaint rate is what gets a sending domain throttled or blocked by the large mailbox providers, so it's worth alerting on rather than only logging.
---
# email.delivered
Source: https://docs.aiinbx.com/webhooks/events/email-delivered
The receiving server accepted the message. This does not establish inbox placement or that the recipient read it.
## Payload
| Prop | Type | Default | Description |
| - | - | - | - |
| `email_id` | `string` | - | eml_… |
| `thread_id` | `string` | - | The conversation. |
| `domain_id` | `string \| null` | - | The domain it was sent from, or null when sent through a mailbox. |
| `mailbox_id` | `string \| null` | - | The mailbox it was sent through, or null when sent from a domain. |
| `suppression_key` | `string \| null` | - | The list the send named, when it named one. |
| `recipients` | `string[]` | - | Which recipients this event covers — delivery is reported per recipient. |
## Handling it
A message to several recipients can produce several `email.delivered` events. Don't treat the first as "the message was delivered" — accumulate `recipients` across events if you need to know that everyone got it.
Accepted by the receiving server is as far as any email API can see. What happens after that — inbox, spam folder, a filter rule — is not observable from here.
---
# email.failed
Source: https://docs.aiinbx.com/webhooks/events/email-failed
The mail provider refused the message before any delivery attempt — a virus verdict, a policy, a rendering failure. Nothing reached any recipient, and nothing will be retried: the message is `failed`.
## Payload
| Prop | Type | Default | Description |
| - | - | - | - |
| `email_id` | `string` | - | eml_… |
| `thread_id` | `string` | - | The conversation. |
| `domain_id` | `string \| null` | - | The domain it was sent from, or null when sent through a mailbox. |
| `mailbox_id` | `string \| null` | - | The mailbox it was sent through, or null when sent from a domain. |
| `suppression_key` | `string \| null` | - | The list the send named, when it named one. |
| `reason` | `string` | - | What the provider said. |
## Handling it
```ts
if (event.type === "email.failed") {
await flagForReview(event.data.email_id, event.data.reason)
}
```
A refusal is about the message, not the address: no suppression is written, and the same recipient can be written to again once the content is fixed.
---
# email.opened
Source: https://docs.aiinbx.com/webhooks/events/email-opened
A tracked open. Requires open tracking on the [domain](/guides/domains#tracking-settings).
## Payload
| Prop | Type | Default | Description |
| - | - | - | - |
| `email_id` | `string` | - | eml_… |
| `thread_id` | `string` | - | The conversation. |
| `domain_id` | `string \| null` | - | The domain it was sent from, or null when sent through a mailbox. |
| `mailbox_id` | `string \| null` | - | The mailbox it was sent through, or null when sent from a domain. |
| `suppression_key` | `string \| null` | - | The list the send named, when it named one. |
| `user_agent` | `string \| null` | - | The client that loaded the pixel. |
## Handling it
:::warning
Opens are unreliable by construction. Privacy proxies prefetch tracking pixels, which reports opens that never happened, and clients that block images report none at all. Useful in aggregate; not evidence about an individual.
:::
Never branch application logic on a single open — "they haven't read it, resend" will misfire on both kinds of client. [`email.clicked`](/webhooks/events/email-clicked) is the signal worth acting on.
---
# email.received
Source: https://docs.aiinbx.com/webhooks/events/email-received
Inbound mail. The one most applications are built around — it fires once per received message, with the thread it belongs to already resolved.
## Payload
| Prop | Type | Default | Description |
| - | - | - | - |
| `email_id` | `string` | - | eml_… — retrieve it for bodies and headers. |
| `thread_id` | `string` | - | The conversation it belongs to. |
| `domain_id` | `string \| null` | - | The domain it was received on, or null when it came through a mailbox. |
| `mailbox_id` | `string \| null` | - | The mailbox it was synced from, or null when it came in on a domain. |
| `from` | `string` | - | Sender address. |
| `to` | `string[]` | - | Recipients on the envelope. |
| `subject` | `string` | - | The subject line. |
| `snippet` | `string` | - | A short preview of the body. |
| `category` | `string \| null` | - | human, out_of_office, auto_reply, bounce, verification, transactional, notification, marketing, or spam. |
| `verdicts?` | `object` | - | Sender authentication results: spam, spf, dkim, dmarc. |
| `attachments?` | `object[]` | - | id, filename, content_type, size, preparation — metadata only. |
```json
{
"id": "evt_...",
"type": "email.received",
"created_at": "2026-09-01T10:31:04Z",
"space_id": null,
"data": {
"email_id": "eml_...",
"thread_id": "thr_...",
"domain_id": "dom_...",
"mailbox_id": null,
"from": "grace@example.com",
"to": ["support@yourapp.com"],
"subject": "Re: Quick question",
"snippet": "Thursday at 10 works for me…",
"category": "human",
"verdicts": { "spam": "PASS", "spf": "PASS", "dkim": "PASS", "dmarc": "PASS" },
"attachments": []
}
}
```
## Handling it
The payload carries a snippet, not the body — fetch the email when you need the full content.
```ts
if (event.type === "email.received" && event.data.category === "human") {
const email = await aiinbx.emails.retrieve(event.data.email_id)
await queueReply(event.data.thread_id, email.text)
}
```
:::tip
Gate on `category === "human"` before letting an agent reply. Without it, an out-of-office autoresponder and your bot will happily talk to each other.
:::
**[Receiving](/guides/receiving)**
Getting mail to arrive in the first place — a domain's MX record, or a connected mailbox.
---
# email.sent
Source: https://docs.aiinbx.com/webhooks/events/email-sent
The message was handed to the provider. Acceptance, not delivery.
## Payload
| Prop | Type | Default | Description |
| - | - | - | - |
| `email_id` | `string` | - | eml_… |
| `thread_id` | `string` | - | The conversation. |
| `domain_id` | `string \| null` | - | The domain it was sent from, or null when sent through a mailbox. |
| `mailbox_id` | `string \| null` | - | The mailbox it was sent through, or null when sent from a domain. |
| `suppression_key` | `string \| null` | - | The list the send named, when it named one. |
| `from` | `string` | - | Sender address. |
| `to` | `string[]` | - | Recipients. |
| `subject` | `string` | - | The subject line. |
| `snippet` | `string` | - | A short preview. |
| `attachments?` | `object[]` | - | Attachment metadata. |
## Handling it
This is the event to record in an outbox or an audit log. It is *not* evidence the message arrived — wait for [`email.delivered`](/webhooks/events/email-delivered) for that, and expect [`email.bounced`](/webhooks/events/email-bounced) instead when it didn't.
A [scheduled send](/guides/scheduling) fires this at the moment it actually goes out, not when you scheduled it.
---
# email.unsubscribed
Source: https://docs.aiinbx.com/webhooks/events/email-unsubscribed
The recipient opted out.
## Payload
| Prop | Type | Default | Description |
| - | - | - | - |
| `email_id` | `string` | - | The message they opted out from. |
| `domain_id` | `string \| null` | - | The domain that message was sent from; null when it went through a mailbox, or is gone. |
| `mailbox_id` | `string \| null` | - | The mailbox that message was sent through; null when it went from a domain, or is gone. |
| `address` | `string` | - | Who opted out. |
| `key` | `string` | - | The suppression list. `*` is the whole workspace's, or the whole space's when the send was from one. |
| `scope` | `"all" \| "optional"` | - | Whether they blocked everything or only optional mail. |
| `source` | `"link" \| "one_click" \| "reply"` | - | How: the unsubscribe link, the mail client's one-click header, or a reply that read as an opt-out. |
## Handling it
```ts
if (event.type === "email.unsubscribed") {
await recordOptOut(event.data.address, event.data.key, event.data.scope)
}
```
Mirror this into your own preference UI so a customer who unsubscribed by email doesn't see themselves still subscribed in your app.
`scope: "optional"` means they blocked marketing but still want the receipts and password resets — don't collapse it to a single "unsubscribed" boolean.
---
# mailbox.connected
Source: https://docs.aiinbx.com/webhooks/events/mailbox-connected
A customer finished authorizing a Gmail or Outlook mailbox.
## Payload
| Prop | Type | Default | Description |
| - | - | - | - |
| `mailbox_id` | `string` | - | mbx_… |
| `address` | `string` | - | The mailbox address. |
| `provider` | `"google" \| "microsoft"` | - | Which provider. |
| `app_id` | `string \| null` | - | Your OAuth app, or null when the shared AI Inbx app was used. |
| `ref?` | `string` | - | The ref you put on the connect link or passed to mailboxes.connect — how you match this to your user. |
| `reconnected` | `boolean` | - | true when this replaced an existing authorization rather than adding a new mailbox. |
## Handling it
This — not the browser landing back on `return_to` — is the signal that the mailbox is live. The redirect happens whether or not the grant completed, and a customer who closes the tab never triggers it at all.
Match on `ref` — whatever you put on the connect link or passed to `mailboxes.connect` — to attach the mailbox to the right user in your database.
**[Mailboxes](/guides/mailboxes)**
The connect flow end to end, including your own OAuth app.
---
# mailbox.disconnected
Source: https://docs.aiinbx.com/webhooks/events/mailbox-disconnected
The mailbox was removed — by you, or by the customer revoking access at the provider.
## Payload
| Prop | Type | Default | Description |
| - | - | - | - |
| `mailbox_id` | `string` | - | mbx_… |
| `address` | `string` | - | The mailbox address. |
| `provider` | `"google" \| "microsoft"` | - | Which provider. |
| `reason` | `string \| null` | - | Why, when known. |
## Handling it
Unlike [`mailbox.needs_reauth`](/webhooks/events/mailbox-needs-reauth), this is terminal — there is no authorization left to refresh. Clear the mailbox from your own UI rather than showing it as broken, and send the customer back through the connect flow if they want it again.
---
# mailbox.needs_reauth
Source: https://docs.aiinbx.com/webhooks/events/mailbox-needs-reauth
The stored authorization stopped working: revoked access, a password change, an admin policy.
## Payload
| Prop | Type | Default | Description |
| - | - | - | - |
| `mailbox_id` | `string` | - | mbx_… |
| `address` | `string` | - | The mailbox address. |
| `provider` | `"google" \| "microsoft"` | - | Which provider. |
| `reason` | `string \| null` | - | What the provider said, when it said anything. |
## Handling it
Prompt the customer to reconnect here. Sync has already stopped, and sends will fail with [`mailbox_inactive`](/reference/errors#mailbox_inactive) until it's fixed.
This is the one mailbox event worth paging a human about — every hour it goes unhandled is an hour of that customer's mail silently not moving.
---
# thread.created
Source: https://docs.aiinbx.com/webhooks/events/thread-created
A new conversation started — by an inbound message, by a send that didn't join an existing thread, or by a [forward](/guides/threads#forwarding).
## Payload
| Prop | Type | Default | Description |
| - | - | - | - |
| `thread_id` | `string` | - | thr_… |
| `subject` | `string` | - | The opening subject. |
| `mailbox` | `string` | - | The address on your side. |
| `forward_of` | `string \| null` | - | When a forward opened the thread: the thread it carries a transcript of. |
## Handling it
Subscribe to this when you want to create a record — a ticket, a CRM entry — once per conversation rather than once per message. Every later message on the conversation reuses this `thread_id`, so the record never needs to be de-duplicated afterwards.
**[Threads](/guides/threads)**
How a reply is matched onto an existing thread instead of opening a new one.
---
# Verifying
Source: https://docs.aiinbx.com/webhooks/verifying
Your webhook URL is a public endpoint. Anyone who learns it can post to it. The signature is what separates an event AI Inbx sent from one somebody made up — verify it on every request, before you parse the body.
## The scheme
Each delivery carries:
```
AIInbx-Signature: t=1756721464,v1=5f2b8c...
```
`t` is the Unix timestamp of the signature. `v1` is `HMAC-SHA256(secret, ".")`, hex-encoded.
Two properties matter:
- **The signed string includes the timestamp**, so an old capture can't be replayed indefinitely. Anything outside a **five-minute** window is rejected by default.
- **It's computed over the raw bytes**, so re-serializing the JSON before verifying will produce a different string and fail. Key order, whitespace, and Unicode escaping all change the bytes.
:::danger[Verify before you parse]
Reach for the raw body first. A framework that has already parsed JSON into an object has thrown away the exact bytes the signature covers — see [Getting the raw body](#getting-the-raw-body).
:::
## TypeScript
`verifyWebhookRequest` takes the `Request` and does everything — raw body, header, timing-safe comparison, replay window:
```ts app/webhooks/aiinbx/route.ts
import { verifyWebhookRequest } from "aiinbx/webhooks"
export async function POST(request: Request) {
let event
try {
event = await verifyWebhookRequest(
request,
process.env.AI_INBX_WEBHOOK_SECRET!
)
} catch {
return new Response("invalid signature", { status: 400 })
}
// `type` is a discriminant — TypeScript narrows `data` to this event's shape.
if (event.type === "email.received") {
console.log(event.data.thread_id, event.data.category)
}
return new Response(null, { status: 204 })
}
```
When your framework hands you the body and header separately, use `verifyWebhook`:
```ts
import { verifyWebhook } from "aiinbx/webhooks"
const event = await verifyWebhook(
rawBody, // string or Uint8Array — exactly as received
request.headers["aiinbx-signature"],
process.env.AI_INBX_WEBHOOK_SECRET!
)
```
Both throw `WebhookSignatureError` on a bad signature, a malformed header, or a timestamp outside the window. Both work anywhere Web Crypto does — Node 20+, Bun, Deno, Cloudflare Workers, Vercel Edge.
## Python
```python
from aiinbx import AIInbx
client = AIInbx()
event = client.webhooks.verify(
request_body, # bytes or str, unmodified
request.headers["AIInbx-Signature"],
webhook_secret,
)
if event["type"] == "email.bounced":
for recipient in event["data"]["recipients"]:
print(recipient)
```
`event` is a discriminated `WebhookEvent` union, so a type checker narrows `data` from `type`.
If your framework gives you a bare digest and the timestamp separately, pass the timestamp explicitly:
```python
event = client.webhooks.verify(body, digest, secret, timestamp=header_timestamp)
```
## Any language
Six steps, no library required:
1. **Read the raw body**
As bytes or a string. Do not parse it yet.
2. **Parse the header**
Split `AIInbx-Signature` on `,`, then each part on the first `=`, giving `t`
and `v1`.
3. **Check the timestamp**
Reject if `|now − t|` exceeds 300 seconds.
4. **Build the signed string**
`"."` — a literal dot between them.
5. **Compute the HMAC**
HMAC-SHA256 with your endpoint secret, hex-encoded.
6. **Compare in constant time**
Use a timing-safe comparison, never `==`.
```go
func verify(body []byte, header, secret string) (bool, error) {
var timestamp, provided string
for _, part := range strings.Split(header, ",") {
key, value, _ := strings.Cut(strings.TrimSpace(part), "=")
switch key {
case "t":
timestamp = value
case "v1":
provided = value
}
}
seconds, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false, err
}
if math.Abs(float64(time.Now().Unix()-seconds)) > 300 {
return false, errors.New("outside the replay window")
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%s.%s", timestamp, body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(provided)), nil
}
```
## Getting the raw body
The usual failure is a framework parsing JSON before you see it. Per framework:
**Next.js (App Router)**
`Request` gives you the raw body directly:
```ts app/webhooks/aiinbx/route.ts
export async function POST(request: Request) {
const event = await verifyWebhookRequest(request, secret)
return new Response(null, { status: 204 })
}
```
**Express**
Mount a raw body parser on the webhook route only:
```ts
app.post(
"/webhooks/aiinbx",
express.raw({ type: "application/json" }),
async (req, res) => {
const event = await verifyWebhook(
req.body, // a Buffer, thanks to express.raw
req.header("AIInbx-Signature")!,
process.env.AI_INBX_WEBHOOK_SECRET!
)
res.status(204).end()
}
)
```
A global `express.json()` mounted before this route will have consumed the stream — order matters.
**FastAPI**
```python
@app.post("/webhooks/aiinbx")
async def webhook(request: Request):
body = await request.body() # raw bytes
event = client.webhooks.verify(
body,
request.headers["aiinbx-signature"],
WEBHOOK_SECRET,
)
return Response(status_code=204)
```
Take `Request` rather than a Pydantic model — a declared body model parses the request before your code runs.
**Django**
```python
@csrf_exempt
def aiinbx_webhook(request):
event = client.webhooks.verify(
request.body, # raw bytes
request.headers["AIInbx-Signature"],
settings.AIINBX_WEBHOOK_SECRET,
)
return HttpResponse(status=204)
```
## Clock skew
The five-minute window assumes your server's clock is roughly right. A host drifting by more than that rejects every event, which looks exactly like a wrong secret. If verification fails across the board and the secret is definitely correct, check NTP before anything else.
Both SDKs let you widen the window, but treat that as a diagnostic rather than a fix:
```ts TypeScript
await verifyWebhookRequest(request, secret, { tolerance: 600 })
```
```python Python
client.webhooks.verify(body, signature, secret, tolerance=600)
```
## After verifying
A valid signature proves the event came from AI Inbx. It doesn't promise you haven't seen it before — retries and manual replays deliver the same event again. Deduplicate on `event.id`:
```ts
const event = await verifyWebhookRequest(request, secret)
// Application-defined: atomically insert into durable storage by event.id.
// An existing event is a successful no-op; a storage failure must throw.
await enqueueOnce(event.id, event)
return new Response(null, { status: 204 })
```
Do not mark an event complete before its work succeeds. A separate “check then insert” can race under concurrent delivery; use a unique constraint or an equivalent atomic queue operation.
The pairing to remember: `event.id` for "have I seen this event", and an [idempotency key](/reference/conventions#idempotency) derived from it for "have I already sent the reply".