# InboxAgents Developer Docs — full text


---

# Authentication



Every request to the InboxAgents API authenticates with a single **API key**.
There is no OAuth flow: create a key, send it on each request, done.

## Sending your key [#sending-your-key]

Send the key as a bearer credential in the `Authorization` header, or as the
`x-api-key` header — either works, pick one.

<Tabs items="[&#x22;REST&#x22;, &#x22;TypeScript&#x22;, &#x22;Python&#x22;, &#x22;CLI&#x22;]">
  <Tab value="REST">
    ```bash
    # Preferred: bearer header
    curl https://app.theinboxagents.com/api/v1/auth/me \
      -H "Authorization: Bearer $INBOXAGENTS_API_KEY"

    # Alternative: x-api-key header
    curl https://app.theinboxagents.com/api/v1/auth/me \
      -H "x-api-key: $INBOXAGENTS_API_KEY"
    ```
  </Tab>

  <Tab value="TypeScript">
    ```typescript
    await fetch("https://app.theinboxagents.com/api/v1/auth/me", {
      headers: { Authorization: `Bearer ${process.env.INBOXAGENTS_API_KEY}` },
    });
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import os, requests

    requests.get(
        "https://app.theinboxagents.com/api/v1/auth/me",
        headers={"Authorization": f"Bearer {os.environ['INBOXAGENTS_API_KEY']}"},
    )
    ```
  </Tab>

  <Tab value="CLI">
    ```bash
    # The CLI reads INBOXAGENTS_API_KEY, or pass --api-key
    inboxagents whoami
    inboxagents whoami --api-key ia_your_secret_here
    ```
  </Tab>
</Tabs>

<Callout title="Keep your key out of source control">
  The convention throughout these docs, the SDKs, and the CLI is to store the key
  in the `INBOXAGENTS_API_KEY` environment variable and never commit it. Treat the
  secret like a password — anyone holding it can act as your tenant within the
  key's scope.
</Callout>

The **WebSocket** surface is the one exception to the header rule: it takes the
key as a query parameter, since browsers can't set headers on a WebSocket
handshake.

## The key secret [#the-key-secret]

A key's full secret is an `ia_…` value. It is shown **exactly once**, in the
response to creating the key (or to `sign_up`). It is never stored in a
retrievable form and can never be fetched again — so store it the moment you get
it.

Afterward, listings show only a non-secret **prefix** (e.g. `ia_3f9a2b1c`), so
you can tell keys apart without the secret being recoverable.

**Rotation** is create-a-new-key-then-delete-the-old — there is no rotate verb.
**Revocation** is deleting the key; it stops working immediately on every
surface (REST, SDK, CLI, WebSocket).

## Permissions [#permissions]

A key is either **full-access** or restricted to a **whitelist**:

* Create a key with &#x2A;*no `permissions`** and it has **full access**.
* Supply a `permissions` object and the key switches to **whitelist mode** —
  only the flags set to `true` are granted; everything else is denied.

```bash title="A read-only key"
curl -X POST https://app.theinboxagents.com/api/v1/api-keys \
  -H "Authorization: Bearer $INBOXAGENTS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "read-only agent",
    "permissions": {
      "inbox_read": true,
      "thread_read": true,
      "message_read": true
    }
  }'
```

A restricted key **cannot mint a child key** with more permissions or a broader
scope than itself — privilege escalation is rejected with a `403`.

### v1 permission flags [#v1-permission-flags]

Flags are CRUD-per-resource. Attachment reads ride on `message_read`.

| Flag              | Grants                                  |
| ----------------- | --------------------------------------- |
| `inbox_read`      | List and get inboxes                    |
| `inbox_create`    | Create inboxes                          |
| `inbox_update`    | Update inboxes                          |
| `inbox_delete`    | Delete inboxes                          |
| `thread_read`     | List and get threads                    |
| `message_read`    | Get messages and download attachments   |
| `label_spam_read` | Include `spam`-labeled threads in reads |
| `webhook_read`    | List and get webhooks                   |
| `webhook_create`  | Create webhooks                         |
| `webhook_update`  | Update webhooks                         |
| `webhook_delete`  | Delete webhooks                         |
| `metrics_read`    | Read `/metrics/usage`                   |
| `api_key_read`    | List and get API keys                   |
| `api_key_create`  | Create API keys                         |
| `api_key_delete`  | Delete (revoke) API keys                |

## Scoping a key to one inbox [#scoping-a-key-to-one-inbox]

Create a key against a specific inbox
(`POST /v1/inboxes/{inbox_id}/api-keys`) and it is **scoped** to that inbox — it
can only operate within that inbox, no matter what permissions it carries. Use
scoped keys to isolate blast radius: one key per agent or per environment.

If the scoped inbox is later deleted, the key's calls to it return the usual
not-found.

## Checking a key: whoami [#checking-a-key-whoami]

Two endpoints answer "is my key valid, and what can it do?":

* **`GET /v1/whoami`** — the lightweight check: resolves the key to a tenant, or
  returns `401` if it's missing or invalid.
