Skip to content

Limits

Hold the request open until the mail arrives

Section titled “Hold the request open until the mail arrives”

Add wait to GET/messages, in milliseconds. The request answers as soon as a message matches your criteria:

wait.sh
curl --silent --show-error --fail-with-body --get \
-H "Authorization: Bearer $FACTEUR_API_KEY" \
--data-urlencode "inbox=j3k9x2mq" \
--data-urlencode "hasOtp=true" \
--data-urlencode "wait=20000" \
https://api.facteur.eu/messages

What this request does:

  • It looks once before it waits. A message that arrived a second ago comes back at once.
  • While nothing matches, it looks again every 250 milliseconds, and answers as soon as a message matches.
  • If nothing has matched at the deadline, it answers 200 with an empty list. Your test decides whether that is a failure.
  • Without wait, or with 0, it is a single search with a single answer.

Send the search before you trigger the action that sends the mail. If it starts afterwards, it can still find a message that has already arrived, but your test no longer waits for the mail and the action at the same time.

Ceiling Value What happens past it
Length of one wait 300000 ms, five minutes A longer wait is brought down to five minutes. It is not refused.
Waits open at once, for one organization 20 The next search answers 429 with the code TOO_MANY_WAITS and Retry-After: 1.
Requests of one key 600 a minute The request answers 429 with the code RATE_LIMITED and a Retry-After header.
Size of a page of messages 200 A larger limit is brought down to 200. The default is 50.

An open wait holds a connection until it answers. That is why the second ceiling counts the whole organization and not one key: every key you hold draws on the same twenty. A slot frees when one of your own waits ends, so what clears a TOO_MANY_WAITS is awaiting your searches, or running fewer workers.

A wait costs one request however long it lasts. A suite that waits does not come close to 600 requests a minute, and a loop that polls does.

Every answer to an authenticated request says how much of the minute is left:

rate-headers.sh
curl --silent --show-error --include \
-H "Authorization: Bearer $FACTEUR_API_KEY" \
https://api.facteur.eu/inboxes
Header What it says
RateLimit-Policy What the service allows: "key";q=600;w=60, "waits";q=20;qu="concurrent-requests". 600 requests in a window of 60 seconds, and 20 concurrent waits.
RateLimit Where this key stands: "key";r=<requests left>;t=<seconds until the window ends>.
X-RateLimit-Limit 600.
X-RateLimit-Remaining Requests left in the minute, never below zero.
X-RateLimit-Reset Seconds until the window ends. It is a number of seconds and not a date.

The two families carry the same numbers. RateLimit and RateLimit-Policy follow the IETF draft on rate limit headers, and the X- names are the older convention that many client libraries already read. Use the family your HTTP client understands. A request with no valid key gets RateLimit-Policy and X-RateLimit-Limit only, because there is no budget to report.

Two routes read messages of an inbox. Both page by cursor:

Route Page
GET/inboxes/{inboxId}/messages limit and cursor, in the query. It lists the messages of one inbox in whatever state they are in.
GET/messages limit and cursor, in the query. It returns parsed messages only, unless you ask for status=any.

nextCursor is null on the last page, so loop while it has a value. Send it back exactly as you received it. A cursor you built or changed gets 400 INVALID_CURSOR.

GET/messages is a GET request, so each criterion is a parameter of its address, such as /messages?inbox=j3k9x2mq&subject=Welcome. The words you search for therefore appear in that address, and a proxy or a gateway between your machine and Facteur can write them in its logs. Facteur does not write them in its access logs, its application logs or its traces. The address of the page of the Facteur app that a search is launched from is not written either, because the browser sends the name of the site only. The logs of a component that is not Facteur’s are out of its reach. Search for an address, a subject or a code, and never for a secret.

Each text has a longest allowed length, counted in characters:

Parameter Longest value
inbox 36
workspace 40
sentTo and sentFrom 254
subject, body and attachmentName 255
otp 16

