Webhooks allow your server to receive real-time notifications about KYC verification status changes.
What are Webhooks?
Webhooks are HTTP POST requests that Flonk sends to your server when verification events occur:
verification.completed— Automated verification finished (AI/system decision). This same event also fires when a reviewer approves a session that was in manual review. When Proof of Address is enabled in the project settings with Wait for PoA result in real time turned on, this event already contains the finalpoaStatus. With the toggle off,verification.completedfires after the identity step and a follow-upverification.updatedarrives later with the PoA outcome — see Proof of Address for the full flow.verification.status_changed— A status change made by an admin from the dashboard or as the outcome of manual review: the reviewer decision (approved/rejected; a rejection carriesrejection_reasonandreviewed_by), and — informational only — the moment a session entersmanual_revieworaction_required(the latter carries the additivenext_actionfield). The informational entry events let your KYC page show a "verification pending" state without polling.verification.updated— Verification data was manually edited by admin (document fields, uploaded files), or the asynchronous PoA review finished (deliverspoaStatusandpoaConfidenceScore).
Review entry is now announced. When automated checks route a session to
manual_revieworaction_required, an informationalverification.status_changedfires with thatnewStatus(no decision yet). The final outcome still arrives later: approval firesverification.completed; rejection firesverification.status_changedwithstatus: "rejected". These entry events are additive — integrators that only handle the reviewer decision can ignore them.
Setup
1. Create endpoint in your application
typescript1import { FlonkKYCServer } from '@flonkid/kyc/server';23const flonk = new FlonkKYCServer({4 secretKey: process.env.FLONK_SECRET_KEY!,5});67// Express webhook handler (use raw body for signature verification)8app.post('/webhooks/kyc',9 express.raw({ type: 'application/json' }),10 (req, res) => {11 const signature = req.headers['x-signature-256'] as string;1213 try {14 // Automatically detects signature format (sha256= or t=...,v1=...)15 const event = flonk.webhooks.constructEvent(16 req.body.toString(),17 signature,18 process.env.FLONK_WEBHOOK_SECRET!,19 );2021 // Process the verified event22 const verification = event.data.object;23 console.log(`Verification ${verification.id}: ${verification.status}`);24 console.log('Extracted data:', verification.extracted_data);2526 res.sendStatus(200);27 } catch (err) {28 console.error('Webhook verification failed:', err instanceof Error ? err.message : err);29 res.sendStatus(400);30 }31 }32);
2. Configure URL in Flonk Dashboard
- Go to Flonk Dashboard
- Select Settings → Webhooks
- Add your endpoint URL:
https://yourdomain.com/api/kyc/webhook - Save and get your webhook secret
Webhook Payload Format
All webhook payloads follow this structure:
json{"id": "evt_1234567890","type": "verification.completed","created": 1640995200,"livemode": true,"data": {"object": {"id": "verification_attempt_id","client_id": "user_1761052168279","client_metadata": {},"status": "completed","confidence": 0.9442,"document_type": "id_card","extracted_data": {"full_name": "JOHN DOE","first_name": "JOHN","last_name": "DOE","date_of_birth": "1990-01-15","nationality": "UKR","sex": "M"},"duplicate_verification": {"is_duplicate": true,"previous_verification_id": "cmohkbzfq000686fezoudgcuc","previous_verified_at": "2026-04-27T19:01:03.000Z","match_type": "document_number","similarity_score": 1},"created_at": "2025-10-21T13:10:10.584Z","test_mode": false}}}
The signature is sent in the headers (not in the body). Flonk sends two, both HMAC-SHA256 with your webhook secret:
X-Signature: t=1700000000, v1=abc123... ← also replay-protected (signed timestamp)X-Signature-256: sha256=def456... ← HMAC of the bodyX-Event-Type: verification.completedX-Event-Id: evt_1234567890
Both are fully valid signatures — both prove the request came from Flonk and
the body wasn't tampered with. The only difference: X-Signature also signs a
timestamp, so a captured request is rejected outside the skew window (default
5 min) — i.e. replay protection. X-Signature-256 has no timestamp, so a
captured request stays valid; close that gap by deduping on event.id
(recommended anyway). Prefer X-Signature when you want replay protection at the
signature layer; X-Signature-256 is perfectly safe for authenticity + integrity.
Event Types
verification.completed
Sent when automated verification finishes (AI/system decision). Status is always completed.
Duplicate verification signal
When a verification completes successfully, Flonk checks whether the same document or person was already verified in the same project. If a match is found, the verification.completed webhook includes an optional duplicate_verification object.
This is a signal for your own risk logic. Flonk still completes the verification normally; your application decides whether to allow the user, block the user, send the case to manual review, or apply a custom threshold.
Duplicate checks are scoped to a project. The same person or document can be verified in a different project without being marked as a duplicate for your project.
Example:
json{"duplicate_verification": {"is_duplicate": true,"previous_verification_id": "cmohkbzfq000686fezoudgcuc","previous_verified_at": "2026-04-27T19:01:03.000Z","match_type": "document_number","similarity_score": 1}}
Fields:
| Field | Type | Description |
|---|---|---|
is_duplicate | boolean | Always true when the object is present |
previous_verification_id | string | ID of the earlier successful verification in the same project |
previous_verified_at | string | ISO 8601 timestamp of the earlier verification |
match_type | "document_number" | "personal_data" | How the duplicate was detected |
similarity_score | number | Match confidence from 0.0 to 1.0 |
match_type values:
| Value | Meaning | Typical score |
|---|---|---|
document_number | Exact blind-index match on the document number. This usually means the same physical document was used before. | 1.0 |
personal_data | Blind-index match on last name + date of birth. This can catch the same person using another document type, for example passport first and ID card later. | 0.95 |
When no duplicate is detected, duplicate_verification is omitted from the webhook payload.
verification.status_changed
Sent when a verification's status changes. Two kinds of change use this event:
- Reviewer decision —
statusisapprovedorrejected, made by an admin from the dashboard or as the outcome of manual review. A rejection carriesrejection_reasonandreviewed_by;previous_statusismanual_review. - Review entry (informational) —
statusismanual_revieworaction_required, fired the moment automated checks route the session there. No decision has been made yet;previous_statusisprocessing. Anaction_requiredentry additionally carriesnext_action(resubmit_front/resubmit_back). These entry events are additive — ignore them if you only act on the final decision.
A manual-review approval does not use this event — it fires
verification.completedinstead. Only rejections (and the informational review-entry events above) arrive asverification.status_changed.
Additional fields in data.object:
| Field | Type | Description |
|---|---|---|
previous_status | string | Status before the change (e.g. completed, manual_review, processing) |
rejection_reason | string? | Reason for rejection (only when status is rejected) |
reviewed_by | string? | Identifier of who made the decision (admin email or reviewer role) |
next_action | string? | On an action_required entry: the side to re-upload — resubmit_front or resubmit_back |
json{"id": "evt_1234567891","type": "verification.status_changed","created": 1640995300,"livemode": true,"data": {"object": {"id": "verification_attempt_id","client_id": "user_1761052168279","status": "rejected","confidence": 0.95,"document_type": "passport","extracted_data": {"full_name": "JOHN DOE","first_name": "JOHN","last_name": "DOE"},"previous_status": "completed","rejection_reason": "Document expired","reviewed_by": "admin@company.com","created_at": "2025-10-21T13:10:10.584Z"}}}
verification.updated
Sent when an admin manually edits verification data (document fields, uploads new files) from the dashboard.
Additional fields in data.object:
| Field | Type | Description |
|---|---|---|
updated_at | string | ISO 8601 timestamp of the update |
updated_by | string? | Email of the admin who made the edit |
extracted_data | object | Updated extracted data (issue_date, expiry_date, place_of_birth, …). document_number is not included — see the note below. |
json{"id": "evt_1234567892","type": "verification.updated","created": 1640995400,"livemode": true,"data": {"object": {"id": "verification_attempt_id","client_id": "user_1761052168279","status": "completed","document_type": "passport","extracted_data": {"full_name": "JOHN DOE","first_name": "JOHN","last_name": "DOE","date_of_birth": "1990-01-15","nationality": "UKR","sex": "M","issue_date": "2020-01-01","expiry_date": "2030-01-01","place_of_birth": "Kyiv"},"created_at": "2025-10-21T13:10:10.584Z","updated_at": "2026-03-31T12:00:00.000Z","updated_by": "admin@company.com","test_mode": false}}}
Handling Event Types
typescriptconst event = flonk.webhooks.constructEvent(rawBody, signature, secret);switch (event.type) {case 'verification.completed':// Automated AI decisionawait handleAutomatedVerification(event.data.object);break;case 'verification.status_changed':// Admin manual decisionawait handleAdminDecision(event.data.object);break;case 'verification.updated':// Admin edited verification dataawait handleDataUpdate(event.data.object);break;}
Signature Verification (Security)
Important: Always verify webhook signature for security!
Using the SDK (Recommended)
typescriptimport { FlonkKYCServer } from '@flonkid/kyc/server';const flonk = new FlonkKYCServer({secretKey: process.env.FLONK_SECRET_KEY!,});// Automatically detects signature format (t=,v1= or sha256=)const event = flonk.webhooks.constructEvent(rawBody, // raw request body as stringsignatureHeader, // X-Signature header (recommended) or X-Signature-256webhookSecret, // your webhook secret);console.log(event.type); // 'verification.completed', 'verification.status_changed', or 'verification.updated'console.log(event.data.object.id); // verification ID
Manual Verification
If you verify without the SDK, use a constant-time comparison —
=== on the signature string leaks how many leading characters matched via
timing and is exploitable. Use crypto.timingSafeEqual:
typescriptimport crypto from 'crypto';function verifyWebhookSignature(payload: string,signature: string,secret: string): boolean {const expected ='sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex');// Constant-time compare. HMAC both sides so the buffers are equal length// regardless of input, then timingSafeEqual — no early length/return branch.const key = crypto.randomBytes(32);const a = crypto.createHmac('sha256', key).update(signature).digest();const b = crypto.createHmac('sha256', key).update(expected).digest();return crypto.timingSafeEqual(a, b);}
Prefer the SDK's
constructEvent— it does exactly this for you and also handles the timestampedt=,v1=format.
Best Practices
1. Idempotency
Flonk delivers at-least-once (see Retry Logic), so the same
event can arrive more than once. Dedupe by event.id — store the ids you've
processed and skip ones you've already seen. This is the same pattern you'd use
for Stripe; keep it in your own durable store, separate from the signature check.
A unique index on the event id (atomic, durable) is the simplest:
typescriptapp.post('/api/kyc/webhook', async (req, res) => {const event = flonk.webhooks.constructEvent(req.rawBody, req.headers['x-signature'], secret);try {await db.webhookEvents.create({ data: { eventId: event.id } }); // unique constraint} catch (e) {if (isUniqueViolation(e)) return res.status(200).end(); // already processedthrow e;}await processWebhook(event);res.status(200).end();});
Or with Redis — SET NX returns null when the key already existed:
typescriptconst fresh = await redis.set(`whk:${event.id}`, '1', 'PX', 6 * 60_000, 'NX');if (fresh === null) return res.status(200).end(); // retry — already handled
2. Quick Response
Respond quickly (< 5 seconds), process asynchronously:
typescript@Post('webhooks/flonk')async handleWebhook(@Body() payload: any) {// Quick validationif (!this.verifySignature(payload)) {throw new UnauthorizedException();}// Add to processing queueawait this.queue.add('process-webhook', payload);// Respond immediatelyreturn { received: true };}
3. Retry Logic
Flonk automatically retries webhooks up to 3 times on errors:
- 1st attempt: immediately
- 2nd attempt: after 1 minute
- 3rd attempt: after 5 minutes
4. Logging
Log all received webhooks:
typescriptlogger.info('Webhook received', {event: payload.event,sessionId: payload.data.sessionId,timestamp: payload.timestamp});