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

HeaderFormat
Persona-Signaturet=<unix_ts>,v1=<hex_digest>; multiple space-separated sets during secret rotation

Signature verification

  1. Read PERSONA_WEBHOOK_SECRET from settings. Missing or empty → 401 with {"detail": "webhook secret not configured"}.
  2. Parse the Persona-Signature header. During rotation the header carries multiple space-separated sets (t=...,v1=... t=...,v1=...); the first seen timestamp is used, and every v1 digest is collected as an acceptable signature. Missing header or no v1 digests → 401 with {"detail": "missing signature"}.
  3. The timestamp must parse as an integer, otherwise 401 invalid timestamp. Replay protection: the timestamp must be within _PERSONA_SIG_TOLERANCE_SECONDS = 300 of the current clock, else 401 stale timestamp.
  4. Compute expected = HMAC_SHA256(secret, f"{ts}." + raw_body).hexdigest(). If no provided v1 digest matches expected under hmac.compare_digest, return 401 invalid signature.

Payload handling

  1. Decode the raw body as JSON. Invalid JSON → 400 invalid json.
  2. Pass the parsed payload to PersonaAdapter.handle_webhook() to obtain a VerificationResult.
  3. If the result has no credential_key400 missing reference-id.
  4. Resolve the owning user and credential via find_user_and_credential_by_key(credential_key). Not found → 404 credential not found.

State machine

Adapter outcomeCredential current statusAction
unresolvedanyReturn 200 with the credential's current status and detail='unresolved'. Credential is not mutated.
approved or rejectednot 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.
approvedvendor-owned pending stateSet status='active', update verificationData.{vendor, vendorStatus, vendorReferenceId}.
rejectedvendor-owned pending stateSet 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"}.


Was this page helpful?