Skip to content

Quickstart

Five requests, and the last one is the one your test suite keeps.

From the application: Settings → API keys → Create a key, at organization level. You need to be an owner or an admin.

Take the defaults unless you know otherwise — read-only, aimed at your main workspace, ninety days. A test suite reads messages; it rarely needs to create anything.

The secret is shown once. Only a digest of it is stored, so neither you nor we can recover it afterwards. Paste it straight into your CI secrets.

Terminal window
export FACTEUR_KEY=fk_...
Terminal window
curl -H "Authorization: Bearer $FACTEUR_KEY" \
https://api.facteur.eu/v1/inboxes

An empty array is a valid answer: the key works, you have no inboxes yet. A 401 means the key is unknown, revoked or expired — the code says which.

Inboxes live inside a workspace, so name the one you want. GET /v1/workspaces lists them; every organization has at least a default one.

Terminal window
curl -X POST https://api.facteur.eu/v1/workspaces/main/inboxes \
-H "Authorization: Bearer $FACTEUR_KEY" \
-H "content-type: application/json" \
-d '{"name": "signup flow"}'

The response carries domain — the address this inbox receives at:

{
"id": "j3k9x2mq",
"name": "signup flow",
"domain": "j3k9x2mq.inbox.facteur.eu",
"retentionDays": 15
}

Point your application at that address, and use subaddressing to tell one test’s mail from another’s:

signup+run-4821@j3k9x2mq.inbox.facteur.eu

Everything after the + is yours. Put the test run, the user, the branch — whatever makes the message you are waiting for unambiguous. This is what lets two tests share an inbox without racing.

The request your suite keeps:

Terminal window
curl -X POST https://api.facteur.eu/v1/messages/search \
-H "Authorization: Bearer $FACTEUR_KEY" \
-H "content-type: application/json" \
-d '{"inbox": "j3k9x2mq", "sentTo": "signup+run-4821", "wait": 30000}'

Issue it before triggering whatever sends the mail. It will wait, and it answers the moment the message lands rather than at the deadline.

{
"messages": [
{
"id": "",
"status": "parsed",
"from": { "address": "no-reply@example.test", "name": "Example" },
"to": "signup+run-4821@j3k9x2mq.inbox.facteur.eu",
"subject": "Your confirmation code",
"otp": "418302",
"links": ["https://example.test/confirm?token=…"],
"text": { "body": "" }
}
],
"nextCursor": null
}

otp is the field most suites assert on. It is populated by the ingestion engine, which is why status reads parsed — see Limits and waiting for what the other statuses mean and when you will see them.

const search = (body) =>
fetch('https://api.facteur.eu/v1/messages/search', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.FACTEUR_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify(body),
}).then((r) => r.json());
test('sign-up sends a confirmation code', async () => {
const address = `signup+${crypto.randomUUID()}@j3k9x2mq.inbox.facteur.eu`;
// Start waiting before the mail is sent, not after.
const arriving = search({ inbox: 'j3k9x2mq', sentTo: address, wait: 30_000 });
await signUp(address);
const { messages } = await arriving;
expect(messages).toHaveLength(1);
expect(messages[0].otp).toMatch(/^\d{6}$/);
});

Two details separate a reliable test from a flaky one, and both are in that snippet: filter on sentTo rather than on the subject or on “the latest message”, and start the search first.

  • Authentication — scopes, which workspaces a key reaches, rotating without downtime.
  • Errors — the shape every failure has.
  • API reference — every operation, in full.