# Read a message from a test suite

> One request is enough: it waits for the message, and hands it back fully parsed.
> https://facteur.eu/en/help/lire-un-message

Three routes are enough to write a test.

```
POST /v1/messages/search          the one you are after, and it waits for it to land
GET  /v1/inboxes/<id>/messages    the latest 50, newest first
GET  /v1/messages/<id>            one message, in full
```

Use the first one. It is the one that waits for the message on your behalf, and the
one that hands it back fully parsed.

## Before you start

Create an API key from
[**Settings → API keys**](https://app.facteur.eu/settings/api-keys) and send it in a
header:

```
Authorization: Bearer fk_your_key
```

See [API keys](cles-api) for what a key reaches and how to rotate one.

## Wait for the message instead of polling for it

Add `wait`, in milliseconds, and the search does not answer until something matches:

```bash
curl -X POST https://api.facteur.eu/v1/messages/search \
  -H "Authorization: Bearer fk_your_key" \
  -H "content-type: application/json" \
  -d '{"inbox": "j3k9x2mq", "sentTo": "order-4821", "wait": 30000}'
```

This is what replaces the `sleep` or `retry` loop you would otherwise write. **Issue
the search before triggering whatever sends the email**: it will wait.

Six things to know:

- **It answers as soon as the message arrives**, not at the deadline.
- **The message it returns is fully parsed**: subject, one-time code, links.
- **Nothing found by the deadline gives a `200` with an empty list**, not an error.
  Whether an absent message is a failure is your test's call.
- **Five minutes at most.** Ask for more and the value is clamped rather than refused.
  With no `wait`, or with `wait: 0`, the search looks once and answers immediately.
- **Twenty concurrent waits per organization.** Past that, `429 TOO_MANY_WAITS`:
  usually the sign of a suite issuing searches without awaiting their answers.
- **`503 API_DRAINING` means "ask again", not "nothing arrived".** When we replace an
  instance, the one holding your wait hands it back instead of dropping the
  connection. Retry the same search: another instance picks it up. The `Retry-After`
  header says how long to leave it, and it is always short. A thirty-second wait, the
  default, crosses a deployment without noticing.

### If you read an inbox listing instead

`GET /v1/inboxes/<id>/messages` has no wait. You then need a polling loop:

```js
async function waitFor(inboxId, recipient, timeoutMs = 15000) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const messages = await api(`/v1/inboxes/${inboxId}/messages`);
    const found = messages.find((m) => m.to === recipient && m.status === 'parsed');
    if (found) return found;
    await new Promise((r) => setTimeout(r, 500));
  }
  throw new Error(`no message for ${recipient} within ${timeoutMs} ms`);
}
```

Two details separate a reliable test from a flaky one:

1. **Filter on `to`**, not on the subject and not on "the latest message". That is
   what subaddressing is for, see [Inbox addresses](adresses-inbox).
2. **Require `status === 'parsed'`**. Otherwise the test occasionally reads a message
   whose code is not extracted yet, and fails one time in fifty.

## Read the `status` field before any other

| `status` | What it means | What you do |
|---|---|---|
| `parsed` | Everything derived from the body is populated | Read the message |
| `received` | Accepted and stored, **not parsed yet** | Call the route again, or use `wait`, which only returns `parsed` |
| `quota_exceeded` | Refused for want of plan. Nothing was stored or parsed | Buy a pack, see [Quotas and retention](quotas-et-retention) |

Parsing happens **after** the SMTP acknowledgement. For a few milliseconds a message
therefore exists with no subject, no code and no links. Without this field, "there is
no code in this message" and "the code is not extracted yet" look exactly alike.

A refused message **omits the extracted fields** rather than returning them empty:
`message.otp` is `undefined` and not a `null` you would write an assertion against
that passes for the wrong reason. It carries an `error` object with its code instead.

## The shape of a message

```json
{
  "id": "…",
  "inbox": "ab12cd34",
  "receivedAt": "2026-08-07T09:14:02.481Z",
  "deliveryMs": 412,
  "status": "parsed",
  "from": { "address": "no-reply@example.test", "name": "Example" },
  "to": "signup+7c1e@ab12cd34.inbox.facteur.eu",
  "sizeBytes": 4821,
  "subject": "Your confirmation code",
  "text": { "body": "…" },
  "html": { "body": "…" },
  "otp": "418302",
  "links": [{ "href": "https://example.test/confirm?token=…", "text": "Confirm" }],
  "codes": [],
  "headers": { "message-id": "…", "x-your-header": "…" },
  "extract": {},
  "attachments": [
    {
      "id": "…",
      "fileName": "invoice-4821.pdf",
      "contentType": "application/pdf",
      "sizeBytes": 86412,
      "checksum": "sha256:9f2c…",
      "stored": true,
      "url": "/v1/attachments/…"
    }
  ],
  "inlines": []
}
```

`deliveryMs` is the delay between sending and reception, measured on our side.
`extract` carries what your own pointers found, see [Pointers](pointeurs).

## Search by criteria

```bash
curl -X POST https://api.facteur.eu/v1/messages/search \
  -H "Authorization: Bearer fk_your_key" \
  -H "content-type: application/json" \
  -d '{"inbox": "j3k9x2mq", "sentTo": "order-4821", "subject": "confirmation"}'
```

**The criteria.** `sentTo`, `sentFrom`, `subject`, `body`, `otp`, `attachmentName`.
They are substrings, case-insensitive. `%` is not a wildcard and matches only the `%`
character. `otp` searches the extracted code and nothing else: `{"body": "882314"}`
would also find a message that merely quotes that number.

**The bounds.** `since`, `hasAttachment` and `hasOtp` narrow the window rather than
adding to the alternatives.

**`match`.** `all` by default: every criterion has to match. Pass `any` for one to be
enough. A bound never enters the `any`, otherwise "since yesterday or from Camille"
would hand you messages from the day before.

**Where to look.** `inbox` for one inbox, `workspace` for a whole workspace, or
neither for everything your key reaches.

**Paging.** `limit` (50 by default, 200 at most) and the `nextCursor` the response
returns. Pass it back as it is; it is null on the last page, which saves comparing a
count to a limit.

**The transitional state.** By default the search returns only `parsed` messages.
Pass `"status": "any"` to also see the ones that just arrived.

### Two refusals that save you an hour

- **An unknown criterion is refused**, not ignored. `subjet` instead of `subject`
  answers `400 CRITERIA_UNKNOWN` naming the field. Ignored, it would have returned
  *every* message in the inbox, and your test would have passed for the wrong reason
  until the day a second message arrives.
- **An inbox that is not yours answers `404`**, not an empty list. An empty list reads
  as "the mail has not arrived" and sends somebody debugging a working pipeline.

## Attachments

`attachments` lists real attachments. `inlines` lists images referenced by the HTML
body (`cid:`), almost always a signature logo. The two are kept apart so
`attachments.length` does not change depending on whether a sender added a banner to
their signature.

**Verify content without downloading the file.** Compare `checksum`, which carries the
received file's SHA-256 prefixed with `sha256:`, against your own local file:

```bash
sha256sum invoice-4821.pdf
# 9f2c…  invoice-4821.pdf
```

**Download the bytes:**

```bash
curl -H "Authorization: Bearer fk_your_key" \
  https://api.facteur.eu/v1/attachments/<id> -o invoice.pdf
```

Same access rules as everything else: a key that does not reach the workspace holding
the message gets `404 ATTACHMENT_NOT_FOUND`, whether or not an attachment actually
exists under that id.

**`stored` can be `false`.** The file is still described (name, size, checksum) but its
bytes are not kept, and `GET /v1/attachments/<id>` answers
`404 ATTACHMENT_NOT_STORED`. The `reason` field says which of the three cases:

| `reason` | What it means | What you do |
|---|---|---|
| `PERFORMANCE_MODE` | The inbox is set to store nothing | Change the inbox setting if you want the bytes |
| `ATTACHMENT_TOO_LARGE` | The file exceeds what your plan allows | Change plan, or test with a smaller file |
| `STORAGE_QUOTA_EXCEEDED` | The organization's storage envelope is full | Free up space or buy a storage pack |

## Known limits

- **Fifty messages, no paging on the list.** `GET /v1/inboxes/<id>/messages` returns
  the latest fifty and has no cursor. The search does page.
- **No full-text search.** The criteria are substrings. Looking for a word with
  different casing or accents will not find it.
- **The inbox page's filter only sees the last 50 messages.** It works on whatever
  `GET /v1/inboxes/{id}/messages` returned, so a value present in the 51st message
  reads as absent. Use `POST /v1/messages/search`, which pages and searches the body.
