API REST de vérification d'identité. Le principe : vous créez un dossier pour votre utilisateur,
vous lui transmettez son lien privé, il capture sa pièce d'identité, un selfie et les mouvements de
tête demandés (puis, selon le parcours, un justificatif de domicile et l'origine de ses fonds), et vous
recevez la décision par webhook.
Authentification
Chaque requête porte l'en-tête Authorization: Bearer <clé>. Les clés se créent dans le tableau de
bord et ne sont montrées qu'une fois :
| Préfixe |
Environnement |
Usage |
kyc_live_… |
production |
vrais utilisateurs |
kyc_test_… |
bac à sable |
intégration et tests (simulation des décisions) |
Une clé ne voit que les données de son compte et de son environnement : toute autre ressource
répond 404. Une clé révoquée répond 401.
Cycle de vie d'un dossier
| Statut |
Signification |
created |
lien émis, l'utilisateur n'a pas commencé |
in_progress |
capture en cours |
submitted |
captures envoyées, analyse planifiée : la décision arrive au plus tard à due_at (délai réglé dans le tableau de bord, de l'instantané à 72 h) |
processing |
analyse en cours |
approved |
identité vérifiée |
declined |
refusé pour un motif robuste (pièce expirée confirmée par la MRZ, âge insuffisant, spécimen) |
review |
à examiner par un analyste (doublon, incertitude, contrôle à confirmer) |
resubmission_requested |
photo à refaire (floue, illisible, non prise en direct) : le même lien permet de recommencer, son échéance est repoussée d'au moins 24 h |
expired |
lien expiré avant la fin de la capture |
cancelled |
annulé par vous (ou effacement de la personne) |
Une incertitude de lecture ou une photo médiocre ne mène jamais à un refus, seulement à une revue ou
à une reprise. La validation automatique n'est activée qu'après calibrage du moteur sur un banc
indépendant : d'ici là, les dossiers conformes passent en review (raison auto_approval_disabled).
Vos réglages de décision (tableau de bord) fixent le délai, le refus et la validation automatiques et
les seuils des mesures du moteur ; une personne politiquement exposée déclarée ou une origine des
fonds à risque élevé passe toujours en revue (pep_declared, source_of_funds_high_risk).
Raisons
reasons liste des codes stables en snake_case, par exemple : document_expired, underage,
specimen_or_fake_document (refus) ; duplicate_document, name_mismatch, date_of_birth_mismatch,
face_mismatch, face_match_uncertain, liveness_unverified, mrz_unverified, expiry_unverified,
address_mismatch, document_too_old, documents_missing, engine_unavailable,
auto_approval_disabled, auto_decline_disabled, face_similarity_below_threshold (et les autres
*_below_threshold), pep_declared, source_of_funds_high_risk (revue) ; capture_quality avec identity_document_quality,
selfie_quality, address_document_quality (reprise) ; cancelled_by_client,
link_expired, applicant_erased.
Erreurs
Toutes les erreurs ont la même forme, sans donnée du dossier :
{"error": {"code": "validation_failed", "message": "Les données envoyées sont invalides.", "fields": {"applicant.email": ["…"]}}}
| HTTP |
Codes |
| 401 |
missing_api_key, invalid_api_key |
| 403 |
organization_inactive, sandbox_only |
| 404 |
not_found, document_unavailable |
| 409 |
invalid_state, idempotency_key_reused, idempotency_request_in_progress, verification_processing |
| 422 |
validation_failed (détail dans fields), no_flow_configured, invalid_idempotency_key |
| 429 |
rate_limited (en-tête Retry-After) |
Idempotence
Les requêtes POST acceptent l'en-tête Idempotency-Key (1 à 255 caractères ASCII). Rejouée avec la
même clé et le même corps pendant 24 h, la requête renvoie la réponse d'origine (en-tête
Idempotent-Replayed: true) sans rien recréer. La même clé avec un autre corps répond 409.
Limitation de débit
120 requêtes par minute et par clé par défaut (en-têtes X-RateLimit-Limit et
X-RateLimit-Remaining). Au-delà : 429 avec Retry-After (secondes).
Pagination
Les listes renvoient {"data": [...], "has_more": true, "next_cursor": "…"} : passez next_cursor
dans le paramètre cursor pour la page suivante (limit : 1 à 100, 25 par défaut).
Webhooks
Déclarez vos adresses de réception dans le tableau de bord (HTTPS obligatoire en production ; les
adresses privées ou internes sont refusées et les redirections ne sont jamais suivies). Chaque
changement de statut envoie un POST JSON :
{
"id": "01jb3c9w4q8t2n6m5r7k1d0f3e",
"type": "verification.approved",
"created_at": "2026-10-24T09:30:00Z",
"data": {
"verification_id": "01jb2y8q5z3x4c6v7b8n9m0k1j",
"applicant_id": "01jb2y7k4m3n2b1v0c9x8z7a6s",
"external_id": "user-48151623",
"status": "approved",
"reasons": [],
"environment": "live"
}
}
Types : verification.<statut> (par exemple verification.submitted, verification.review,
verification.approved) et webhook.test (bouton d'essai du tableau de bord). Répondez 2xx en
moins de 10 secondes ; sinon l'envoi est retenté après 1 min, 5 min, 30 min, 2 h puis 6 h. Le même
événement peut arriver deux fois : dédoublonnez sur id.
Vérifiez toujours la signature de l'en-tête Kyc-Signature: t=<horodatage>,v1=<signature> :
v1 vaut HMAC-SHA256(secret du point d'arrivée, <horodatage>.<corps brut>), en hexadécimal.
Refusez une signature invalide ou un horodatage de plus de 5 minutes (rejeu).
function kycSignatureValid(?string $header, string $body, string $secret): bool
{
if ($header === null || ! preg_match('/^t=(\d+),/', $header, $t) || abs(time() - (int) $t[1]) > 300) {
return false;
}
$expected = hash_hmac('sha256', $t[1].'.'.$body, $secret);
preg_match_all('/(?:^|,)v1=([0-9a-f]{64})/', $header, $signatures);
foreach ($signatures[1] as $v1) {
if (hash_equals($expected, $v1)) {
return true;
}
}
return false;
}
// Corps brut : file_get_contents('php://input'). Signature invalide : répondre 401.
function kycSignatureValid(header, rawBody, secret) {
const t = /^t=(\d+),/.exec(header || '');
if (!t || Math.abs(Date.now() / 1000 - Number(t[1])) > 300) return false;
const expected = Buffer.from(crypto.createHmac('sha256', secret).update(`${t[1]}.${rawBody}`).digest('hex'));
return [...(header.matchAll(/(?:^|,)v1=([0-9a-f]{64})/g))]
.some(([, v1]) => crypto.timingSafeEqual(expected, Buffer.from(v1)));
}