Demandes de vérification idempotentes : éviter les doubles vérifications et les doubles facturations

Charles Archibong, Cofondateur
Attribuez un identifiant de requête (requestId) à chaque vérification logique, enregistrez-le avant l'envoi de la requête et réutilisez-le à chaque nouvelle tentative. Un identifiant de requête déjà utilisé permet de récupérer la vérification existante au lieu d'en créer et d'en facturer une seconde.

Points clés
- Un metadata.requestId répété renvoie la vérification existante au lieu d'en créer une nouvelle.
- Générez l'identifiant de requête à partir de votre propre enregistrement et stockez-le avant la première tentative, et non à l'intérieur de la boucle de nouvelle tentative.
- Réessayez les erreurs 429, 502, 503, 504 et les échecs de connexion avec un délai d'attente ; traitez les autres réponses 4xx comme des bogues à corriger.
- Un code 202 signifie simplement que le message est en file d'attente. Un échec de vérification est un résultat à gérer, et non une erreur nécessitant une nouvelle tentative.
Pour réessayer une demande de vérification en toute sécurité, envoyez la mêmemetadata.requestIdà chaque fois que vous réessayez. Toden friandisesrequestIdcomme clé d'idempotence : si une requête arrive avec unerequestIdvotre organisation a déjà utilisé, le API Elle renvoie la vérification existante au lieu d'en créer une seconde, et vous n'êtes donc pas facturé deux fois.
Ce champ unique ne vous protège que si vous l'utilisez correctement.requestIdIl faut la générer une fois par vérification logique, l'enregistrer avant la première tentative et la réutiliser telle quelle. Générez-la dans votre boucle de nouvelle tentative : chaque nouvelle tentative deviendra ainsi une vérification entièrement nouvelle. La suite de cet article explique où la créer, quelles réponses relancer et quelles « erreurs » ne sont en réalité pas des échecs.
Que se passe-t-il en l'absence d'idempotence ?
Le cas classique est un délai d'attente dépassé après une réussite. Votre serveur envoiePOST /api/kyc/verify, le API Le système accepte la requête et met la vérification en file d'attente, mais la réponse est perdue lors du retour : coupure de la connexion mobile, délai d'attente du répartiteur de charge, redémarrage du pod. De votre côté, la requête a échoué. De notre côté, elle a abouti.
Si votre code effectue une nouvelle tentative avec un identifiant différent, la plateforme détecte une nouvelle requête et crée une seconde vérification. En production, chaque vérification effectuée est facturée sur votre solde créditeur, conformément à la documentation de l'environnement (s’ouvre dans un nouvel onglet) . Par conséquent, la réponse perdue vous a coûté une seconde vérification. De plus, deux identifiants de vérification sont associés à un même demandeur, et votre système de rapprochement doit désormais déterminer lequel est valide.
Avec le mêmerequestIdLa nouvelle tentative renvoie la vérification initiale, et la réponse perdue ne coûte rien.
Comment fonctionne requestId sur le point de terminaison de vérification ?
Elle se trouve à l'intérieur de la requête.metadataobjet. Extrait de la référence de vérification de création (s’ouvre dans un nouvel onglet) :
{
"country": "NG",
"idType": "bvn",
"idNumber": "12345678901",
"externalUserId": "user_42",
"metadata": { "requestId": "order_1001", "loanId": "loan_20191" }
}La première soumission renvoie202 Accepted:
{
"verificationId": "ver_01j9...",
"status": "processing",
"externalUserId": "user_42",
"metadata": { "loanId": "loan_20191" }
}Une soumission ultérieure avec le mêmerequestIdrenvoie cette vérification, quel que soit l'état auquel elle se trouve, par exemple{ "verificationId": "ver_01j9...", "status": "approved" }.externalUserIdet vos métadonnées sont renvoyées exactement telles qu'elles ont été enregistrées, de sorte que la nouvelle tentative et la tentative originale concordent.
Les clés sont limitées à votre organisation. Si unerequestIdentre en collision avec une personne appartenant à une autre organisation, le API retours403avec{ "error": "Forbidden" }plutôt que de divulguer quoi que ce soit concernant cette vérification.
Où devez-vous générer l'identifiant de requête ?
D'après vos propres archives, et avant d'envoyer quoi que ce soit. Une méthode qui a fait ses preuves :
Lorsqu'un client entame une vérification d'identité, créez une ligne dans votre base de données pour cette tentative (une demande, une étape d'intégration, un prêt).
Dérivez le
requestIdde cette ligne, par exemplekyc-application-8841et stockez-le sur la ligne.Envoyez la requête avec cette valeur stockée.
Lors de toute nouvelle tentative, relisez la valeur à partir de la ligne. Ne calculez jamais une nouvelle valeur.
Les trois règles suivantes s'appliquent :
N'utilisez pas de méthode aléatoire UUID généré par HTTP L' appel est un identifiant unique, ce qui est l'inverse de ce dont vous avez besoin.
Nomme-le. Un nu
1001risque d'entrer en conflit avec vos autres enregistrements au fil du temps, et un conflit inter-organisations se produit403. Un préfixe tel quekyc-application-rend la valeur sans ambiguïté.N'émettez un nouveau ticket que pour une vérification véritablement nouvelle. Si la demande d'un client est refusée et que vous décidez qu'il peut recommencer à zéro, il s'agit d'une nouvelle vérification logique avec un nouveau ticket.
requestIdRéutiliser l'ancien renverrait simplement un refus de vérification.
Si un examinateur renvoie une vérification pour que le candidat puisse refaire certaines étapes, vous n'avez pas besoin d'une nouvelle demande.requestIdUne vérification renvoyée ou vérifiée à nouveau conserve son identifiant, et la nouvelle soumission apparaît comme une nouvelle vérification.attemptdessus, comme le décrit la référence des résultats de vérification (s’ouvre dans un nouvel onglet) .
Quand le SDK soumet pour vous
La plupart des intégrations n'appellent pas/verifydirectement. Les SDK web, React Native et Flutter effectuent l'appel lorsque le candidat clique sur « Envoyer ». Ils acceptentmetadataavec votrerequestId(metadata={{ requestId: "order_1001" }}dans React Native,metadata: {'requestId': 'order_1001'}(dans Flutter), le même principe s'applique donc : transmettez une valeur dérivée de votre propre enregistrement, et non une valeur générée à chaque fois que l'écran est monté.
Quelles réponses devriez-vous réessayer ?
La documentation relative aux erreurs (s’ouvre dans un nouvel onglet) est explicite et se résume à un tableau concis.
Réponse | Réessayer ? | Ce qu'il faut faire |
|---|---|---|
| Non | Terminé. Suivez le |
| Oui | Attendez le |
| Oui | Recul exponentiel avec gigue, même |
Réinitialisation de la connexion, DNS échec, délai d'attente dépassé | Oui | Comme ci-dessus. La requête a peut-être abouti, ce qui explique précisément pourquoi… |
| Pas aveuglément | Conservez les détails de la demande et contactez le support si le problème persiste. |
| Non | Corrigez la charge utile. Lire |
| Non | Vérifiez la clé et son environnement. |
| Non | La production nécessite un profil d'entreprise approuvé. |
| Non | Ton |
La limite documentée est de 100 requêtes par tranche de 15 minutes et par client.RateLimit-Limit,RateLimit-RemainingetRateLimit-ResetEn-têtes des réponses. LectureRateLimit-RemainingPermet de ralentir le traitement par lots avant qu'il ne soit refusé, plutôt qu'après. La page relative aux limites de débit (s’ouvre dans un nouvel onglet) indique que ces limites peuvent différer entre l'environnement de test et l'environnement de production et qu'elles peuvent être ajustées pour votre compte.
À quoi ressemble une boucle de nouvelle tentative sécurisée ?
Une version minimale de Node.js, utilisant le globalfetchLe corps est sérialisé une seule fois, en dehors de la boucle, de sorte que chaque tentative envoie des octets identiques avec le mêmerequestId.
const VERIFY_URL = 'https://api.toden.co/api/kyc/verify';
const RETRYABLE = new Set([429, 502, 503, 504]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const backoff = (attempt) => Math.min(30_000, 500 * 2 ** attempt) * (0.5 + Math.random());
export async function submitVerification(body, apiKey, maxAttempts = 5) {
if (!body.metadata?.requestId) throw new Error('metadata.requestId is required');
const payload = JSON.stringify(body);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
let res;
try {
res = await fetch(VERIFY_URL, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: payload,
});
} catch (networkError) {
if (attempt === maxAttempts) throw networkError;
await sleep(backoff(attempt));
continue;
}
if (res.ok) return res.json(); // 202 for a new verification, or the existing one on a replay
if (!RETRYABLE.has(res.status) || attempt === maxAttempts) {
const detail = await res.json().catch(() => ({}));
throw Object.assign(new Error(detail.error ?? `HTTP ${res.status}`), { status: res.status, detail });
}
const resetSeconds = Number(res.headers.get('RateLimit-Reset'));
await sleep(res.status === 429 && resetSeconds > 0 ? resetSeconds * 1000 : backoff(attempt));
}
}Notez ce qui est absent : aucune nouvelle tentative sur400,401ou403et aucun nouvel identifiant nulle part. L'appelant fournit unrequestIdIl est déjà stocké.
Faut-il réessayer en cas d'échec de la vérification ?
Non, et la confusion entre les deux est une source fréquente de doublons.202Cela signifie simplement que la requête a été mise en file d'attente. La vérification peut encore se terminer.declinedparce qu'un visage ne correspondait pas, ou parce que la pièce d'identité était introuvable dans la base de données gouvernementale. Ces résultats sont transmis par webhook ou par sondage, chacun avec unreasonet une stabilitéreasonCodeIls ne le sont pas HTTP erreurs.
Si votre logique de nouvelle tentative surveille les résultats et soumet à nouveau en cas de refus, elle recevra soit la même vérification refusée (mêmerequestId) ou créer une deuxième vérification payante de la même entrée erronée (nouvellerequestIdAucune des deux solutions n'est efficace. Branchez-vous sur lereasonCodeau lieu de cela : demandez une nouvelle photo surdocument_blurry, pour un numéro corrigé suridentity_not_foundet soumettre délibérément cette personne à une nouvelle vérification ou à une vérification déjà effectuée.
Le côté webhook a-t-il également besoin d'idempotence ?
Oui. Une même requête webhook peut arriver plusieurs fois, car la livraison est réessayée jusqu'à ce que votre point de terminaison la confirme. La documentation des webhooks (s’ouvre dans un nouvel onglet) indique de dédupliquer les messages.X-Myaza-Deliveryen-tête, ou surverificationIdEnregistrez les données traitées et désactivez l'opération lors de la seconde exécution. L'idempotence de l'envoi et de la réception garantit, du point de vue du client, que le flux s'effectue une seule fois.
Liste de contrôle
Un
requestIdpar vérification logique, dérivée de votre propre enregistrement et espace de noms.Stocké avant la première tentative ; relu, jamais régénéré, lors d'une nouvelle tentative.
Passé par le SDK 's
metadataquand le SDK soumet pour vous.Nouvelles tentatives sur
429,502,503,504et les pannes de réseau uniquement, avec repli et leRateLimit-Resetvaleur.Des résultats tels que
declinedgéré parreasonCode, jamais en soumettant à nouveau.Gestionnaire de webhook dédupliqué sur
X-Myaza-DeliveryouverificationId.
Le contrat de requête complet se trouve dans le guide de vérification de création (s’ouvre dans un nouvel onglet) , et le hub développeur donne accès au reste du document. API .
Sources
Créer une vérification, Toden documentation (s’ouvre dans un nouvel onglet)
Erreurs, Toden documentation (s’ouvre dans un nouvel onglet)
Limites de débit, Toden documentation (s’ouvre dans un nouvel onglet)
Webhooks, Toden documentation (s’ouvre dans un nouvel onglet)
Environnements, Toden documentation (s’ouvre dans un nouvel onglet)

Charles Archibong
Cofondateur
Charles Archibong est le cofondateur de Toden. Il écrit sur la vérification d'identité, les technologies financières et les aspects pratiques de la création de services numériques de confiance.


