Conventions
IDs, pagination, timestamps, request IDs, and idempotency — the rules that hold across every endpoint.
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_ |
|
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 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:
{
"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:
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:
// 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)
}# 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 rather than silently starting from the top.
Spaces
Every resource that can live in a space 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:
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:
const { data, response, requestId } = await aiinbx.emails
.retrieve("eml_...")
.withResponse()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.
await aiinbx.emails.send(payload, { idempotencyKey: "order-8812-receipt" })client.emails.send(payload, idempotency_key="order-8812-receipt")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 |
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:
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 returns415unsupported_media_type. Bodyless operations, such as canceling an email, do not require a JSON body. PATCHis a partial update. Send only the fields you’re changing; at least one is required.- Empty successes return
204with no body — deletions and revocations. - Redirects are real. Attachment downloads answer
307with aLocationheader; 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
issuesarray 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) with a machine-readable code. Full list and handling patterns in Errors.
{
"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:
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 and Retry-After in seconds; both SDKs wait that long and retry on their own. A 429 with 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.