Aller au contenu
Facteur

Tapez au moins deux caractères.

Lire un message depuis une suite de tests

Une seule requête suffit : elle attend le message, et vous le rend entièrement analysé.

Trois routes suffisent à écrire un test.

GET  /messages                   celui que vous cherchez, et il attend qu'il arrive
GET  /inboxes/<id>/messages      les messages d'une inbox, du plus récent au plus ancien
GET  /messages/<id>              un message, en entier

Utilisez la première. C'est celle qui attend le message pour vous, et celle qui vous le rend entièrement analysé.

Avant de commencer

Créez une clé d'API depuis Paramètres → Clés d'API et envoyez-la dans un en-tête :

Authorization: Bearer fk_votre_cle

Il faut la permission ws:read. Voir Clés d'API pour ce qu'une clé atteint et comment la remplacer.

Attendre le message plutôt que le sonder

Ajoutez wait, en millisecondes, et la recherche ne répond pas tant que rien ne correspond :

curl -G https://api.facteur.eu/v1/messages \
  -H "Authorization: Bearer fk_votre_cle" \
  --data-urlencode "inbox=j3k9x2mq" \
  --data-urlencode "sentTo=commande-4821" \
  --data-urlencode "wait=30000"

C'est ce qui remplace la boucle sleep ou retry que vous écririez autrement. Lancez la recherche avant de déclencher l'action qui envoie l'email : elle attendra.

Six choses à savoir :

  • Elle répond dès l'arrivée du message, pas à l'échéance : la recherche est rejouée toutes les 250 ms.
  • Le message rendu est entièrement analysé : sujet, code à usage unique, liens.
  • Rien trouvé à l'échéance donne un 200 avec une liste vide, pas une erreur. C'est à votre test de décider si un message absent est un échec.
  • Cinq minutes au plafond. Demandez plus, la valeur est ramenée au plafond plutôt que refusée. Sans wait, ou avec wait: 0, la recherche regarde une fois et répond immédiatement.
  • Vingt attentes simultanées par organisation. Au-delà, 429 TOO_MANY_WAITS : en général le signe d'une suite qui lance des recherches sans attendre leurs réponses.
  • 503 API_DRAINING veut dire « relancez », pas « rien n'est arrivé ». Quand nous remplaçons une instance, celle qui tenait votre attente vous la rend au lieu de couper la connexion. Relancez la même recherche : une autre instance la reprend. L'en-tête Retry-After dit combien attendre.

Si vous lisez la liste d'une inbox

GET /inboxes/<id>/messages n'a pas d'attente. Il vous faut alors une boucle de guet :

async function attendre(inboxId, versQui, delaiMs = 15000) {
  const fin = Date.now() + delaiMs;
  while (Date.now() < fin) {
    const { messages } = await api(`/inboxes/${inboxId}/messages`);
    const trouve = messages.find((m) => m.toAddress === versQui && m.status === 'parsed');
    if (trouve) return trouve;
    await new Promise((r) => setTimeout(r, 500));
  }
  throw new Error(`aucun message pour ${versQui} en ${delaiMs} ms`);
}

Deux détails séparent un test fiable d'un test capricieux :

  1. Filtrez sur toAddress, pas sur le sujet ni sur « le dernier message ». C'est à cela que sert le sous-adressage, voir Adresses d'inbox.
  2. Exigez status === 'parsed'. Sinon le test lit parfois un message dont le code n'est pas encore extrait, et échoue une fois sur cinquante.

Lire le champ status avant tous les autres

status Ce que ça veut dire Ce que vous faites
parsed Tout ce qui dérive du corps est renseigné Lisez le message
received Accepté, pas encore analysé Rappelez la route, ou utilisez wait qui ne rend que du parsed
quota_exceeded Refusé : une limite de votre plan ou de l'inbox est atteinte. Rien n'a été analysé Voir Quotas et rétention

L'analyse a lieu après l'acquittement SMTP. Pendant quelques millisecondes, un message existe donc sans sujet, sans code et sans liens. Sans ce champ, « il n'y a pas de code dans ce message » et « le code n'est pas encore extrait » se ressemblent trait pour trait.

Un message refusé omet les champs extraits au lieu de les renvoyer vides : message.otp vaut undefined et non un null sur lequel vous écririez une assertion qui passe pour la mauvaise raison. Il porte à la place un errorCode qui dit laquelle des limites l'a refusé.

La forme d'un message

{
  "id": "…",
  "inbox": "ab12cd34",
  "receivedAt": "2026-08-07T09:14:02.481Z",
  "deliveryMs": 412,
  "status": "parsed",
  "fromAddress": "no-reply@exemple.test",
  "fromName": "Exemple",
  "toAddress": "signup+7c1e@ab12cd34.inbox.facteur.eu",
  "subject": "Votre code de confirmation",
  "textBody": "…",
  "htmlBody": "…",
  "otp": "418302",
  "links": ["https://exemple.test/confirm?token=…"],
  "codes": [],
  "headers": { "message-id": "…", "x-votre-en-tete": "…" },
  "extract": {},
  "attachments": [
    {
      "id": "…",
      "fileName": "facture-4821.pdf",
      "contentType": "application/pdf",
      "sizeBytes": 86412,
      "checksum": "sha256:9f2c…",
      "stored": true
    }
  ],
  "inlines": []
}