* **`GET /v1/auth/me`** — the fuller identity probe: returns the key's
  `api_key_id`, `tenant_id`, `scope_type` (`organization` or `inbox`),
  `scope_id`, and — for an inbox-scoped key — its `inbox_id`. Identity and scope
  only; it never returns plan or limits (see [`/metrics/usage`](https://docs.theinboxagents.com/docs/metrics/getUsage)
  for usage).

```json title="GET /v1/auth/me — organization-scoped key"
{
  "api_key_id": "key_a1b2c3d4",
  "tenant_id": "b7e3…",
  "scope_type": "organization",
  "scope_id": "b7e3…"
}
```

```json title="GET /v1/auth/me — inbox-scoped key"
{
  "api_key_id": "key_9f8e7d6c",
  "tenant_id": "b7e3…",
  "scope_type": "inbox",
  "scope_id": "inb_8f2a1c9d",
  "inbox_id": "inb_8f2a1c9d"
}
```

## Auth errors [#auth-errors]

Authentication and authorization fail differently, and the status tells you
which:

| Status | Error `name`      | Means                                                                                                               | Fix                                                    |
| ------ | ----------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `401`  | `unauthenticated` | The key is missing, malformed, revoked, or unknown.                                                                 | Send a valid, active key.                              |
| `403`  | `forbidden`       | The key is valid but lacks the required permission, is scoped to a different inbox, or the tenant hit a plan limit. | Grant the permission, use an in-scope key, or upgrade. |

A `401` never leaks whether other keys exist. A `403` is distinct from a `401`
on purpose: it tells you the key is fine but *this action* isn't allowed — a
permissions problem, not a bad key. See [Errors](https://docs.theinboxagents.com/docs/errors) for the full
envelope and error catalog.


---

# Connect an AI assistant (MCP)





Connect InboxAgents to an AI assistant like **Claude** or **ChatGPT** and let it
provision its own inboxes and read the mail that arrives — "make me an inbox and
watch for the verification email", "read the latest thread in inbox
`inb_8f2a1c9d`", "download the attachment from that message" — instead of wiring
up an integration by hand.

<Callout type="info">
  This uses **MCP**, the standard way AI assistants plug into other apps — you
  don't need to understand it to use this. To connect Claude or ChatGPT, follow
  **The simple way** below. The **Developer setup** section is for wiring
  InboxAgents into coding tools.
</Callout>

## Fastest path: install the Skill [#fastest-path-install-the-skill]

If you use a coding agent (**Claude Code**, **Cursor**, or **Codex**), install
the InboxAgents Skill with one command:

```bash
npx skills add inboxagents
```

It preconfigures this MCP connection and teaches your agent the **provision →
receive → read** loop, so it can create an inbox and act on incoming mail with
no further setup. Set your key (below) and you're done. The manual add in
[Developer setup](#developer-setup) is the fallback for platforms without Skill
support — nothing is lost.

## The simple way (point-and-click) [#the-simple-way-point-and-click]

Best for **Claude Desktop**, **claude.ai**, and **ChatGPT** — no files, no
commands.

<Callout type="info">
  **No InboxAgents account yet?** Sign up at
  [app.theinboxagents.com](https://app.theinboxagents.com) and create an API key
  first — or, if you use a code-capable assistant like Claude Code, have it sign
  you up in code (see [the quickstart](https://docs.theinboxagents.com/docs/quickstart)). Then come back and
  connect.
</Callout>

The server address is:

```
https://mcp.theinboxagents.com/mcp
```

Point-and-click clients authenticate by passing your InboxAgents API key as an
`x-api-key` header when they add the connector.

### Claude Desktop or claude.ai [#claude-desktop-or-claudeai]

1. Open **Settings → Connectors → Add custom connector**.
2. Paste the InboxAgents server address `https://mcp.theinboxagents.com/mcp`.
3. Add an `x-api-key` header set to your InboxAgents API key (an `ia_…` secret).
4. Click **Add**, then **Connect** — Claude discovers the tools and can start
   working your inboxes.

### ChatGPT [#chatgpt]

Remote connectors need a **paid ChatGPT plan** with **Developer mode** turned on
(if you want the easiest route, Claude Desktop is simpler):

1. **Settings → Apps & Connectors → Advanced settings** → turn on **Developer
   mode**.
2. **Settings → Connectors → Create**. Name it *InboxAgents* and set the
   **Connector URL** to `https://mcp.theinboxagents.com/mcp`.
3. Add your API key as an `x-api-key` header, then enable the connector in a
   conversation.

## What the assistant can do [#what-the-assistant-can-do]

Once connected, the assistant acts with **exactly** the scope and permissions of
the API key you gave it — nothing more. Within that scope it can:

* **Provision inboxes** — create, list, inspect, and delete inboxes (`inbox_create`
  and `inbox_delete` permissions gate the create/delete tools).
* **Read mail** — list threads, read a full thread or a single message, and fetch
  a presigned download link for an attachment.
* **Check identity and usage** — confirm what its key can see (`whoami`) and read
  the tenant's usage totals (`get_usage`, requires `metrics_read`).

Sending, replying, and webhook/key management are **not** available over MCP in
v1 — manage those through the [REST API](https://docs.theinboxagents.com/docs/quickstart) or the dashboard. See
every tool's fields in the [API reference](https://docs.theinboxagents.com/docs/inboxes/createInbox).

<Callout type="warn">
  A key with `inbox_delete` lets the assistant **permanently delete inboxes and
  their stored mail**. Scope keys tightly — issue a read-only or single-inbox key
  when the agent doesn't need to provision. You can revoke any key at any time
  (see [Access & revoking](#access--revoking)).
</Callout>

## Developer setup [#developer-setup]

For coding tools. The server is a remote **Streamable HTTP** MCP endpoint:

```
https://mcp.theinboxagents.com/mcp
```

Authenticate with your InboxAgents API key, passed either as an **`x-api-key`
header*&#x2A; or as an **`?apiKey=` query parameter** on the URL. There is no OAuth
dance in v1 — a single API key is all a client needs.

### Claude Code [#claude-code]

Add the server with your key as a header:

```bash
claude mcp add --transport http inboxagents https://mcp.theinboxagents.com/mcp \
  --header "x-api-key: YOUR_INBOXAGENTS_API_KEY"
```

Add `--scope project` to share it with your team via a checked-in `.mcp.json`,
or `--scope user` to use it across all your projects (the default is this
project only). Run `/mcp` inside a session to confirm the tools loaded.

### Cursor [#cursor]

Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (this project). Use
`${env:VAR}` so the key isn't hardcoded:

```json
{
  "mcpServers": {
    "inboxagents": {
      "url": "https://mcp.theinboxagents.com/mcp",
      "headers": { "x-api-key": "${env:INBOXAGENTS_API_KEY}" }
    }
  }
}
```

Or one-click:
[**Add to Cursor**](cursor://anysphere.cursor-deeplink/mcp/install?name=InboxAgents\&config=eyJ1cmwiOiJodHRwczovL21jcC50aGVpbmJveGFnZW50cy5jb20vbWNwIn0=)
— the deeplink adds the server; set your `x-api-key` header afterward.

### VS Code [#vs-code]

Add to `.vscode/mcp.json` (workspace). Note VS Code uses `servers` (not
`mcpServers`) and requires `"type": "http"`. Prompt for the key securely with an
input so it's never stored in plain text:

```json
{
  "inputs": [
    {
      "type": "promptString",
      "id": "inboxagents-api-key",
      "description": "InboxAgents API key",
      "password": true
    }
  ],
  "servers": {
    "inboxagents": {
      "type": "http",
      "url": "https://mcp.theinboxagents.com/mcp",
      "headers": { "x-api-key": "${input:inboxagents-api-key}" }
    }
  }
}
```

Or run **MCP: Add Server** from the Command Palette and follow the guided flow.

### Query-param auth [#query-param-auth]

Any client that can't set custom headers can carry the key in the URL instead:

```
https://mcp.theinboxagents.com/mcp?apiKey=YOUR_INBOXAGENTS_API_KEY
```

Prefer the `x-api-key` header where you can — a URL is more likely to end up in
logs. Never commit either form; read the key from an environment variable or a
secret input.

### Other clients [#other-clients]

Any client that supports **remote Streamable HTTP** MCP servers works — point it
at `https://mcp.theinboxagents.com/mcp` and authenticate with an `x-api-key`
header or an `?apiKey=` query parameter.

## Minimum client requirements [#minimum-client-requirements]

The server speaks **Streamable HTTP** only. A client must support remote
Streamable HTTP MCP servers to connect. Clients that only support the older
stdio or SSE transports can't connect — there is **no silent partial
connection**; a missing or invalid key is rejected outright with a clear auth
error and no tools are exposed. Because the server is **hosted**, there's nothing
to install or run locally, and the tool set updates centrally.

## Access & revoking [#access--revoking]

* **Scope** — every connection acts with **exactly** the connecting key's scope
  and permissions, and only ever sees its own tenant's data. An inbox-scoped key
  sees just that one inbox.
* **Revoking** — disconnect in your client (remove the connector in a GUI client,
  or drop the server from your MCP config for Claude Code / Cursor / VS Code), or
  **delete the API key** in the dashboard under **Settings → API keys**. Deleting
  a key stops it working immediately on every surface (REST, SDK, CLI, WebSocket,
  and MCP). There is no separate rotate step — create a new key, then delete the
  old one.

## Troubleshooting [#troubleshooting]

* **Tools don't appear / connection failed** — the API key is missing or invalid.
  Check the `x-api-key` header (or `?apiKey=` query param) carries a current
  `ia_…` key; a rejected key exposes no tools.
* **A tool returns `forbidden`** — the key is valid but lacks that tool's
  permission (e.g. `inbox_create`) or is scoped to a different inbox. Grant the
  permission or use an in-scope key.
* **A read returns nothing / not-found** — the id is unknown or out of the key's
  scope (the two are indistinguishable on purpose — no cross-tenant leakage), or
  the inbox is simply empty (an empty result, not an error).
* **Spam or unauthenticated mail is missing** — it's hidden by default. Pass the
  `include_spam` / `include_unauthenticated` flag; reading spam also requires the
  key's `label_spam_read` permission.
* **Config-file changes don't take effect** — restart the client. Claude Desktop
  and VS Code re-read MCP config on restart.
* **Client can't connect at all** — confirm it supports remote Streamable HTTP
  MCP servers (see [Minimum client requirements](#minimum-client-requirements)).

Prefer raw HTTP? Everything here is also available over the
[REST API](https://docs.theinboxagents.com/docs/quickstart) with the same API key.


---

# Errors



Every error the API returns uses **one envelope**, so your error-handling code
is written once and works everywhere.

## The envelope [#the-envelope]

```json
{
  "name": "not_found",
  "message": "Inbox not found."
}
```

* **`name`** is the stable, machine-readable identifier for the condition.
  Branch on this — it never changes for a given condition.
* **`message`** is a human-readable description. Show it; don't parse it.

The HTTP status also carries meaning (see the table below), but `name` is the
canonical thing to switch on.

## Validation errors [#validation-errors]

Validation failures (`422`) use a slightly richer envelope: `name` is always
`validation`, and an `errors` object carries per-field detail keyed by field
name.

```json
{
  "name": "validation",
  "errors": {
    "human_email": "must be a valid email address"
  }
}
```

## Error catalog [#error-catalog]

| Status | `name`                | When                                                                                                                                             |
| ------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `401`  | `unauthenticated`     | The API key is missing, malformed, revoked, or unknown.                                                                                          |
| `403`  | `forbidden`           | Valid key, but it lacks the required permission or is scoped to a different inbox.                                                               |
| `403`  | `read_only`           | The tenant's subscription is canceled — reads still work, writes are blocked.                                                                    |
| `403`  | `cap_hit`             | A plan limit was reached (inbox count, monthly email volume, storage, or webhook count).                                                         |
| `404`  | `not_found`           | The resource doesn't exist — **or belongs to another tenant**. Unknown and cross-tenant ids are indistinguishable, so existence is never leaked. |
| `409`  | `conflict`            | The request conflicts with current state (e.g. a non-idempotent duplicate).                                                                      |
| `409`  | `address_unavailable` | The requested inbox username is taken, reserved, or still quarantined after a delete. Choose another.                                            |
| `422`  | `validation`          | The request body or query parameters failed validation. Carries per-field `errors`.                                                              |
| `429`  | `rate_limited`        | The key exceeded its rate limit. Carries a `Retry-After` header. See [Rate limits](https://docs.theinboxagents.com/docs/rate-limits).                                           |

## Not-found never leaks existence [#not-found-never-leaks-existence]

An unknown id and an id that belongs to **another tenant** both return the same
`404 not_found`. There is no way to distinguish "doesn't exist" from "exists but
isn't yours" — so the API never leaks whether a resource exists in someone
else's account.

## Handling errors [#handling-errors]

Branch on `name`, and treat `429` specially by honoring `Retry-After`:

<Tabs items="[&#x22;TypeScript&#x22;, &#x22;Python&#x22;]">
  <Tab value="TypeScript">
    ```typescript
    const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });

    if (!res.ok) {
      const err = await res.json(); // { name, message } or { name: "validation", errors }
      switch (err.name) {
        case "unauthenticated":
          throw new Error("Bad or missing API key");
        case "forbidden":
          throw new Error("Key lacks permission or scope");
        case "not_found":
          return null; // unknown or not yours — treat the same
        case "rate_limited": {
          const retryAfter = Number(res.headers.get("Retry-After")) || 1;
          await new Promise((r) => setTimeout(r, retryAfter * 1000));
          // …retry
          break;
        }
        default:
          throw new Error(err.message);
      }
    }
    ```
  </Tab>

  <Tab value="Python">
    ```python
    res = requests.get(url, headers={"Authorization": f"Bearer {key}"})

    if not res.ok:
        err = res.json()  # {"name", "message"} or {"name": "validation", "errors"}
        name = err["name"]
        if name == "unauthenticated":
            raise RuntimeError("Bad or missing API key")
        if name == "forbidden":
            raise RuntimeError("Key lacks permission or scope")
        if name == "not_found":
            return None  # unknown or not yours — treat the same
        if name == "rate_limited":
            retry_after = int(res.headers.get("Retry-After", "1"))
            time.sleep(retry_after)
            # …retry
        else:
            raise RuntimeError(err["message"])
    ```
  </Tab>
</Tabs>

The typed SDKs surface these as exceptions carrying the same `name`, and the CLI
prints the same envelope with `--format json` — so error-handling logic ports
one-to-one across every surface.


---

# Introduction



InboxAgents gives your AI agents their own email inboxes. Create a live,
addressable inbox in a single API call, then receive and read the mail that
lands in it — over a small, predictable REST API authenticated by one API key.

The v1 API is **receive-focused**: provision inboxes, then read the threads,
messages, and attachments that arrive. Sending is not part of v1.

## Start here [#start-here]

<Cards>
  <Card title="Quickstart" href="/docs/quickstart" description="Provision an inbox, send it a test email, and read the message — end to end, in REST, TypeScript, Python, and the CLI." />

  <Card title="Connect an AI assistant" href="/docs/connect-ai-assistant" description="Give Claude, ChatGPT, or a coding agent its own inboxes over MCP — point-and-click or one-line install." />

  <Card title="Authentication" href="/docs/authentication" description="API keys, permissions and scopes, and the whoami identity check." />

  <Card title="API reference" href="/docs/inboxes/createInbox" description="Every endpoint, generated from the OpenAPI contract so it never drifts from the live API." />
</Cards>

## How it works [#how-it-works]

* **Inboxes** are the core primitive — a live email identity you create, list,
  read, update, and delete. New inboxes get an address on the shared
  `agents.theinboxagents.com` domain.
* **Threads, messages, and attachments** are read as subresources of the inbox
  that owns them (`/v1/inboxes/{inbox_id}/threads/…`).
* **Every request** authenticates with a single API key. There is no OAuth
  dance for the REST API.
* **Real-time delivery** happens two ways: register a **webhook** to be
  notified when mail arrives, or hold a **WebSocket** for a live stream.

## Conventions at a glance [#conventions-at-a-glance]

* **Base URL** — `https://app.theinboxagents.com/api/v1`. The API is versioned
  at `/v1`.
* **Auth** — send your key as `Authorization: Bearer <key>` (or the
  `x-api-key` header). See [Authentication](https://docs.theinboxagents.com/docs/authentication).
* **JSON everywhere** — request and response bodies are JSON; timestamps are
  ISO-8601 UTC.
* **Opaque, typed ids** — ids carry a short prefix so their type is obvious:
  `inb_` (inbox), `thr_` (thread), `msg_` (message), `att_` (attachment),
  `key_` (API key), `ia_` (the API key secret itself).
* **Consistent errors** — every error is a `{ name, message }` envelope with a
  stable `name`. See [Errors](https://docs.theinboxagents.com/docs/errors).
* **Cursor pagination** — list endpoints page with `page_token`. See
  [Pagination](https://docs.theinboxagents.com/docs/pagination).
* **Rate limits** — per-key limits return `429` with `Retry-After`. See
  [Rate limits](https://docs.theinboxagents.com/docs/rate-limits).

## For coding agents [#for-coding-agents]

The docs are published in agent-readable form: an [`/llms.txt`](https://docs.theinboxagents.com/llms.txt)
index, a full [`/llms-full.txt`](https://docs.theinboxagents.com/llms-full.txt) concatenation, and clean
Markdown per page. Point your agent at those to ingest the docs cheaply.


---

# Pagination



Every list endpoint paginates with an **opaque cursor**, and they all share one
envelope shape — so pagination code is written once and reused across inboxes,
threads, and API keys.

## Request parameters [#request-parameters]

| Parameter    | Type    | Default | Notes                                                                  |
| ------------ | ------- | ------- | ---------------------------------------------------------------------- |
| `limit`      | integer | `50`    | Page size, 1–100.                                                      |
| `page_token` | string  | —       | The `next_page_token` from the previous page. Omit for the first page. |
| `ascending`  | boolean | `false` | Sort oldest-first when `true`. Default is newest-first.                |

## Response envelope [#response-envelope]

Every list response carries the same three pagination fields, plus a typed array
named for the resource (`inboxes`, `threads`, `api_keys`, …):

| Field             | Type           | Notes                                                      |
| ----------------- | -------------- | ---------------------------------------------------------- |
| `count`           | integer        | Number of items in **this** page.                          |
| `limit`           | integer        | The page size that was applied.                            |
| `next_page_token` | string \| null | Token for the next page, or `null` when there are no more. |
| `<resource>`      | array          | The page of items — e.g. `inboxes`.                        |

```json title="GET /v1/inboxes?limit=2"
{
  "count": 2,
  "limit": 2,
  "next_page_token": "eyJvZmZzZXQiOjJ9",
  "inboxes": [
    { "inbox_id": "inb_8f2a1c9d", "email": "brave-otter@agents.theinboxagents.com" },
    { "inbox_id": "inb_2a7b4e11", "email": "keen-finch@agents.theinboxagents.com" }
  ]
}
```

## Ordering [#ordering]

Lists come back **newest-first** by default (`created_at` / `last_activity_at`
descending). Pass `ascending=true` for oldest-first. Search-style listings cap
`limit` at **100**.

## Iterating every page [#iterating-every-page]

Follow `next_page_token` until it comes back `null`:

<Tabs items="[&#x22;REST&#x22;, &#x22;TypeScript&#x22;, &#x22;Python&#x22;, &#x22;CLI&#x22;]">
  <Tab value="REST">
    ```bash
    # First page
    curl "https://app.theinboxagents.com/api/v1/inboxes?limit=50" \
      -H "Authorization: Bearer $INBOXAGENTS_API_KEY"

    # Next page — pass the previous response's next_page_token
    curl "https://app.theinboxagents.com/api/v1/inboxes?limit=50&page_token=eyJvZmZzZXQiOjUwfQ" \
      -H "Authorization: Bearer $INBOXAGENTS_API_KEY"
    ```
  </Tab>

  <Tab value="TypeScript">
    ```typescript
    async function* allInboxes() {
      let pageToken: string | undefined;
      do {
        const url = new URL("https://app.theinboxagents.com/api/v1/inboxes");
        url.searchParams.set("limit", "50");
        if (pageToken) url.searchParams.set("page_token", pageToken);

        const page = await fetch(url, {
          headers: { Authorization: `Bearer ${process.env.INBOXAGENTS_API_KEY}` },
        }).then((r) => r.json());

        yield* page.inboxes;
        pageToken = page.next_page_token ?? undefined;
      } while (pageToken);
    }

    for await (const inbox of allInboxes()) {
      console.log(inbox.email);
    }
    ```
  </Tab>

  <Tab value="Python">
    ```python
    def all_inboxes():
        base = "https://app.theinboxagents.com/api/v1"
        auth = {"Authorization": f"Bearer {os.environ['INBOXAGENTS_API_KEY']}"}
        page_token = None
        while True:
            params = {"limit": 50}
            if page_token:
                params["page_token"] = page_token
            page = requests.get(f"{base}/inboxes", headers=auth, params=params).json()
            yield from page["inboxes"]
            page_token = page.get("next_page_token")
            if not page_token:
                break

    for inbox in all_inboxes():
        print(inbox["email"])
    ```
  </Tab>

  <Tab value="CLI">
    ```bash
    # The CLI handles paging for you when listing
    inboxagents inboxes list --limit 50
    ```
  </Tab>
</Tabs>

The typed SDKs wrap this loop in a pagination helper, so you can iterate results
directly without ever handling a `page_token` yourself.


---

# Quickstart



This quickstart walks the full **provision → receive → read** loop end to end:

1. **Provision** — create an inbox and get a live email address.
2. **Receive** — send a real email to that address and be notified it arrived.
3. **Read** — fetch the thread and read the message.

Every step is shown in &#x2A;*REST (cURL)**, **TypeScript**, **Python**, and the
**CLI** — pick your surface. The whole loop takes a couple of minutes.

## Before you start [#before-you-start]

You need an API key. If you already have one (from onboarding or the
dashboard), skip ahead. Otherwise, provision an account entirely in code with
the two-step signup flow.

<Steps>
  <Step>
    ### Sign up [#sign-up]

    `sign_up` creates a tenant, a first inbox, and a first API key, then emails a
    6-digit code to the address you give it. It is the one unauthenticated
    endpoint — you use it to obtain your first key.

    <Tabs items="[&#x22;REST&#x22;, &#x22;TypeScript&#x22;, &#x22;Python&#x22;, &#x22;CLI&#x22;]">
      <Tab value="REST">
        ```bash
        curl -X POST https://app.theinboxagents.com/api/v1/sign_up \
          -H "Content-Type: application/json" \
          -d '{ "human_email": "you@example.com" }'
        ```
      </Tab>

      <Tab value="TypeScript">
        ```typescript
        const res = await fetch("https://app.theinboxagents.com/api/v1/sign_up", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ human_email: "you@example.com" }),
        });
        const { tenant_id, inbox_id, api_key } = await res.json();
        // Store api_key now — it is shown once and never retrievable again.
        ```
      </Tab>

      <Tab value="Python">
        ```python
        import requests

        res = requests.post(
            "https://app.theinboxagents.com/api/v1/sign_up",
            json={"human_email": "you@example.com"},
        )
        data = res.json()
        # data["api_key"] is shown once — store it now.
        ```
      </Tab>

      <Tab value="CLI">
        ```bash
        inboxagents agent sign-up --human-email you@example.com
        ```
      </Tab>
    </Tabs>

    The response carries a one-time `api_key` (an `ia_…` secret). **Store it now** —
    it is never returned again.
  </Step>

  <Step>
    ### Verify [#verify]

    Enter the 6-digit code from the email to activate the account and lift it off
    the restricted new-account limits.

    <Tabs items="[&#x22;REST&#x22;, &#x22;TypeScript&#x22;, &#x22;Python&#x22;, &#x22;CLI&#x22;]">
      <Tab value="REST">
        ```bash
        curl -X POST https://app.theinboxagents.com/api/v1/verify \
          -H "Content-Type: application/json" \
          -d '{ "otp_code": "123456" }'
        ```
      </Tab>

      <Tab value="TypeScript">
        ```typescript
        await fetch("https://app.theinboxagents.com/api/v1/verify", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ otp_code: "123456" }),
        });
        ```
      </Tab>

      <Tab value="Python">
        ```python
        requests.post(
            "https://app.theinboxagents.com/api/v1/verify",
            json={"otp_code": "123456"},
        )
        ```
      </Tab>

      <Tab value="CLI">
        ```bash
        inboxagents agent verify --otp-code 123456
        ```
      </Tab>
    </Tabs>
  </Step>
</Steps>

<Callout title="Store your key in an environment variable">
  Every example below reads the key from `INBOXAGENTS_API_KEY`. Export it once and
  never commit it:

  ```bash
  export INBOXAGENTS_API_KEY="ia_your_secret_here"
  ```
</Callout>

## 1. Provision an inbox [#1-provision-an-inbox]

Create an inbox. Every field is optional — omit `username` and you get a
readable random address on `agents.theinboxagents.com`. The response includes
the live `email` and the inbox's `inbox_id` (an `inb_…` id) — you'll need the
id to read mail later.

<Tabs items="[&#x22;REST&#x22;, &#x22;TypeScript&#x22;, &#x22;Python&#x22;, &#x22;CLI&#x22;]">
  <Tab value="REST">
    ```bash
    curl -X POST https://app.theinboxagents.com/api/v1/inboxes \
      -H "Authorization: Bearer $INBOXAGENTS_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "display_name": "Support Bot" }'
    ```
  </Tab>

  <Tab value="TypeScript">
    ```typescript
    const key = process.env.INBOXAGENTS_API_KEY!;
    const base = "https://app.theinboxagents.com/api/v1";

    const res = await fetch(`${base}/inboxes`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${key}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ display_name: "Support Bot" }),
    });
    const inbox = await res.json();
    console.log(inbox.email); // e.g. brave-otter@agents.theinboxagents.com
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import os, requests

    key = os.environ["INBOXAGENTS_API_KEY"]
    base = "https://app.theinboxagents.com/api/v1"
    auth = {"Authorization": f"Bearer {key}"}

    inbox = requests.post(
        f"{base}/inboxes",
        headers=auth,
        json={"display_name": "Support Bot"},
    ).json()
    print(inbox["email"])  # e.g. brave-otter@agents.theinboxagents.com
    ```
  </Tab>

  <Tab value="CLI">
    ```bash
    inboxagents inboxes create --display-name "Support Bot"
    ```
  </Tab>
</Tabs>

```json title="Response"
{
  "inbox_id": "inb_8f2a1c9d",
  "email": "brave-otter@agents.theinboxagents.com",
  "display_name": "Support Bot",
  "client_id": null,
  "metadata": {},
  "created_at": "2026-07-06T12:00:00Z",
  "updated_at": "2026-07-06T12:00:00Z"
}
```

## 2. Receive an email [#2-receive-an-email]

Send a real email to the inbox's address from any mail client — subject and
body of your choosing. InboxAgents receives it, threads it, and stores the
message.

To be **notified** the moment it arrives, use one of two real-time surfaces:

* **Webhook** — register a URL and get a signed `POST` when a message lands.
* **WebSocket** — hold a live connection and stream arrivals.

Register a webhook so your agent is pushed each new message:

<Tabs items="[&#x22;REST&#x22;, &#x22;TypeScript&#x22;, &#x22;Python&#x22;, &#x22;CLI&#x22;]">
  <Tab value="REST">
    ```bash
    curl -X POST https://app.theinboxagents.com/api/v1/webhooks \
      -H "Authorization: Bearer $INBOXAGENTS_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "url": "https://your-app.example.com/inboxagents/webhook" }'
    ```
  </Tab>

  <Tab value="TypeScript">
    ```typescript
    await fetch(`${base}/webhooks`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${key}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ url: "https://your-app.example.com/inboxagents/webhook" }),
    });
    ```
  </Tab>

  <Tab value="Python">
    ```python
    requests.post(
        f"{base}/webhooks",
        headers=auth,
        json={"url": "https://your-app.example.com/inboxagents/webhook"},
    )
    ```
  </Tab>

  <Tab value="CLI">
    ```bash
    inboxagents webhooks create --url https://your-app.example.com/inboxagents/webhook
    ```
  </Tab>
</Tabs>

When a message arrives, your endpoint receives a signed event carrying the
`inbox_id`, `thread_id`, and `message_id`. Verify the signature (the webhook
signing secret is a `whsec_…` value returned when you create the webhook), then
use those ids to read the message in step 3.

<Callout>
  No webhook endpoint handy for testing? Skip the notification and just poll —
  list the inbox's threads (step 3) a few seconds after sending. The message is
  already stored; the webhook only tells you *when* it landed.
</Callout>

## 3. Read the message [#3-read-the-message]

List the inbox's threads (newest first), then fetch the message to read its
content and headers. Threads, messages, and attachments are all addressed as
subresources of the inbox that owns them.

<Tabs items="[&#x22;REST&#x22;, &#x22;TypeScript&#x22;, &#x22;Python&#x22;, &#x22;CLI&#x22;]">
  <Tab value="REST">
    ```bash
    # List threads in the inbox
    curl https://app.theinboxagents.com/api/v1/inboxes/inb_8f2a1c9d/threads \
      -H "Authorization: Bearer $INBOXAGENTS_API_KEY"

    # Read a single message
    curl https://app.theinboxagents.com/api/v1/inboxes/inb_8f2a1c9d/messages/msg_5b3e7a10 \
      -H "Authorization: Bearer $INBOXAGENTS_API_KEY"
    ```
  </Tab>

  <Tab value="TypeScript">
    ```typescript
    const inboxId = "inb_8f2a1c9d";

    // List threads (newest first)
    const threads = await fetch(`${base}/inboxes/${inboxId}/threads`, {
      headers: { Authorization: `Bearer ${key}` },
    }).then((r) => r.json());

    const latest = threads.threads[0];

    // Read the message
    const message = await fetch(
      `${base}/inboxes/${inboxId}/messages/${latest.last_message_id}`,
      { headers: { Authorization: `Bearer ${key}` } },
    ).then((r) => r.json());

    console.log(message.subject, message.text);
    ```
  </Tab>

  <Tab value="Python">
    ```python
    inbox_id = "inb_8f2a1c9d"

    # List threads (newest first)
    threads = requests.get(
        f"{base}/inboxes/{inbox_id}/threads", headers=auth
    ).json()

    latest = threads["threads"][0]

    # Read the message
    message = requests.get(
        f"{base}/inboxes/{inbox_id}/messages/{latest['last_message_id']}",
        headers=auth,
    ).json()

    print(message["subject"], message["text"])
    ```
  </Tab>

  <Tab value="CLI">
    ```bash
    # List threads
    inboxagents inboxes:threads list --inbox-id inb_8f2a1c9d

    # Read a message
    inboxagents inboxes:messages get \
      --inbox-id inb_8f2a1c9d --message-id msg_5b3e7a10
    ```
  </Tab>
</Tabs>

That's the whole loop — you provisioned an inbox, received a real email, and
read it back. If the message has attachments, fetch each one's bytes from the
[attachment download endpoint](https://docs.theinboxagents.com/docs/messages/getAttachment).

## Where to next [#where-to-next]

<Cards>
  <Card title="Authentication" href="/docs/authentication" description="Scope and permission your keys, and check identity with whoami." />

  <Card title="API reference" href="/docs/inboxes/createInbox" description="Every endpoint and field, generated from the OpenAPI contract." />

  <Card title="Errors" href="/docs/errors" description="The one error envelope and every stable error name." />
</Cards>


---

# Rate limits



Requests are rate-limited **per API key**. Exceeding the limit returns
`429 Too Many Requests` with a `Retry-After` header telling you exactly how long
to wait.

## The limit [#the-limit]

| Limit                  | Value                                          |
| ---------------------- | ---------------------------------------------- |
| Per-key request rate   | **600 requests / minute** (10 req/s sustained) |
| Window                 | Fixed one-minute window                        |
| Response when exceeded | `429` + `Retry-After`                          |

The limit is per key, so isolating agents or environments onto separate keys
gives each its own budget.

<Callout>
  Request rate limits are separate from **plan limits** on email volume and
  storage — those are per-tenant and return a `403 cap_hit`, not a `429`. See
  [Errors](https://docs.theinboxagents.com/docs/errors).
</Callout>

## The 429 response [#the-429-response]

When you go over, the request has **no side effects** — nothing is partially
performed — and the response is the standard error envelope plus a `Retry-After`
header:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 12
Content-Type: application/json

{
  "name": "rate_limited",
  "message": "Rate limit exceeded. Retry after 12 seconds."
}
```

`Retry-After` is the number of **seconds** until the current window resets.

## Backing off correctly [#backing-off-correctly]

Honor `Retry-After` when it's present, and otherwise back off exponentially with
jitter. Cap the number of retries so a persistent `429` surfaces as an error
rather than hanging.

<Tabs items="[&#x22;TypeScript&#x22;, &#x22;Python&#x22;]">
  <Tab value="TypeScript">
    ```typescript
    async function withRetry(request: () => Promise<Response>, maxRetries = 5) {
      for (let attempt = 0; attempt <= maxRetries; attempt++) {
        const res = await request();
        if (res.status !== 429) return res;

        // Prefer the server's Retry-After; fall back to exponential backoff.
        const retryAfter = Number(res.headers.get("Retry-After"));
        const backoff = Number.isFinite(retryAfter)
          ? retryAfter * 1000
          : Math.min(2 ** attempt * 1000, 30_000) + Math.random() * 250;

        await new Promise((r) => setTimeout(r, backoff));
      }
      throw new Error("Rate limited: retries exhausted");
    }
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import random, time

    def with_retry(request, max_retries=5):
        for attempt in range(max_retries + 1):
            res = request()
            if res.status_code != 429:
                return res

            # Prefer the server's Retry-After; fall back to exponential backoff.
            retry_after = res.headers.get("Retry-After")
            if retry_after is not None:
                backoff = int(retry_after)
            else:
                backoff = min(2 ** attempt, 30) + random.random() * 0.25

            time.sleep(backoff)
        raise RuntimeError("Rate limited: retries exhausted")
    ```
  </Tab>
</Tabs>

The typed SDKs can do this for you — they expose the `Retry-After` value on the
rate-limit error and optionally auto-retry with backoff.


---

# Resolve the authenticated tenant

Echoes the tenant the presented API key resolves to. Useful as an
"is my key valid?" check. Returns `401` if the key is missing/invalid.


API reference: https://docs.theinboxagents.com/docs/diagnostics/whoami

---

# Identity and scope of the calling key

Return the calling API key's **identity and scope** — the canonical
"is my key valid, and what can it see?" probe. Returns identity/scope
only; it never returns plan or limits (see `/metrics/usage` for usage).

`scope_type` is `organization` for a key that reaches the whole tenant,
or `inbox` for a key pinned to a single inbox. `scope_id` is the id that
scope resolves to — the tenant for an organization key, the inbox for an
inbox key. `inbox_id` is present only for an inbox-scoped key.


API reference: https://docs.theinboxagents.com/docs/auth/whoAmI

---

# Cumulative usage time-series

Return usage as a **cumulative time-series**: for each requested
`usage_types` dimension, a list of `{ timestamp, value }` points where
`value` is the **running total** at that bucket boundary. Counts
(`inbox_count`, `message_count`, `thread_count`) accumulate over the
window; `storage_bytes` reports the current live counter at each point.

The window is `start`–`end` (ISO-8601, at most 90 days apart), bucketed by
`period` seconds. The number of buckets — `(end − start) ÷ period` — may
not exceed 500; use a larger `period` for a longer window. `limit` takes
at most that many buckets from the tail of the window, and `descending`
returns the newest bucket first.

Requires the key's `metrics_read` permission; a key without it gets `403`.


API reference: https://docs.theinboxagents.com/docs/metrics/getUsage

---

# Sign up an agent and get a key + inbox

Provision a brand-new account entirely in code: this creates a tenant, a
first inbox, and a first API key, then emails a 6-digit verification code
to `human_email`. Call `verify` with that code to activate the account.

This is the one unauthenticated endpoint — you use it to obtain your
first API key, so there is no key to present yet.

The `api_key` in the response is shown **once** and is never retrievable
again — store it securely. Calling `sign_up` again with the same
`human_email` is safe and idempotent: it returns the **same** account with
a freshly **rotated** `api_key` (the previous key stops working) and
re-sends the verification code if the previous one has expired — it never
creates a duplicate account.


API reference: https://docs.theinboxagents.com/docs/onboarding/signUp

---

# Verify an agent account with its OTP

Activate an account created by `sign_up` by submitting the 6-digit code
emailed to its `human_email`. On success the account is lifted off the
restricted new-account limits to its full plan.

The code expires 10 minutes after it is issued and allows 5 attempts. A
wrong or expired code fails verification; obtain a fresh code by calling
`sign_up` again with the same `human_email`.

Unauthenticated — the account is verified by possession of the code, not
by an API key.


API reference: https://docs.theinboxagents.com/docs/onboarding/verify

---

# List inboxes

List the inboxes belonging to the authenticated tenant, newest-first by
default, with cursor pagination. An account with no inboxes gets an empty
list, not an error.


API reference: https://docs.theinboxagents.com/docs/inboxes/listInboxes

---

# Create an inbox

Create an inbox and get back a live, addressable email identity. Every
field is optional: omit `username` and a readable random address is
generated on the shared domain. Supply a `client_id` for idempotency —
repeating a create with the same `client_id` returns the existing inbox
(`200`) instead of creating a duplicate.


API reference: https://docs.theinboxagents.com/docs/inboxes/createInbox

---

# Get an inbox

Fetch a single inbox by id. An unknown id and an id belonging to another
tenant both return the same not-found error — existence is never leaked.


API reference: https://docs.theinboxagents.com/docs/inboxes/getInbox

---

# Update an inbox

Partially update an inbox's `display_name` and/or `metadata`. At least
one field must be supplied. Metadata merges: supplied keys are set, a key
with a `null` value is removed, and `metadata: null` clears all metadata.
The `email` address is immutable.


API reference: https://docs.theinboxagents.com/docs/inboxes/updateInbox

---

# Delete an inbox

Hard-delete an inbox and its stored mail; the address is quarantined for
a cooling-off period before it can be reused. An unknown, already-deleted,
or cross-tenant id returns not-found — a delete is never silently
successful on a missing inbox.


API reference: https://docs.theinboxagents.com/docs/inboxes/deleteInbox

---

# List API keys

List the tenant's API keys, newest-first by default, with cursor
pagination. Each entry carries the name, non-secret prefix, permissions,
scope, and created/last-used timestamps — **never the secret**, which is
shown only once at creation.


API reference: https://docs.theinboxagents.com/docs/api-keys/listApiKeys

---

# Create an API key

Create a named, organization-scoped API key. With no `permissions` the
key has full access; supply a `permissions` object to restrict it to a
whitelist (only flags set `true` are granted). The raw `secret` is
returned **once** in this response and never again — store it now. A
restricted key that tries to mint a child with more permissions than
itself is forbidden, and the per-tenant key limit is enforced.


API reference: https://docs.theinboxagents.com/docs/api-keys/createApiKey

---

# Revoke an API key

Revoke (hard-delete) an API key by id. The revoke is effective
immediately on every surface — the key stops authenticating at once. An
unknown, already-revoked, or cross-tenant id returns not-found; a revoke
is never silently successful on a missing key.


API reference: https://docs.theinboxagents.com/docs/api-keys/deleteApiKey

---

# Create an inbox-scoped API key

Create an API key pinned to this inbox (`scope_type=inbox`). The key can
only ever operate on this one inbox. Otherwise identical to creating an
organization key: the raw `secret` is returned **once**, and the
no-escalation and key-limit rules apply. A key not permitted this inbox,
or attempting to broaden its own scope, is forbidden.


API reference: https://docs.theinboxagents.com/docs/api-keys/createInboxApiKey

---

# List an inbox's threads (thin list)

Lists the inbox's threads, most-recently-active first, paginated. Each
entry is the **thin** thread (no `messages[]` — fetch those with
get-thread). Threads labeled `spam` or `unauthenticated` are excluded
unless the matching `include_*` flag is set; opting into `spam`
additionally requires the key's `label_spam_read` permission. An inbox
with no mail returns an empty list; an unknown/cross-tenant inbox is a
`404`.


API reference: https://docs.theinboxagents.com/docs/threads/listThreads

---

# Get a thread with its messages (fat get)

Returns the thread plus its **full** messages in chronological
(`timestamp` ascending) order — the complete conversation an agent reads
as memory. A `spam`-labeled thread requires the key's `label_spam_read`
permission. Unknown/cross-tenant inbox or thread ids return an
indistinguishable `404`.


API reference: https://docs.theinboxagents.com/docs/threads/getThread

---

# Get a single message

Returns the full structured message — parsed sender/recipients, `text`/
`html` bodies, the reply-history-stripped `extracted_*`, `headers`,
threading headers, and the attachment list. A `spam`-labeled message
requires the key's `label_spam_read` permission. Unknown/cross-tenant
inbox or message ids return an indistinguishable `404`.


API reference: https://docs.theinboxagents.com/docs/messages/getMessage

---

# Get an attachment download URL

Returns the attachment's metadata plus a short-lived presigned
`download_url` — the client fetches the bytes from that URL. The URL
expires (`expires_at`); re-call this endpoint to mint a fresh one.
Unknown/cross-tenant ids return an indistinguishable `404`.


API reference: https://docs.theinboxagents.com/docs/messages/getAttachment

---

# List webhooks

List the tenant's webhooks, newest-first by default, with cursor
pagination. The signing secret is never included in a listing — it is
shown only once at creation.


API reference: https://docs.theinboxagents.com/docs/webhooks/listWebhooks

---

# Register a webhook

Register a webhook endpoint. `url` must be HTTPS and `event_types` must
list at least one event type; `inbox_ids` (max 10) optionally scopes the
endpoint to specific inboxes (omit for all of the tenant's inboxes).
Supply a `client_id` for idempotency — repeating a create with the same
`client_id` returns the existing webhook (`200`). The response includes
the endpoint's **signing secret** (`whsec_…`), returned **only** here —
store it now to verify deliveries.


API reference: https://docs.theinboxagents.com/docs/webhooks/createWebhook

---

# Get a webhook

Fetch a single webhook by id. An unknown id and an id belonging to
another tenant both return the same not-found error — existence is never
leaked. The signing secret is not included (shown only at creation).


API reference: https://docs.theinboxagents.com/docs/webhooks/getWebhook

---

# Update a webhook

Partially update a webhook's `url`, `event_types`, `inbox_ids`, and/or
`enabled` flag. At least one field must be supplied. `event_types` is
**replace-not-merge** — the supplied array becomes the whole filter set.
Set `enabled: false` to pause deliveries without deleting the endpoint.


API reference: https://docs.theinboxagents.com/docs/webhooks/updateWebhook

---

# Delete a webhook

Delete a webhook by id; deliveries to it stop immediately and any
in-flight retries are abandoned. An unknown, already-deleted, or
cross-tenant id returns not-found.


API reference: https://docs.theinboxagents.com/docs/webhooks/deleteWebhook

---

# Register a webhook scoped to an inbox

Register a webhook pre-scoped to this inbox — the endpoint only receives
events for this one inbox. Otherwise identical to `POST /webhooks`: `url`
must be HTTPS, `event_types` must list at least one type, and the
response includes the one-time signing secret. Supplying `inbox_ids` in
the body overrides the path scope.


API reference: https://docs.theinboxagents.com/docs/webhooks/createInboxWebhook