Événements webhook

Référence complète de chaque type d'événement webhook de Certivant — schéma de l'enveloppe, structures de charge utile par événement, modèle d'idempotence et règles d'ordre.

Chaque charge utile de webhook partage la même enveloppe externe. Seul le bloc data change selon le type d'événement.

{
  "event": "kyc.request.approved",
  "requestId": "req_01J...",
  "organizationId": "org_01H...",
  "externalRef": "user-9281",
  "subTenantName": "Acme Corp",
  "subTenantExternalId": "cust_8842",
  "status": "APPROVED",
  "timestamp": "2026-05-27T10:04:21.000Z",
  "data": { /* event-specific */ }
}
ChampTypeRemarques
eventchaîneLe type d'événement. Commence toujours par kyc.
requestIdchaîneL'identifiant de demande Certivant
organizationIdchaîneL'organisation propriétaire de la demande
externalRefchaîne | nullLa clé étrangère que vous avez définie à la création
subTenantNamechaîne | nullMode partenaire : le nom d'affichage du client final défini sur la demande, le cas échéant
subTenantExternalIdchaîne | nullMode partenaire : votre identifiant de client final défini sur la demande — permet d'acheminer l'événement vers le bon client sans recherche
statusénumérationStatut actuel de la demande au moment de l'événement
timestampchaîne (ISO-8601)Moment où l'événement a été mis en file d'attente
dataobjet | nullCharge utile propre à l'événement (voir ci-dessous)

Catalogue des événements

kyc.webhook.test