A longer value is refused with 400 CRITERIA_INVALID, and the search does not run on a text cut short. A cursor holds 64 characters at most, and a longer one answers 400 INVALID_CURSOR, like any cursor Facteur did not issue. A true-or-false parameter (hasOtp, hasAttachment) is written true or false and nothing else. limit and wait are written as digits. since is a date in ISO 8601, such as 2026-11-03T10:00:00Z, and anything else answers 400 CRITERIA_INVALID.

POST/messages/search takes the criteria of GET/messages as a JSON body, such as {"inbox": "j3k9x2mq", "subject": "Welcome", "wait": 20000}. It is the same search: the same criteria, the same longest values, the same limit, cursor and wait, the same page in the answer and the same refusals. Use it when you do not want a criterion in an address, or when your client already sends the search as JSON. In the body, hasOtp and hasAttachment are JSON booleans, limit and wait are JSON numbers, and a field the search does not know answers 400 CRITERIA_UNKNOWN, as in the address. A body that is not JSON answers 400 BAD_REQUEST. When you give both inbox and workspace, the inbox wins and workspace is ignored, with the GET as with the POST.

Each message of a search comes with its bodies, textBody and htmlBody, its headers and what the connection knew (connection), exactly as GET/messages/{messageId} returns it. A test that waits for a mail reads its link or its code from that answer, and does not send a second request. A message that was refused (quota_exceeded) has no body, and the keys are absent. The list of an inbox, GET/inboxes/{inboxId}/messages, stays without the bodies.

A page of 200 messages with their bodies can be large. Ask for fewer with limit when you read many mails at once.

Read status before anything read from the body

Section titled “Read status before anything read from the body”

Facteur accepts a message first and reads it a moment later. For that moment it has no subject, no code and no links.

status Meaning What you do
received Stored, not read yet. Look again in a moment.
parsed Every field read from the body is filled in. Assert on subject, otp and links.
quota_exceeded Refused because a limit was reached. Nothing was read. Read errorCode, see the errors guide.

A search returns parsed messages only by default, so “the mail has not arrived” and “the mail is here and not read yet” cannot be mistaken for each other. Pass status=any when you want to see the other states.

The text criteria are sentTo, sentFrom, subject, body, otp and attachmentName. Each one matches a substring, without regard to case. % and _ are ordinary characters: searching for 100% finds the text 100%.

  • body is searched in the text part and in the HTML part of the message.
  • attachmentName matches the file name of a real attachment. An inline image, such as a logo in a signature, is not an attachment and never matches.
  • otp matches the code Facteur extracted, and nothing else. otp=882314 means “the code is 882314”. body=882314 would also find a message that only quotes it.

match decides how the text criteria combine: all of them (the default) or any of them. since, type, hasAttachment, hasOtp and status are not text criteria. They narrow the result in both modes.

A search has to name a set. inbox, sentTo, sentFrom, subject, body, otp, hasOtp, hasAttachment and since each do. workspace, type and status only narrow a set, so alone they answer 400 CRITERIA_EMPTY. A field the search does not know answers 400 CRITERIA_UNKNOWN and names it.

Each inbox has a retentionDays, set from the plan the organization had when the inbox was opened. A message carries expiresAt, the moment the retention removes it, or null when it does not expire.

Add an Idempotency-Key header to a POST, with a value of your own, such as a UUID. If your script has to send the request again, because the connection dropped before the answer came back, send it with the same key: Facteur gives you the answer to the first request and does not do the work a second time.

Terminal window
curl --silent --show-error --fail-with-body \
-H "Authorization: Bearer $FACTEUR_API_KEY" \
-H "Idempotency-Key: 6f1c2b7e-3a49-4d8e-9d52-0b1f7c4a8e31" \
-H "Content-Type: application/json" \
-d '{"name": "signup-tests"}' \
https://api.facteur.eu/workspaces/$WORKSPACE/inboxes

Facteur keeps the answer for 24 hours. A request that comes back with the same key after that is treated as a new request and does its work again, so send your retries within the day. The key belongs to the API key that sent it: two keys can use the same value without meeting. An answer that failed is not kept, so a request that was refused can be sent again with the same key.