Compliance - Webhooks
Location: api/nextango/apps/compliance/views_kyc.py
Last Updated: 2026-07-06
Overview
KYC vendors notify the platform of inquiry outcomes via signed webhooks. Each vendor has its own endpoint under /compliance/kyc/<vendor>/webhook/. The webhook view verifies the HMAC signature over the raw request body before parsing the payload, delegates to the vendor adapter for payload interpretation, and then applies the resulting VerificationResult to the owning credential.
Signature verification is not optional. Every webhook endpoint mutates credential state and must reject any request whose signature does not match.
POST /compliance/kyc/persona/webhook/
Class: PersonaWebhookView
Permission: AllowAny (HMAC signature is the authorisation)
Headers
| Header | Format |
|---|---|
Persona-Signature | t=<unix_ts>,v1=<hex_digest>; multiple space-separated sets during secret rotation |
Signature verification
- Read
PERSONA_WEBHOOK_SECRETfrom settings. Missing or empty →401with{"detail": "webhook secret not configured"}. - Parse the
Persona-Signatureheader. During rotation the header carries multiple space-separated sets (t=...,v1=... t=...,v1=...); the first seen timestamp is used, and everyv1digest is collected as an acceptable signature. Missing header or nov1digests →401with{"detail": "missing signature"}. - The timestamp must parse as an integer, otherwise
401 invalid timestamp. Replay protection: the timestamp must be within_PERSONA_SIG_TOLERANCE_SECONDS = 300of the current clock, else401 stale timestamp. - Compute
expected = HMAC_SHA256(secret, f"{ts}." + raw_body).hexdigest(). If no providedv1digest matchesexpectedunderhmac.compare_digest, return401 invalid signature.
Payload handling
- Decode the raw body as JSON. Invalid JSON →
400 invalid json. - Pass the parsed payload to
PersonaAdapter.handle_webhook()to obtain aVerificationResult. - If the result has no
credential_key→400 missing reference-id. - Resolve the owning user and credential via
find_user_and_credential_by_key(credential_key). Not found →404 credential not found.
State machine
| Adapter outcome | Credential current status | Action |
|---|---|---|
unresolved | any | Return 200 with the credential's current status and detail='unresolved'. Credential is not mutated. |
approved or rejected | not in {pending_verification, pending_vendor_response} | Return 200 with the credential's current status and detail='credential not in vendor-owned pending state'. Credential is not mutated. |
approved | vendor-owned pending state | Set status='active', update verificationData.{vendor, vendorStatus, vendorReferenceId}. |
rejected | vendor-owned pending state | Set status='rejected', rejectionReason='KYC vendor status: <vendor_status>', update verificationData.{vendor, vendorStatus, vendorReferenceId}. |
The vendor-owned pending-state gate preserves audit fields written by a human reviewer (approvedBy, rejectedBy); once a credential has entered pending_employee_review or moved past any pending status, a late vendor webhook is acknowledged but does not overwrite the human decision.
The webhook only ever writes verificationData.vendor, verificationData.vendorStatus, and verificationData.vendorReferenceId. It never writes approvedBy, approvedAt, rejectedBy, or rejectedAt; those fields are reserved for the human-review workflow in RegulatedCustomerOnboardingService.
Response shapes
200 OK (resolved):
{"status": "active"}
200 OK (unresolved):
{"status": "pending_verification", "detail": "unresolved"}
200 OK (state skipped):
{"status": "rejected", "detail": "credential not in vendor-owned pending state"}
400: {"detail": "invalid json"} or {"detail": "missing reference-id"}.
401: {"detail": "<reason>"} where reason is one of: webhook secret not configured, missing signature, invalid timestamp, stale timestamp, invalid signature.
404: {"detail": "credential not found"}.