> For an agent: the map of this documentation is at https://escouade.io/en/mail/docs/llms.txt; every page exists in Markdown, at the same address with `.md` appended.

# HTTP API

> Every MCP tool can also be called over HTTP: `POST https://api.subagent.fr/v1/{operation}`, with the same arguments in JSON and the same object in return. Under `/v1`, every route is a tool.

## In short

| Property | Value |
|---|---|
| Address | `https://api.subagent.fr/v1/{operation}`, where `{operation}` is the name of a tool: `send_email`, `list_threads`… |
| Method | `POST`, for every operation, reads included |
| Body | A JSON object: the tool's arguments. An empty body counts as `{}`. 1 MiB at most. |
| Authentication | `Authorization: Bearer sak_…` header. Only `sign_up` is called without a key. |
| Response | Status 200 and the tool's structured object |
| Refusal | An HTTP status and `{"error": {"code", "message", "hint"}}` |
| Schemas | `https://api.subagent.fr/openapi.json` (OpenAPI 3.1), readable without a key |

## A call

```sh
curl -X POST https://api.subagent.fr/v1/inbox_status \
  -H "Authorization: Bearer $SUBAGENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

The response is the object the tool returns over MCP in `structuredContent`. The readable text only exists over MCP.

## Signing up and verifying over HTTP

```sh
curl -X POST https://api.subagent.fr/v1/sign_up \
  -H "Content-Type: application/json" \
  -d '{"username": "atelier-exemple", "human_email": "marie@example.com"}'
```

The response carries `api_key`: the key, shown once only. The following calls present it.

```sh
curl -X POST https://api.subagent.fr/v1/verify \
  -H "Authorization: Bearer $SUBAGENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"otp_code": "482913"}'
```

## Refusals

```json
{
  "error": {
    "code": "space_unverified",
    "message": "…",
    "hint": "…"
  }
}
```

- **`code`**: stable. It is what a program tests.
- **`message`**: a sentence in French.
- **`hint`**: the lead, when there is one. It says what to do.

| Status | Meaning |
|---|---|
| 400 | An argument is missing, unknown or malformed: `invalid_argument`. |
| 401 | The key is missing, unknown or revoked: `unauthorized`. |
| 402 | The space has no active plan: `payment_required`. |
| 403 | The key does not carry the permission (`forbidden`), the space is not verified (`space_unverified`), or the call comes from a web page (`origin_refused`). |
| 404 | The object or the operation does not exist: `not_found`, `unknown_operation`. |
| 409 | The state does not allow the action: `duplicate_recipient`, `not_draft`, `inbox_suspended`… |
| 413 | The body exceeds 1 MiB: `payload_too_large`. |
| 422 | The request is understood but refused: `recipient_suppressed`, `own_domain_required`, `unresolved_placeholder`… |
| 429 | A limit is reached: `rate_limited`, `key_daily_cap`, `queue_full`… |
| 500, 502, 503 | The service, or a service it depends on, did not answer: `internal`, `provisioning_failed`, `dns_unavailable`, `billing_unavailable`… |

Every code, with what to do: [Errors](https://escouade.io/en/mail/docs/erreurs.md).

A message held back is not a refusal. Daily quota reached, send outside working hours, month's volume reached: the response is a success (200), and the message is queued (`queued`). `estimated_send_at` gives its earliest departure, based on the sending window and the daily quota.

## Going through a list

Lists return `items` and `next_cursor`.

- `limit` goes from 1 to 100. 20 by default.
- As long as `next_cursor` is not `null`, pass it back as it is in `cursor` for the next page.
- A cursor is never made up: an unreadable cursor is refused with the code `invalid_argument`.

```sh
curl -X POST https://api.subagent.fr/v1/list_threads \
  -H "Authorization: Bearer $SUBAGENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"unanswered": true, "limit": 50}'
```

`get_thread` follows the same rule for a thread's messages: 50 per call, then `next_cursor`. `list_domains` returns all the space's domains at once.

## Retrying without duplicates

`send_email` and `reply_to_thread` accept an `idempotency_key`, 1 to 255 characters. Over HTTP, the `Idempotency-Key` header is equivalent to the parameter.

- **Same key, same content**: the server returns the message already created, with `replayed` = `true`.
- **Same key, different content**: refusal `idempotency_conflict`. Nothing is sent.

After a 500 error or a network cut, retry with the same `idempotency_key`.

## The shapes

- **Identifiers**: `inb_…` (inbox), `thr_…` (thread), `msg_…` (message), `dom_…` (domain), followed by 32 hexadecimal characters. Pass them exactly as the tools return them.
- **Dates**: ISO 8601 with an offset, in Paris time: `2026-10-13T09:00:00+02:00`. As input, a Paris time without an offset is accepted: `2026-10-13T09:00`.
- **Arguments**: an unknown argument is refused, with the list of accepted arguments in the lead.

## Rate

60 requests per minute without a key, 600 with a key, per client address. Beyond that: status 429, code `rate_limited`, and the `Retry-After` header gives the number of seconds to wait. See [Limits and quotas](https://escouade.io/en/mail/docs/limites.md#debit).

## From a browser

The API cannot be called from another site's web page: a request carrying a foreign `Origin` header is refused with the code `origin_refused`. Call it from your agent or your server.

## The server's reference

- `https://api.subagent.fr/openapi.json`: every operation, its input and output schemas, its permissions (`x-subagent-permissions`).
- `https://api.subagent.fr/llms.txt`: the rules and the list of tools, as text.
- The service's pages that are not tools: an assistant's sign-in (`/oauth/…`, see [Connector](https://escouade.io/en/mail/docs/connecteur.md)), the payment page and the invoices (see [Pricing and payment](https://escouade.io/en/pricing)), the unsubscribe page.
- [Tool reference](https://escouade.io/en/mail/docs/reference/index.md): one page per tool, produced from this registry.
