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
200with 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 withwait: 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_DRAININGmeans “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. TheRetry-Afterheader 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:
- Filter on
to, not on the subject and not on “the latest message”. That is what subaddressing is for, see Inbox addresses. - 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 |
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.
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.
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:
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>/messagesreturns 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}/messagesreturned, so a value present in the 51st message reads as absent. UsePOST /v1/messages/search, which pages and searches the body.
Checked on Is this article wrong or incomplete?