KYC webhooks
The authoritative KYC result channel. The common envelope, every event type MetaKYC emits, sample payloads, and how to register a subscription.
Webhooks are how you learn a KYC result. The browser callback tells you the embedded flow ended, not what was decided: a review can still be running after the user sees the last screen.
Subscriptions are configured per tenant in the admin panel under Config → Webhooks. Each subscription
pairs a WebhookType with a target URL, and more than one subscription can listen to the same event.
Delivery
Every webhook is a JSON POST with Content-Type: application/json. MetaKYC adds two helper fields to
every type-specific payload:
{
"...": "type-specific fields",
"internalAppId": { "applicantId": 1248 },
"provider": { "name": "sumsub" },
"timestamp": "2026-09-17T08:21:47.1234567Z",
"deliveryId": "b75fa64e-c506-3c30-a690-a46dbcec75e7"
}| Behaviour | Detail |
|---|---|
| Logging | Every call is logged with its HTTP status and response body, visible in the admin panel. |
| Retries | A transport error, 408, 429 or any 5xx is retried for that subscription with exponential backoff, from 30s up to a 1 hour cap. |
| Giving up | After 5 consecutive failures the subscription is dead-lettered and skipped until an admin re-enables it. A 2xx resets the counter. |
| Permanent errors | Any other 4xx is recorded and never retried. A URL answering 404 loses that event for good. |
| Ordering | Not guaranteed. Treat each delivery as a statement about current state. |
The same event can arrive more than once
Retries reuse the same deliveryId, so de-duplicate on it rather than on the payload. Acknowledge fast
with a 2xx and process asynchronously: a slow handler that times out is a retryable failure and will be
re-sent. Reconcile with GetApplicantProgress to cover a dead-lettered
subscription.
Verifying a delivery
When a signing secret is configured, each request carries these headers. Verify the signature before trusting the body.
| Header | Value |
|---|---|
X-Assetera-Delivery-Id | Stable idempotency key, unchanged across retries. |
X-Assetera-Timestamp | Unix seconds at send time. |
X-Assetera-Signature | t=<unix>,v1=<hex>, where the hex is HMAC-SHA256(secret, "<unix>.<raw body>"). |
X-Webhook-Secret | The shared secret, when one is configured. |
// Compute over the RAW body, before any JSON parsing.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? '');
return a.length === b.length && timingSafeEqual(a, b);
}Event types
| WebhookType | Fires when | Payload |
|---|---|---|
OnApplicantCreated | An applicant registration succeeds, when called with WebSdk = true. | Progress result. |
OnApplicantResult | An identity provider finishes verification (Sumsub, Sardine, Onfido). | Raw provider result. |
OnWatchlistResult | A sanction or PEP screening completes. | Screening result. |
OnRiskAssessmentCompleted | A risk-scoring plan finishes. | { riskLevel, riskPlan, score }. |
OnFinishWorkflow | The applicant reaches the end of the workflow tree. | Workflow finish output. |
OnHoldPrgocess | The workflow pauses, typically for a flag or manual review. | Snapshot plus hold reason. |
OnBeneficiariesAdded | KYB: a UBO, director, shareholder or representative is added. | { company, type, email, link }. |
OnProgressChanged | After every step transition, including on-hold flips. | Lightweight status. |
OnApplicantResult is the verdict channel, and OnFinishWorkflow is the one that tells you the whole
tree is done. Spellings such as OnHoldPrgocess are kept for backwards compatibility: match them exactly.
Sample payloads
{
"applicantId": 1248,
"workflowRisks": [
{ "planName": "Default", "riskLevel": "LowRisk", "riskAssesmentProvider": "internal" }
],
"finalStatus": "Approved",
"workflowResult": "Success",
"nextWorkflowKey": null,
"nextWorkflowName": null,
"internalAppId": { "applicantId": 1248 },
"provider": { "name": null }
}{
"applicantId": 1248,
"workFlowKey": "INDIVIDUAL_FULL_KYC",
"workflowResult": "InProgress",
"riskLevel": null,
"kycStatus": "Pending",
"reviewStatus": "InProgress",
"status": "InProgress",
"nextWorkflowKey": null,
"nextWorkflowName": null,
"internalAppId": { "applicantId": 1248 },
"provider": { "name": null }
}{
"riskLevel": "MediumRisk",
"riskPlan": "Default risk plan",
"score": 42,
"internalAppId": { "applicantId": 1248 },
"provider": { "name": null }
}{
"company": "Acme Holdings Ltd.",
"type": "UBO",
"email": "jane.doe@example.com",
"link": { "url": "https://your-domain.example/u/abc123" },
"internalAppId": { "applicantId": 1248 },
"provider": { "name": null }
}Field values such as finalStatus, workflowResult and riskLevel use the vocabularies in the
enum reference.
Registering a subscription
The admin panel is the usual route. Subscriptions can also be managed over the admin REST API:
POST {baseUrl}/api/services/app/WebhookInfo/CreateOrEditWebhookInfo{
"webhookType": "OnApplicantCreated",
"url": "https://your-domain.example/api/webhooks/metakyc",
"isEnabled": true
}Applicant status (GetApplicantProgress)
Read where a user is in the KYC workflow. The GetApplicantProgress request, its response shape, and the enum vocabulary every status field uses.
Styling the KYC SDK
Three ways to restyle the embedded KYC flow, why overriding from your own stylesheet needs care, and every CSS class and theme variable the SDK supports.