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.
status | What it means |
|---|---|
received | Accepted and stored, not parsed yet. Call the route again. |
parsed | Everything derived from the body is populated. |
quota_exceeded | Refused 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:
- Filter on
to, not on the subject and not on “the latest message”. That is what subaddressing is for — see adresses-inbox. - 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 criteria — sentTo, sentFrom, subject, body, and since as a lower
bound. They are case-insensitive substrings; % is not a wildcard and matches only
the % character.
match — all 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 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.
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
200with 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.
subjetinstead ofsubjectanswers400 CRITERIA_UNKNOWNnaming 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>/messagesreturns 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.