Find a message, optionally waiting for it to arrive
const url = 'https://api.facteur.eu/v1/messages/search';const options = { method: 'POST', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"inbox":"example","workspace":"example","sentTo":"example","sentFrom":"example","subject":"example","body":"example","since":"2026-04-15T12:00:00Z","match":"all","status":"parsed","limit":50,"cursor":"example","wait":1}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}curl --request POST \ --url https://api.facteur.eu/v1/messages/search \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "inbox": "example", "workspace": "example", "sentTo": "example", "sentFrom": "example", "subject": "example", "body": "example", "since": "2026-04-15T12:00:00Z", "match": "all", "status": "parsed", "limit": 50, "cursor": "example", "wait": 1 }'The route a migrating test suite calls first, and the one that removes the
sleep-and-retry loop: set wait and the request blocks until a message
matches, instead of handing back an empty list for you to poll.
A POST that needs only ws:read. The criteria do not fit in a query
string, but this is a read — a read-only key, the kind every test suite
should be given, is not refused its own search.
An unknown criterion is refused, never ignored. A caller who writes
subjet for subject would otherwise receive every message in the inbox
and assert on the first one, and the suite would pass until the day two
messages arrive.
Authorizations
Section titled “Authorizations”Request Bodyrequired
Section titled “Request Bodyrequired”At least one of sentTo, sentFrom, subject, body or since. An
empty search is not a search — for “the last messages”, read
GET /v1/inboxes/{id}/messages.
Text criteria are case-insensitive substrings, not full-text: 100%
matches the literal string, wildcards and all.
object
Search one inbox. Mutually exclusive with workspace; neither means everything the key reaches.
Search one workspace, by slug.
Matched against the text part and the HTML part; either one counts, whatever match says.
A bound, not a criterion — it narrows the window in both match modes.
Whether every text criterion must match, or any one of them.
parsed by default, and this is the sharpest edge in the API. A
message exists before it is parsed, so returning received rows would
make “the mail has not arrived” and “the mail is here but not read
yet” look identical. any is available for whoever wants to watch the
transitional state.
Clamped to 200 rather than refused.
The nextCursor of the previous page. Opaque — its contents are ours to change.
Milliseconds to keep looking before answering. Absent or 0 is one
query and an answer. Clamped to 300000 (five minutes) rather than
refused. The first look always happens before any waiting, so a
message that arrived a second ago costs nothing.
Responses
Section titled “Responses”The matching messages, newest first. An empty messages array with a
wait that expired means the mail did not arrive in time — not that
the criteria were wrong.
object
Read status first. A message accepted over SMTP exists before it is
parsed, and a refused one carries no derived fields at all — an absent
otp pushes a client to read status rather than assert on a plausible
null.
object
The inbox this arrived in.
How long the message took to reach us.
received — accepted and stored, not parsed yet. Everything derived
from the body is still empty; look again.
parsed — everything below is populated.
quota_exceeded — refused for want of plan. Nothing was stored or
parsed, and the derived fields are absent rather than null.
object
object
object
The one-time code found in the message, when there is one. This is the field most suites assert on.
object
object
object
One property per extractor saved on this inbox, keyed by the extractor’s key.
object
Present instead of the derived fields when the message was not processed.
object
Null on the last page, so a client loops on truthiness rather than comparing counts to a limit it may not have sent.
Example
{ "messages": [ { "status": "received", "error": { "code": "PLAN_QUOTA_EXCEEDED" } } ]}CRITERIA_EMPTY (no criterion given), CRITERIA_UNKNOWN (a field
that is not a criterion — the message names it), CRITERIA_INVALID
(a criterion whose value is unusable), CURSOR_INVALID, or
INVALID_JSON.
Every failure has this shape. Branch on error.code; error.message is
written for a human and is in French today.
object
object
The stable, machine-readable reason.
A sentence for whoever is reading the failure. Not part of the contract.
Example
{ "error": { "code": "SCOPE_INSUFFICIENT" }}No usable credential. UNAUTHENTICATED when none was presented,
KEY_UNKNOWN, KEY_REVOKED or KEY_EXPIRED when a key was — kept
distinct on purpose, because “mint a new one” and “somebody took this
away from you” are different facts to whoever is reading a red pipeline.
Every failure has this shape. Branch on error.code; error.message is
written for a human and is in French today.
object
object
The stable, machine-readable reason.
A sentence for whoever is reading the failure. Not part of the contract.
Example
{ "error": { "code": "SCOPE_INSUFFICIENT" }}SCOPE_INSUFFICIENT (the message names the scope this request wanted and
the ones the key holds), KEY_CANNOT_REACH (no scope opens this route to
a key at all), FORBIDDEN, or one of the stated refusals listed under
x-api-key-refusals.
Every failure has this shape. Branch on error.code; error.message is
written for a human and is in French today.
object
object
The stable, machine-readable reason.
A sentence for whoever is reading the failure. Not part of the contract.
Example
{ "error": { "code": "SCOPE_INSUFFICIENT" }}INBOX_NOT_FOUND or WORKSPACE_NOT_FOUND when the search named one the key does not reach.
Every failure has this shape. Branch on error.code; error.message is
written for a human and is in French today.
object
object
The stable, machine-readable reason.
A sentence for whoever is reading the failure. Not part of the contract.
Example
{ "error": { "code": "SCOPE_INSUFFICIENT" }}TOO_MANY_WAITS — your organization already holds 20 open waits. That
is usually a suite firing searches without awaiting their answers.
Lower the concurrency, or lower wait.
Every failure has this shape. Branch on error.code; error.message is
written for a human and is in French today.
object
object
The stable, machine-readable reason.
A sentence for whoever is reading the failure. Not part of the contract.
Example
{ "error": { "code": "SCOPE_INSUFFICIENT" }}