Skip to content
Facteur
FRGet started

Type at least two characters.

Read a message from a test suite

One request is enough: it waits for the message, and hands it back fully parsed.

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 and send it in a header:

Authorization: Bearer fk_your_key

See API keys 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:

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:

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.
  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

statusWhat it meansWhat you do
parsedEverything derived from the body is populatedRead the message
receivedAccepted and stored, not parsed yetCall the route again, or use wait, which only returns parsed
quota_exceededRefused for want of plan. Nothing was stored or parsedBuy a pack, see Quotas and 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

{
  "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.

Search by criteria

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:

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

Download the bytes:

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:

reasonWhat it meansWhat you do
PERFORMANCE_MODEThe inbox is set to store nothingChange the inbox setting if you want the bytes
ATTACHMENT_TOO_LARGEThe file exceeds what your plan allowsChange plan, or test with a smaller file
STORAGE_QUOTA_EXCEEDEDThe organization’s storage envelope is fullFree 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.

Checked on Is this article wrong or incomplete?