Skip to content

Find a message, optionally waiting for it to arrive

POST
/v1/messages/search
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.

Media typeapplication/json

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
inbox

Search one inbox. Mutually exclusive with workspace; neither means everything the key reaches.

string
workspace

Search one workspace, by slug.

string
sentTo
string
sentFrom
string
subject
string
body

Matched against the text part and the HTML part; either one counts, whatever match says.

string
since

A bound, not a criterion — it narrows the window in both match modes.

string format: date-time
match

Whether every text criterion must match, or any one of them.

string
default: all
Allowed values: all any
status

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.

string
default: parsed
Allowed values: parsed any
limit

Clamped to 200 rather than refused.

integer
default: 50 <= 200
cursor

The nextCursor of the previous page. Opaque — its contents are ours to change.

string | null
wait

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.

integer

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.

Media typeapplication/json
object
messages
required
Array<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
id
required
string
inbox
required

The inbox this arrived in.

string
receivedAt
required
string format: date-time
deliveryMs

How long the message took to reach us.

integer
status
required

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.

string
Allowed values: received parsed quota_exceeded
from
required
object
address
string
name
string | null
to
required
string
sizeBytes
integer
subject
string | null
text
object
body
string | null
html
object
body
string | null
otp

The one-time code found in the message, when there is one. This is the field most suites assert on.

string | null
links
Array<object>
object
key
additional properties
any
codes
Array<object>
object
key
additional properties
any
headers
object
key
additional properties
any
extract

One property per extractor saved on this inbox, keyed by the extractor’s key.

object
key
additional properties
any
error

Present instead of the derived fields when the message was not processed.

object
code
string
Allowed values: PLAN_QUOTA_EXCEEDED INBOX_QUOTA_EXCEEDED
message
string
nextCursor
required

Null on the last page, so a client loops on truthiness rather than comparing counts to a limit it may not have sent.

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

Media typeapplication/json

Every failure has this shape. Branch on error.code; error.message is written for a human and is in French today.

object
error
required
object
code
required

The stable, machine-readable reason.

string
message

A sentence for whoever is reading the failure. Not part of the contract.

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

Media typeapplication/json

Every failure has this shape. Branch on error.code; error.message is written for a human and is in French today.

object
error
required
object
code
required

The stable, machine-readable reason.

string
message

A sentence for whoever is reading the failure. Not part of the contract.

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

Media typeapplication/json

Every failure has this shape. Branch on error.code; error.message is written for a human and is in French today.

object
error
required
object
code
required

The stable, machine-readable reason.

string
message

A sentence for whoever is reading the failure. Not part of the contract.

string
Example
{
"error": {
"code": "SCOPE_INSUFFICIENT"
}
}

INBOX_NOT_FOUND or WORKSPACE_NOT_FOUND when the search named one the key does not reach.

Media typeapplication/json

Every failure has this shape. Branch on error.code; error.message is written for a human and is in French today.

object
error
required
object
code
required

The stable, machine-readable reason.

string
message

A sentence for whoever is reading the failure. Not part of the contract.

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

Media typeapplication/json

Every failure has this shape. Branch on error.code; error.message is written for a human and is in French today.

object
error
required
object
code
required

The stable, machine-readable reason.

string
message

A sentence for whoever is reading the failure. Not part of the contract.

string
Example
{
"error": {
"code": "SCOPE_INSUFFICIENT"
}
}