deliveryMs est le temps que le serveur de réception a mis à accepter le message. headers, textBody et htmlBody ne sont que dans la lecture d'un seul message, pas dans les listes. extract porte ce que les extracteurs de l'inbox ont trouvé, voir Pointeurs.

Chercher par critères

curl -G https://api.facteur.eu/v1/messages \
  -H "Authorization: Bearer fk_votre_cle" \
  --data-urlencode "inbox=j3k9x2mq" \
  --data-urlencode "sentTo=commande-4821" \
  --data-urlencode "subject=confirmation"

Chaque critère est un paramètre de l'adresse (-G fait mettre les paramètres dans l'adresse par curl, et --data-urlencode écrit les caractères spéciaux à votre place). Les mots que vous cherchez font donc partie de l'adresse de la requête, et peuvent être écrits dans les journaux d'un proxy ou d'une passerelle entre votre machine et Facteur. Facteur, lui, ne les écrit ni dans ses journaux d'accès, ni dans ses journaux d'application, ni dans ses traces, et il n'écrit pas non plus l'adresse de la page de l'application d'où une recherche est lancée (le navigateur n'indique que le nom du site). Les journaux d'un composant qui n'est pas à Facteur sont hors de sa portée. Cherchez une adresse, un sujet ou un code, jamais un secret.

Les critères. sentTo, sentFrom, subject, body, otp, attachmentName. Ce sont des sous-chaînes, insensibles à la casse. % n'est pas un joker et ne cherche que le caractère %. otp cherche le code extrait et lui seul : body=882314 trouverait aussi un message qui ne fait que citer ce nombre.

Les bornes. since, hasAttachment et hasOtp rétrécissent la fenêtre au lieu de s'ajouter aux alternatives.

match. all par défaut : tous les critères doivent correspondre. Passez any pour qu'un seul suffise. Une borne n'entre jamais dans le any, sinon « depuis hier ou de Camille » vous rendrait des messages d'avant-hier.

Les longueurs maximales. En caractères : inbox 36, workspace 40, sentTo et sentFrom 254, subject, body et attachmentName 255, otp 16. Une valeur plus longue est refusée avec 400 CRITERIA_INVALID plutôt que coupée : coupée, elle trouverait d'autres messages. Un cursor fait 64 caractères au plus, et un plus long répond 400 INVALID_CURSOR. Un critère vrai ou faux (hasOtp, hasAttachment) s'écrit true ou false, et limit et wait sont des nombres.

Où chercher. inbox pour une inbox, workspace (l'adresse de l'espace) pour un espace entier, ou ni l'un ni l'autre pour tout ce que votre clé atteint.

La pagination. limit (50 par défaut, 200 au plus) et le nextCursor que la réponse renvoie, à repasser dans cursor. Reprenez-le tel quel ; il est nul sur la dernière page, ce qui évite de comparer un compte à une limite.

Le transitoire. Par défaut, la recherche ne rend que les messages parsed. Passez status=any pour voir aussi ceux qui viennent d'arriver et ceux qui ont été refusés.

Deux refus qui vous font gagner une heure

  • Un critère inconnu est refusé, pas ignoré. subjet au lieu de subject répond 400 CRITERIA_UNKNOWN en nommant le champ. Ignoré, il vous aurait rendu tous les messages de l'inbox, et votre test serait passé pour la mauvaise raison jusqu'au jour où deux messages arrivent.
  • Une inbox qui n'est pas la vôtre répond 404, pas une liste vide. Une liste vide se lit « le message n'est pas arrivé » et envoie déboguer un pipeline qui fonctionne.

Pièces jointes

attachments liste les vraies pièces jointes. inlines liste les images référencées par le corps HTML (cid:), le plus souvent un logo de signature. Les deux sont séparées pour que attachments.length ne change pas selon que l'expéditeur a ajouté une bannière à sa signature.

Vérifier le contenu sans télécharger le fichier. Comparez le checksum, qui porte l'empreinte SHA-256 du fichier reçu préfixée par sha256:, à celle de votre fichier local :

sha256sum facture-4821.pdf
# 9f2c…  facture-4821.pdf

Télécharger les octets :

curl -H "Authorization: Bearer fk_votre_cle" \
  https://api.facteur.eu/v1/attachments/<id> -o facture.pdf

Les mêmes règles d'accès que pour le reste : une clé qui n'atteint pas l'espace de travail contenant le message reçoit 404 ATTACHMENT_NOT_FOUND, qu'une pièce jointe existe réellement sous cet identifiant ou non.

stored peut valoir false. Le fichier est alors décrit (nom, taille, empreinte) mais ses octets ne sont pas conservés, et GET /attachments/<id> répond 404 ATTACHMENT_NOT_STORED. Le champ reason dit lequel des quatre cas :

reason Ce que ça veut dire Ce que vous faites
PERFORMANCE_MODE L'inbox est réglée pour ne rien stocker Changez le réglage de l'inbox si vous voulez les octets
TRIAL_INBOX L'inbox est une inbox d'essai, qui ne garde pas les pièces Utilisez une inbox de votre organisation
ATTACHMENT_TOO_LARGE Le fichier dépasse la taille permise par votre plan Changez de plan, ou testez avec un fichier plus petit
STORAGE_QUOTA_EXCEEDED Le stockage de l'organisation est plein Libérez de la place ou achetez un pack de stockage

Limites connues

  • Pas de recherche plein texte. Les critères sont des sous-chaînes. Chercher un mot avec une casse ou un accent différent ne le trouvera pas.