Webhooks
In this guide, we will look at how to register and consume webhooks to integrate your app with bronID. With webhooks, your app can know when something happens in bronID, such as a verification status change, risk update, or screening completion.
What are Webhooks?
Webhooks are real-time notifications that bronID sends to your application whenever something important changes in your verifications. Rather than repeatedly polling the bronID API to check if a verification is complete, webhooks push updates directly to your server as soon as events occur. This enables you to respond immediately to status changes and provide timely feedback to your customers throughout the verification process.
You configure the URL at which you would like to receive the webhooks in the bronID Portal. bronID will send HTTP POST requests to this endpoint whenever a verification transitions between states, allowing your application to stay synchronized with the verification lifecycle.
How to Use Webhooks
1. Configure Your Webhook Endpoints
Set up a publicly accessible HTTPS endpoint where your application can receive POST requests from bronID. Configure this URL in the bronID Portal under Settings » Webhooks » Endpoints.
Your endpoint should be able to:
- Accept HTTP POST requests
- Return a
200 OKresponse to acknowledge receipt - Process webhook payloads asynchronously to avoid timeout issues
Delivery rules
- Webhook URLs must use HTTPS.
2. Listen for Webhook Notifications
When bronID sends a webhook, your endpoint receives the payload containing the current verification status.
Based on the verificationStatus field, you should update your application state and communicate
the appropriate next steps to your customer.
Example webhook handling logic
Handle webhook notifications
// Webhook endpoint handler in your application
app.post('/webhooks/bronid', async (req, res) => {
const webhook = req.body;
// Acknowledge receipt immediately
res.status(200).send('OK');
// Process webhook asynchronously
await processWebhook(webhook);
});
async function processWebhook(webhook) {
const { trace, parentTrace, verificationStatus, submissionStatus, attempt, maxAttempts, screening, idvRisk } = webhook;
// Check screening results if present
if (screening?.watchlist) {
const { status, totalHits } = screening.watchlist;
if (status === 'hits_found') {
await flagForReview(trace, `Watchlist screening found ${totalHits} hit(s)`);
}
}
switch (verificationStatus) {
case 'new':
// This webhook is currently not sent
break;
case 'pending':
// Inform customer verification is being processed. For an individual
// verification this can also follow a 'rejected' webhook: the individual
// retried (data: resubmitted corrected details; biometric: re-attempted
// on their eKYC link) and a new attempt is running.
await notifyCustomer(trace, 'Your submission is being reviewed');
break;
case 'verified':
// Notify customer of successful verification
await notifyCustomer(trace, 'Verification complete.');
await updateCustomerStatus(trace, 'verified');
break;
case 'rejected': {
if (submissionStatus === 'locked') {
// Too many failed attempts — no further resubmissions will be accepted
await notifyCustomer(trace, 'Verification locked due to too many failed attempts.');
await updateCustomerStatus(trace, 'locked');
break;
}
// Retryability is carried by the cap: `maxAttempts` present with `attempt` below it
// (and not locked) means the individual can retry in place. `attempt` alone is NOT a
// retryability signal — it is always populated. See /verification-statuses.
const retryOpen = typeof maxAttempts === 'number' && attempt < maxAttempts;
if (!retryOpen) {
// No open retry window — treat the rejection as final; create a new
// verification if the individual should try again.
await notifyCustomer(trace, 'Verification unsuccessful.');
await updateCustomerStatus(trace, 'rejected');
break;
}
// Inform customer verification was unsuccessful and can be retried in place.
await notifyCustomer(trace, 'Verification unsuccessful. Please review the submitted information.');
break;
}
case 'info':
// Retrieve detailed information about what's required: `remainingActions` lists it, and
// `formUrl` is the hosted form your customer can provide it on
const details = await getVerificationDetails(trace);
await handleInfoRequest(trace, details);
break;
case 'error':
// Handle error scenario
const errorDetails = await getVerificationDetails(trace);
await handleError(trace, errorDetails);
break;
case 'removed':
// Stakeholder webhooks only (they carry `parentTrace`): the individual was marked as removed.
// The removal reason is not in the payload — read it from the stakeholders list
// (see /verification-statuses#removed).
await updateStakeholderStatus(parentTrace, trace, 'removed');
break;
}
}
3. Retrieve Detailed Verification Data
When you receive a webhook notification, the payload contains only summary information about the verification state.
To access comprehensive details – including specific information requests, rejection reasons, error details,
or the complete entity data – use the verification details endpoint by submitting a GET request to /v5/verifications/:trace with the trace identifier from the webhook payload.
Webhook schema
- Name
version- Description
The webhook payload version, tracking the API namespace. Always
"v5"for v5 webhooks. Use it to branch your handler if the payload shape ever changes in a future API version.
- Name
userId- Description
The user ID of the entity in your system.
- Name
onBehalfUid- Description
The UID of the onBehalf account.
- Name
tags- Description
Array of tag labels currently attached to the verification (e.g.
["region:apac", "tier-1 >>> gold"]). Always present; an empty array if no tags have been set.
- Name
trace- Description
The trace of the verification.
- Name
submissionId- Description
The submission ID of the verification.
- Name
name- Description
The name of the entity.
- Name
country- Description
The ISO3 country code of the entity.
- Name
type- Description
The type of the entity.
- Name
subType- Description
The sub-type of the entity.
- Name
inputRisk- Description
The risk level provided by the customer when submitting the verification. Optional.
- Name
idvRisk- Description
The calculated IDV risk level, based on country risk, screening results, stakeholder risk, and input risk. Optional — absent until risk is calculated.
- Name
screening- Description
Screening summary. Present once watchlist screening has been performed. Contains
screening.watchlistwithstatus("clear"or"hits_found"),totalHits,lastScreenedAt(Unix timestamp ms), andlastScreenedTime.
- Name
verificationStatus- Description
The verification status. For stakeholder webhooks this may additionally be
removed, emitted when an individual is marked as removed (details).
- Name
submissionStatus- Description
The submission status.
"locked"means the verification exhausted its attempt cap and accepts no further resubmissions — see retrying a rejected individual verification.
- Name
attempt- Description
Which verification attempt the status belongs to, starting at
1. Increments when a rejectedindividual(subType: data) verification is resubmitted, and likewise for a rejectedindividual(subType: biometric) verification — standalone or entity stakeholder — that re-attempts on its hosted eKYC link. Always populated (defaults to1), so on its own it does not signal that a rejection is retryable — that is whatmaxAttemptsis for. Rejection reasons are deliberately not included in webhook payloads; fetch them via GET /v5/verifications/:trace (or the stakeholders list for a stakeholder).
- Name
maxAttempts- Description
The attempt cap in force on the record. Present on verifications that track capped retries — its presence alone does not mean a retry is open: a
rejectedstatus is retryable in place only withattemptbelowmaxAttemptsand a non-"locked"submissionStatus. Absent when the record has no capped-retry regime — treat such a rejection as final. Read the cap from this field rather than assuming5; it can be raised on a specific verification.
- Name
timestamp- Description
Unix timestamp (milliseconds) of the change this webhook describes. For a given verification it always increases: if a webhook arrives with a lower
timestampthan one you have already processed for the sametrace, it describes an older state and can be discarded. Re-deliveries of the same event carry the sametimestampanddeliveryId; a new value always means a new change.
- Name
parentTrace- Description
Stakeholder webhooks only. The
traceof the encapsulating entity verification. Absent on entity and standalone-individual webhooks.
- Name
parentUserId- Description
Stakeholder webhooks only. The
userIdof the encapsulating entity, when set.
- Name
roles- Description
Stakeholder webhooks only. The full set of roles the individual holds within the entity (e.g.
["director", "shareholder"]). An individual requested under multiple roles reports all of them.
Example payload (verified with screening)
{
"version": "v5",
"userId": "user-123",
"onBehalfUid": "onBehalf-123",
"trace": "test-trace",
"submissionId": "test-submission-id",
"name": "My Company",
"country": "AUS",
"type": "company",
"subType": "none",
"inputRisk": "high",
"idvRisk": "high",
"screening": {
"watchlist": {
"status": "clear",
"totalHits": 0,
"lastScreenedAt": 1767004800000,
"lastScreenedTime": "29/12/2025 21:40:00"
}
},
"verificationStatus": "verified",
"submissionStatus": "submitted",
"tags": ["region:apac", "tier-1 >>> gold"],
"timestamp": 1767004789403
}
Stakeholder webhooks
When an individual is verified as part of an entity — a stakeholder such as a director or beneficial owner — bronID sends a dedicated webhook for that individual, in addition to the entity's own webhook. The stakeholder webhook is keyed on the individual:
traceis the individual stakeholder's own trace (the same identifier as that person's eKYC), not the entity's.parentTrace,parentUserId, androleslink the individual back to the encapsulating entity.verificationStatusmay beremovedwhen the individual is marked as removed. This can be the first webhook you receive for that individual. The removal reason is not in the payload; see how to read it.
The entity itself still receives its own webhook (keyed on the entity trace) as its aggregate status changes. To correlate the two, match a stakeholder webhook's parentTrace to the entity webhook's trace. Both formats are signed identically (see Security).
Example payload (verified stakeholder)
{
"version": "v5",
"userId": null,
"onBehalfUid": "onBehalf-123",
"trace": "stakeholder-trace",
"parentTrace": "entity-trace",
"parentUserId": "user-123",
"roles": ["director", "shareholder"],
"submissionId": "entity-trace",
"name": "Rachel Mills",
"country": "AUS",
"type": "individual",
"subType": "biometric",
"inputRisk": "low",
"idvRisk": "low",
"screening": null,
"verificationStatus": "verified",
"submissionStatus": "submitted",
"tags": [],
"timestamp": 1767004789403
}
Security
To know for sure that a webhook was, in fact, sent by bronID instead of a malicious actor, you can verify the request signature. Each webhook request contains the following headers:
X-BronID-Signature- Contains the HMAC-SHA256 signature in the formatsha256=<hex-encoded-hash>X-BronID-Signature-version- Contains the signature version (currently2)
You can verify this signature by using your webhook signing key. Create one in Settings » Webhooks » Signing keys and save it when it is shown: the value is displayed once, at creation, and cannot be retrieved later. The signature is an HMAC hash of the request payload hashed using your secret key. Here is an example of how to verify the signature in your app:
Verifying a request
import crypto from 'crypto';
function verifyWebhook(payload: string, signature: string, secret: string): boolean {
// 1. Validate signature format
// The signature must start with "sha256=" to indicate the hashing algorithm used.
// This prevents algorithm confusion attacks and ensures we're comparing like-for-like.
if (!signature.startsWith('sha256=')) {
return false;
}
// 2. Extract the hash value
// Remove the "sha256=" prefix to get the raw hex-encoded hash for comparison.
const receivedHash = signature.replace('sha256=', '');
// 3. Compute the expected hash
// Using HMAC-SHA256: a keyed hash that combines the payload with our secret.
// Only someone with the secret can generate a valid signature.
const expectedHash = crypto.createHmac('sha256', secret).update(payload).digest('hex');
// 4. Compare signatures using timing-safe comparison
// Regular string comparison (===) is vulnerable to timing attacks where an attacker
// can measure response times to guess the signature character by character.
// timingSafeEqual compares in constant time regardless of where differences occur.
try {
if (!crypto.timingSafeEqual(Buffer.from(expectedHash), Buffer.from(receivedHash))) {
return false;
}
} catch {
// timingSafeEqual throws if buffer lengths differ.
// This is a sign of an invalid signature, so we reject it.
return false;
}
// 5. Validate timestamp to prevent replay attacks
// Without this check, an attacker who intercepts a valid signed request
// could replay it indefinitely. The timestamp ensures requests expire.
const data = JSON.parse(payload);
const timestamp = data.timestamp;
const age = Date.now() - timestamp;
const tolerance = 5 * 60 * 1000; // 5 minutes in milliseconds
// Reject if timestamp is too old (expired) or too far in the future (clock skew attack)
if (Math.abs(age) > tolerance) {
return false;
}
return true;
}
You should check:
- Timestamp within ±5 minutes (to prevent replay attacks)
- Signature matches (to prevent tampering)
- Reject replays (to prevent duplicate deliveries)