Webhooks
When a verification reaches a final state (pipeline decision or a reviewer’s
manual decision), BAQSHI-KYC POSTs to the webhook_url you registered at creation:
{ "type": "verification.decided", "verification_id": "ver_…", "status": "approved" }The payload deliberately carries no identity data — if your webhook endpoint is ever compromised or misrouted, nothing personal leaks. Fetch details with your API key after verifying the signature.
Verify every delivery
Section titled “Verify every delivery”Headers on each request:
| Header | Content |
|---|---|
X-Baqshi-Timestamp | Unix seconds at send time |
X-Baqshi-Signature | Hex HMAC-SHA256 over "{timestamp}.{raw_body}" |
The signing key is your client’s own whsec_… webhook signing secret, issued
alongside your API key (verifications created with an operator/root key are
signed with the global WEBHOOK_SIGNING_KEY instead).
import hashlib, hmac, time
def verify(raw_body: bytes, headers, signing_key: str) -> bool: ts = headers["X-Baqshi-Timestamp"] if abs(time.time() - int(ts)) > 300: # reject replays older than 5 min return False expected = hmac.new( signing_key.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, headers["X-Baqshi-Signature"])Rules that matter:
- Sign over the raw bytes — verify before JSON-parsing; re-serialized JSON won’t match.
- Use constant-time comparison (
hmac.compare_digest), never==. - Enforce the timestamp window — the signature alone doesn’t stop replays.
- The signing key is shared out-of-band by the operator (in the reference
deployment: the
WEBHOOK_SIGNING_KEYRailway variable).
Delivery semantics
Section titled “Delivery semantics”- At-least-once: up to 3 attempts with backoff (1s, 5s, 15s). Make your handler
idempotent on
verification_id+status. - Ordering: a verification can fire twice — once for
manual_review, later for the reviewer’sapproved/rejected. Treat the latest status as truth; better, re-fetchGET /v1/verifications/{id}on every delivery. - Timeout: respond
2xxwithin 10 seconds — do real work async. - No delivery is not no decision: if all attempts fail (your endpoint was down),
the verification still holds its final status. Reconcile with a periodic poll of
any
verification_ids you’re awaiting.