---
title: Conventions
description: IDs, pagination, timestamps, request IDs, and idempotency — the rules that hold across every endpoint.
sidebar:
  icon: ruler
---

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.
