Skip to content
Facteur
FRGet started

Reading a message from a test suite

The three read routes, the shape of a message, and the status field to read before anything else.

Three routes are enough to write a test.

GET  /v1/inboxes/<id>/messages    the latest 50, newest first
GET  /v1/messages/<id>            one message, in full
POST /v1/messages/search          the one you are looking for, by criteria

Prefer the search as soon as a test waits for a specific message: it saves reading a list to sift through it yourself, and by default it returns only fully parsed messages.

Authentication: create an API key from Settings → API keys and send it as Authorization: Bearer fk_…. See cles-api for what a key reaches and how to rotate one.

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": ["https://example.test/confirm?token=…"],
  "codes": [],
  "headers": { "message-id": "…", "x-your-header": "…" },
  "extract": {}
}

deliveryMs is the delay between sending and reception, measured on our side. extract carries what your own pointers found — see pointeurs.

status is read before anything else

This is the field that avoids the most tiresome class of failure: a red test with no visible reason.

statusWhat it means
receivedAccepted and stored, not parsed yet. Call the route again.
parsedEverything derived from the body is populated.
quota_exceededRefused for want of plan. Nothing stored, nothing parsed.

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

A refused message omits the derived keys rather than returning them empty: message.otp is undefined, not a plausible-looking null you might write an assertion against that passes for the wrong reason. It carries an error object with its code instead.

Waiting

There is no server-side blocking wait yet: you call the list again until your message is there. The pattern that works:

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 adresses-inbox.
  2. Require status === 'parsed'. Otherwise the test occasionally reads a message whose OTP is not there yet, and fails one time in fifty.

Searching for a message

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 criteriasentTo, sentFrom, subject, body, and since as a lower bound. They are case-insensitive substrings; % is not a wildcard and matches only the % character.

matchall by default: every criterion must match. any for one to be enough. since never joins the any disjunction: it is a bound, not an alternative.

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

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

Waiting for the message instead of polling for it

Add wait, in milliseconds, and the search stops answering 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/retry loop you would otherwise write. Issue the search before triggering whatever sends the mail: it will wait.

  • It answers as soon as the message arrives, not at the deadline.
  • The message it returns is fully parsed — subject, OTP, links. A wait that answered as soon as a row existed would hand you a message with no code.
  • Nothing found by the deadline is 200 with an empty list, not an error: an absent message is an answer, and whether that is a failure is your test’s call.
  • Five minutes at most, thirty seconds if you say nothing else. With no wait, the search 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.

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.

The status field, again

By default the search returns only parsed messages. Deliberately: a message exists for a few milliseconds before it is parsed, with no subject and no OTP, and returning it would make “not arrived yet” and “arrived but unread” indistinguishable. Pass "status": "any" to see that transitional state.

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 — use it when you need to go further.
  • The SDK does not exist yet. messages.await, shown on the site, is a TypeScript client that is not published. The wait itself works — see above — but it has to be called over HTTP.
  • No full-text search. The criteria are substrings. Looking for a word with different casing or accents will not find it.
  • No attachments. Storage anticipates them, the API does not serve them.