Un test de connectivité — envoyé lorsque vous cliquez sur Tester pour une URL de webhook (pour tout le locataire, sous Paramètres KYC → Développeur, ou par formulaire dans l'éditeur de formulaire). Il ne s'agit pas d'un véritable événement de vérification : il ne contient pas de requestId et ne modifie rien de votre côté. Son seul objectif est de confirmer que votre point de terminaison est accessible.

Il comporte un indicateur explicite test: true au premier niveau, afin que votre gestionnaire puisse court-circuiter le traitement et répondre 2xx immédiatement — sans rechercher de demande, sans vérifier le status, et même sans vérifier la signature (il n'y a rien à protéger, car un ping ne modifie aucun état).

{
  "event": "kyc.webhook.test",
  "test": true,
  "organizationId": "org_...",
  "formId": "form_...",        // present only for the per-form test
  "formName": "KYC — Identity Verification",
  "timestamp": "...",
  "data": { "message": "This is a test webhook from your KYC platform." }
}

Gestionnaire recommandé :

const payload = JSON.parse(rawBody);
// Reachability ping — ack immediately, no auth needed.
if (payload.test === true || payload.event === "kyc.webhook.test") {
  return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
// …verify the signature and process real events below…

Les véritables événements de résultat (dont le status figure dans l'énumération du cycle de vie) doivent tout de même faire l'objet d'une vérification de signature avant que vous n'agissiez : voir Vérification de la signature. L'événement de test est lui aussi signé (lorsqu'un secret est défini) — avec le même en-tête X-Certivant-Signature-V2 que les événements réels, et un horodatage en secondes Unix — vous pouvez donc le vérifier, sans y être obligé.

Le bouton Envoyer un événement de test du tableau de bord peut également envoyer une charge utile exemple de kyc.request.created, kyc.request.approved, kyc.request.rejected ou kyc.request.escalated. Les exemples ont la forme réelle de l'enveloppe, portent "test": true, utilisent un faux requestId (req_sample_…) et data.sample: true, et ne sont jamais enregistrés comme de véritables événements. Ne les traitez pas dans les chemins de code de production : testez d'abord payload.test === true.

kyc.request.created

Déclenché immédiatement après le retour 201 de POST /api/v1/kyc/requests. Utile si vous voulez confirmer la réception sans attendre que le demandeur soumette.

{
  "event": "kyc.request.created",
  "requestId": "req_...",
  "organizationId": "org_...",
  "externalRef": "user-9281",
  "status": "PENDING_UPLOAD",
  "timestamp": "...",
  "data": null
}

kyc.request.processing

Déclenché dès que la demande passe à PROCESSING — c'est-à-dire lorsque le demandeur (via une session hébergée ou une soumission de formulaire) ou vous-même (via POST /requests/:id/submit) l'avez soumise. C'est le signal « soumis » : il arrive avant toute décision, de sorte que votre application peut faire passer l'utilisateur à un état « en cours d'examen » dès qu'il a terminé ses envois.

{
  "event": "kyc.request.processing",
  "requestId": "req_...",
  "externalRef": "user-9281",
  "status": "PROCESSING",
  "timestamp": "...",
  "data": null
}

Réagissez à cet événement lorsque vous voulez afficher la progression (« Votre vérification est en cours d'examen ») entre la soumission et l'événement final approved / rejected / escalated. Il contient externalRef et les champs de sous-locataire, ce qui vous permet de l'acheminer vers le bon utilisateur sans recherche.


kyc.document.uploaded

Déclenché à chaque fois qu'un document est rattaché à une demande. Utile si votre application affiche en temps réel un indicateur « documents reçus jusqu'ici ».

{
  "event": "kyc.document.uploaded",
  "requestId": "req_...",
  "status": "PENDING_UPLOAD",
  "data": {
    "documentId": "doc_...",
    "documentType": "GOVERNMENT_ID",
    "side": "front",
    "mimeType": "image/jpeg",
    "fileSize": 412938
  }
}

kyc.check.completed

Déclenché à la fin de chaque contrôle de vérification. Une seule demande peut produire plusieurs de ces événements — un pour l'authenticité du document, un pour la correspondance faciale, un pour la vivacité, etc.

{
  "event": "kyc.check.completed",
  "requestId": "req_...",
  "status": "PROCESSING",
  "data": {
    "checkType": "face_match",
    "result": "pass",
    "score": 0.91,
    "details": { "method": "deep_face_match" }
  }
}

Valeurs possibles de checkType : document_authenticity, ocr, face_match, liveness, sanctions_screening, pep_screening, adverse_media, proof_of_address, phone_verification, email_verification, registry_lookup (KYB).


kyc.request.approved

L'événement majeur : la demande est approuvée. Il est déclenché aussi bien lorsque le moteur automatisé l'accepte que lorsqu'un réviseur humain approuve manuellement une demande escaladée (ou précédemment rejetée automatiquement) depuis le tableau de bord ou l'API /decision. Pour les approbations manuelles, data.decision vaut "approve" et data.reason contient la note du réviseur ; les champs détaillés du moteur (score, factors, …) ne sont présents que pour les décisions automatisées.

{
  "event": "kyc.request.approved",
  "requestId": "req_...",
  "status": "APPROVED",
  "data": {
    "score": 94,
    "decision": "approve",
    "factors": [
      { "name": "document_authenticity", "weight": 0.3, "score": 0.96 },
      { "name": "face_match", "weight": 0.3, "score": 0.91 },
      { "name": "liveness", "weight": 0.2, "score": 0.98 },
      { "name": "sanctions_screening", "weight": 0.2, "score": 1.0 }
    ],
    "document": {
      "type": "GOVERNMENT_ID",
      "issuingCountry": "GB",
      "expiryDate": "2031-04-12",
      "isExpired": false
    },
    "extracted": {
      "fullName": "Alex Singh",
      "dateOfBirth": "1988-11-03",
      "documentNumber": "GBR-1234567",
      "address": "1 Example Street, London",
      "nationality": "GB"
    },
    "liveness": { "isLive": true, "confidence": 0.98 },
    "faceMatch": { "isMatch": true, "confidence": 0.91 },
    "sanctions": { "isMatch": false, "topScore": 0, "listsChecked": ["OFAC", "UN", "EU", "UK_HMT"] }
  }
}

Le bloc data.extracted est une mine d'or : enregistrez ces valeurs dans la fiche de l'utilisateur afin de ne pas redemander au demandeur les mêmes données deux fois.


kyc.request.rejected

La demande est rejetée — soit le moteur automatisé l'a rejetée (consultez data.disqualifiers et data.factors pour en connaître les motifs), soit un réviseur humain a rejeté manuellement une demande escaladée (data.decision = "reject", data.reason = la note du réviseur).

{
  "event": "kyc.request.rejected",
  "requestId": "req_...",
  "status": "REJECTED",
  "data": {
    "score": 18,
    "decision": "reject",
    "disqualifiers": ["document_expired", "liveness_failed"],
    "reviewTriggers": [],
    "reason": "Document expired (2024-01-01) and liveness confidence below threshold (0.22).",
    "factors": [
      { "name": "document_authenticity", "weight": 0.3, "score": 0.0, "verdict": "fail" },
      { "name": "liveness", "weight": 0.2, "score": 0.22, "verdict": "fail" }
    ],
    "document": {
      "type": "GOVERNMENT_ID",
      "expiryDate": "2024-01-01",
      "isExpired": true
    },
    "liveness": { "isLive": false, "confidence": 0.22 }
  }
}

Vous pouvez soumettre à nouveau la demande via POST /requests/:id/resubmit si votre politique le permet.


kyc.request.escalated

Le moteur n'était pas assez sûr de lui : scores limites, correspondance partielle avec les sanctions, document de mauvaise qualité, ou tout cas ayant dépassé le seuil d'escalade de votre organisation. Une personne de votre organisation doit examiner le dossier.

{
  "event": "kyc.request.escalated",
  "requestId": "req_...",
  "status": "ESCALATED",
  "data": {
    "score": 62,
    "decision": "escalate",
    "reviewTriggers": ["sanctions_partial_match", "low_face_match_confidence"],
    "factors": [...],
    "sanctions": {
      "isMatch": true,
      "topScore": 0.72,
      "listsChecked": ["OFAC", "UN", "EU", "UK_HMT"]
    },
    "faceMatch": { "isMatch": true, "confidence": 0.68 }
  }
}

Un réviseur ouvre la demande dans le tableau de bord et décide. Lorsqu'il le fait, vous recevez un second webhook — kyc.request.approved ou kyc.request.rejected — avec la décision humaine consignée dans le journal d'audit.


kyc.request.cancelled

Déclenché lorsqu'une demande est explicitement annulée (actuellement uniquement depuis le tableau de bord — aucun point de terminaison API).

{
  "event": "kyc.request.cancelled",
  "requestId": "req_...",
  "status": "CANCELLED",
  "data": { "reason": "applicant_withdrew", "cancelledBy": "user_..." }
}

Événements de vérification en direct — conference.*

Les événements de la vérification Live ID (session vidéo en direct animée par un humain) utilisent leur propre corps au format de conférence — conferenceId, kycRequestId, applicantId, organizationId, votre externalRef, le status de la conférence et les horodatages — au lieu de l'enveloppe kyc.request.* ci-dessus. Ils sont déclenchés aussi bien pour les conférences planifiées depuis le tableau de bord que pour celles planifiées par l'API.

ÉvénementDéclenché lorsque
conference.scheduledUne vérification en direct est créée.
conference.rescheduledElle est déplacée à une nouvelle heure (previousScheduledFor).
conference.cancelledElle est annulée — par l'API, ou parce que le demandeur a refusé le consentement (cancellation).
conference.startedL'animateur démarre la session (une fois par démarrage).
conference.completedL'animateur enregistre la décision (decision.decision, decision.outcome, notes, indicateur d'enregistrement, certificat).
{
  "event": "conference.completed",
  "conferenceId": "cmf...",
  "kycRequestId": "req_...",
  "applicantId": "cma...",
  "organizationId": "org_...",
  "externalRef": "order-10042",
  "status": "completed",
  "decision": { "decision": "verified", "outcome": "approved", "requestStatus": "APPROVED", "notes": "..." },
  "timestamp": "..."
}

Charge utile complète et API de planification : API de vérification en direct.


Idempotence

Certivant peut livrer le même événement plus d'une fois (coupures réseau, nouvelles tentatives — voir Nouvelles tentatives). Rendez votre gestionnaire idempotent.

L'en-tête X-Certivant-Event-Id (également envoyé sous le nom X-KYC-Event-Id) est unique pour chaque événement. Modèle courant :

async function handleWebhook(rawBody: string, headers: Headers) {
  const eventId = headers.get("X-KYC-Event-Id")!;

  // Skip if already processed
  const seen = await db.webhookEvent.findUnique({ where: { id: eventId } });
  if (seen) return;

  const payload = JSON.parse(rawBody);

  // Do the work + record the event in a single transaction
  await db.$transaction([
    db.webhookEvent.create({ data: { id: eventId, eventType: payload.event } }),
    ...yourBusinessLogic(payload),
  ]);
}

X-KYC-Event-Id est stable d'une tentative à l'autre : le même événement logique conserve le même identifiant, quel que soit le nombre de tentatives de livraison.

Ordre des événements

Les événements concernant des demandes différentes peuvent arriver dans n'importe quel ordre. Les événements concernant la même demande sont mis en file d'attente dans l'ordre de leur génération, mais les nouvelles tentatives peuvent les entremêler : par exemple, un événement processing bloqué peut arriver après un événement approved pour la même demande.

Modèle défensif : utilisez toujours le champ status comme source de vérité, et ignorez les événements dont le status est « plus ancien » que celui que vous avez déjà enregistré. Le cycle de vie n'avance que dans un sens (PENDING_UPLOAD → PROCESSING → APPROVED/REJECTED/ESCALATED) : si votre base indique que la demande est APPROVED et qu'un événement kyc.request.processing tardif arrive, éliminez-le.

Tester les gestionnaires d'événements en local

Deux boutons du tableau de bord vous aident ici :

  • Tester (à côté de n'importe quelle URL de webhook, pour tout le locataire ou par formulaire) envoie un ping de connectivité kyc.webhook.test (test: true). Utilisez-le pour confirmer que votre point de terminaison est accessible et renvoie 2xx — il n'a besoin d'aucune donnée de demande ni d'aucun secret configuré.
  • Envoyer un événement de test envoie une charge utile complète kyc.request.approved avec des données d'exemple, afin que vous puissiez exercer votre véritable chemin de traitement des résultats.

Pour tester d'autres types d'événements, écrivez un petit script qui construit la même enveloppe et l'envoie en POST vers votre URL de développement via un tunnel :

# tunnel localhost:3000 to a public URL
ngrok http 3000

# Then in the dashboard webhook settings, paste the ngrok URL,
# trigger real test requests via the API, and watch your logs.