Flonk
Flonk Docs

Webhooks

Setup and handle webhook notifications from Flonk KYC

Last updated: 8/25/2026
5 min read

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 final poaStatus. With the toggle off, verification.completed fires after the identity step and a follow-up verification.updated arrives 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 carries rejection_reason and reviewed_by), and — informational only — the moment a session enters manual_review or action_required (the latter carries the additive next_action field). 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 (delivers poaStatus and poaConfidenceScore).

Review entry is now announced. When automated checks route a session to manual_review or action_required, an informational verification.status_changed fires with that newStatus (no decision yet). The final outcome still arrives later: approval fires verification.completed; rejection fires verification.status_changed with status: "rejected". These entry events are additive — integrators that only handle the reviewer decision can ignore them.

Setup

1. Create endpoint in your application

1import { FlonkKYCServer } from '@flonkid/kyc/server';
2
3const flonk = new FlonkKYCServer({
4 secretKey: process.env.FLONK_SECRET_KEY!,
5});
6
7// 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;
12
13 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 );
20
21 // Process the verified event
22 const verification = event.data.object;
23 console.log(`Verification ${verification.id}: ${verification.status}`);
24 console.log('Extracted data:', verification.extracted_data);
25
26 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);
typescript

2. Configure URL in Flonk Dashboard

  1. Go to Flonk Dashboard
  2. Select SettingsWebhooks
  3. Add your endpoint URL: https://yourdomain.com/api/kyc/webhook
  4. Save and get your webhook secret

Webhook Payload Format

All webhook payloads follow this structure:

{
"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
}
}
}
json

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 body
X-Event-Type: verification.completed
X-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:

{
"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
}
}
json

Fields:

FieldTypeDescription
is_duplicatebooleanAlways true when the object is present
previous_verification_idstringID of the earlier successful verification in the same project
previous_verified_atstringISO 8601 timestamp of the earlier verification
match_type"document_number" | "personal_data"How the duplicate was detected
similarity_scorenumberMatch confidence from 0.0 to 1.0

match_type values:

ValueMeaningTypical score
document_numberExact blind-index match on the document number. This usually means the same physical document was used before.1.0
personal_dataBlind-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 decisionstatus is approved or rejected, made by an admin from the dashboard or as the outcome of manual review. A rejection carries rejection_reason and reviewed_by; previous_status is manual_review.
  • Review entry (informational)status is manual_review or action_required, fired the moment automated checks route the session there. No decision has been made yet; previous_status is processing. An action_required entry additionally carries next_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.completed instead. Only rejections (and the informational review-entry events above) arrive as verification.status_changed.

Additional fields in data.object:

FieldTypeDescription
previous_statusstringStatus before the change (e.g. completed, manual_review, processing)
rejection_reasonstring?Reason for rejection (only when status is rejected)
reviewed_bystring?Identifier of who made the decision (admin email or reviewer role)
next_actionstring?On an action_required entry: the side to re-upload — resubmit_front or resubmit_back
{
"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"
}
}
}
json

verification.updated

Sent when an admin manually edits verification data (document fields, uploads new files) from the dashboard.

Additional fields in data.object:

FieldTypeDescription
updated_atstringISO 8601 timestamp of the update
updated_bystring?Email of the admin who made the edit
extracted_dataobjectUpdated extracted data (issue_date, expiry_date, place_of_birth, …). document_number is not included — see the note below.
{
"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
}
}
}
json

Handling Event Types

const event = flonk.webhooks.constructEvent(rawBody, signature, secret);
switch (event.type) {
case 'verification.completed':
// Automated AI decision
await handleAutomatedVerification(event.data.object);
break;
case 'verification.status_changed':
// Admin manual decision
await handleAdminDecision(event.data.object);
break;
case 'verification.updated':
// Admin edited verification data
await handleDataUpdate(event.data.object);
break;
}
typescript

Signature Verification (Security)

Important: Always verify webhook signature for security!

import { 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 string
signatureHeader, // X-Signature header (recommended) or X-Signature-256
webhookSecret, // your webhook secret
);
console.log(event.type); // 'verification.completed', 'verification.status_changed', or 'verification.updated'
console.log(event.data.object.id); // verification ID
typescript

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:

import 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);
}
typescript

Prefer the SDK's constructEvent — it does exactly this for you and also handles the timestamped t=,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:

app.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 processed
throw e;
}
await processWebhook(event);
res.status(200).end();
});
typescript

Or with Redis — SET NX returns null when the key already existed:

const fresh = await redis.set(`whk:${event.id}`, '1', 'PX', 6 * 60_000, 'NX');
if (fresh === null) return res.status(200).end(); // retry — already handled
typescript

2. Quick Response

Respond quickly (< 5 seconds), process asynchronously:

@Post('webhooks/flonk')
async handleWebhook(@Body() payload: any) {
// Quick validation
if (!this.verifySignature(payload)) {
throw new UnauthorizedException();
}
// Add to processing queue
await this.queue.add('process-webhook', payload);
// Respond immediately
return { received: true };
}
typescript

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:

logger.info('Webhook received', {
event: payload.event,
sessionId: payload.data.sessionId,
timestamp: payload.timestamp
});
typescript