MetaKYC is a multi-tenant KYC / KYB platform. The
MetaKYC SDK (@vpdev2/metakyc) is the React
front-end that drops into your web app and runs the entire end-user
onboarding flow: applicant creation, identity verification, questionnaire,
risk scoring, document upload, appropriateness test, and overview/result
pages — all driven by the configuration you set up in the MetaKYC admin
panel.
Integration has three moving parts:
<MetaKYC />,
uses the token to call the MetaKYC API, and renders every workflow step.
GetApplicantProgress while the workflow is active to render UI
updates — your server should not poll. Use the outbound webhooks listed in
§ 7 to keep your own database in sync.
apiKey
+ secretKey + externalRefId +
workflowKey to POST /api/SdkSession/CreateToken
and receives a Bearer token plus the resolved applicantId.MetaKYCProvider.GetApplicantProgress after each step to know
what to render next./api/CallBack/*Webhook
endpoints when it has a result.There are exactly two things your server must expose:
Never put your apiKey/secretKey in browser code.
Instead, expose a POST endpoint on your server that does the exchange and
returns only the short-lived accessToken + applicantId.
The SDK ships a Node helper that does this in one line:
// ── server/routes/sdk-token.ts (Next.js API route, Express, Nest, etc.) ──
import { MetaKYCSession } from '@vpdev2/metakyc';
export async function POST(req: Request) {
const user = await getCurrentUser(req); // your auth
if (!user) return new Response('Unauthorized', { status: 401 });
const session = await MetaKYCSession.createToken({
baseUrl: process.env.METAKYC_BASE_URL!, // e.g. https://kycapi.assetera.com
apiKey: process.env.METAKYC_API_KEY!, // server-side only
secretKey: process.env.METAKYC_SECRET_KEY!, // server-side only
clientId: process.env.METAKYC_CLIENT_ID!, // or tenantId: 1
externalRefId: user.id, // your stable user ID
workflowKey: 'INDIVIDUAL_FULL_KYC', // configured in admin panel
email: user.email,
isCompany: false, // true → KYB form
applicant: { // optional pre-fill
firstName: user.firstName,
lastName: user.lastName,
phonenumber: user.phone,
},
});
// Return ONLY what the browser needs — never the apiKey/secret.
return Response.json({
accessToken: session.accessToken,
applicantId: session.applicantId,
expiresInSeconds: session.expiresInSeconds,
});
}
Under the hood this calls the MetaKYC endpoint:
externalRefId, returns a short-lived Bearer token scoped to that applicant.| Field | Type | Required | Description |
|---|---|---|---|
apiKey | string | yes | Your tenant API key. |
secretKey | string | yes | Your tenant secret. |
tenantId | number | one of | Numeric ABP tenant ID. |
clientId | string | one of | String client ID (alternative to tenantId). |
externalRefId | string | yes | Your system's unique user identifier. The server upserts an applicant against this ID. |
workflowKey | string | yes | Workflow key from the admin panel (e.g. INDIVIDUAL_FULL_KYC). |
email | string | yes | Applicant email. |
isCompany | boolean | no | When true, KYB flow. |
tenantClientId | number | no | Sub-client ID inside your tenant (maps to OwnerClientId). |
applicant | object | no | Pre-fill fields (firstName, lastName, phonenumber, dateOfBirth, country, nationality). |
{
"success": true,
"result": {
"accessToken": "eyJhbGciOi…",
"expiresInSeconds": 3600,
"applicantId": 1248,
"encSessionKey": "…", // present when encryption is negotiated
"encSessionId": "…",
"sardinSessionKey": "…" // present when workflow includes SardinAI
}
}
externalRefId is locked to that applicant — even if the browser
tries to load a different one, the API will reject the request. This
prevents one user from accessing another user's KYC data.
Expose a single public POST endpoint on your server that accepts JSON. MetaKYC will POST to it for every event type you subscribe to in the admin panel (§ 7 lists all event types).
// ── server/routes/webhooks/metakyc.ts ──
export async function POST(req: Request) {
const event = await req.json();
// Every payload includes these two helper fields:
// event.internalAppId.applicantId ← MetaKYC's internal ID
// event.provider.name ← identity provider name (when relevant)
switch (event.webhookType ?? inferType(event)) {
case 'OnApplicantCreated': return handleApplicantCreated(event);
case 'OnApplicantResult': return handleProviderResult(event);
case 'OnRiskAssessmentCompleted': return handleRiskResult(event);
case 'OnFinishWorkflow': return handleWorkflowFinish(event);
case 'OnProgressChanged': return handleProgressChanged(event);
// …
}
return new Response('ok');
}
Register the endpoint URL once in the MetaKYC admin panel under Config → Webhooks; each event type can have its own URL. Sample payloads for every type are in § 7.
applicantId + the event timestamp as your idempotency key.
The SDK is published as a scoped npm package under the
@asseteragmbh organisation on the public npm registry.
Installing it requires an npm access token issued by Assetera, because the
scope is protected against unauthenticated publishes.
Add a scope-specific auth entry to your project's .npmrc file.
This tells npm to use your Assetera-issued token whenever it resolves any
@asseteragmbh/* package, while leaving all other registry
requests unaffected.
# .npmrc (commit this file; the token value comes from an env variable)
@asseteragmbh:_authToken=${ASSETERA_NPM_TOKEN}
Then export the token in your environment before installing — or store it in your CI/CD secrets vault and inject it at build time:
# local development — add to your shell profile (~/.zshrc, ~/.bashrc, etc.)
export ASSETERA_NPM_TOKEN=npm_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
# CI/CD (GitHub Actions example)
# In your repository: Settings → Secrets → New repository secret
# Name: ASSETERA_NPM_TOKEN
# Value: npm_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Then in your workflow yml:
env:
ASSETERA_NPM_TOKEN: ${{ secrets.ASSETERA_NPM_TOKEN }}
.npmrc file itself
is safe to commit because it only contains the placeholder
${ASSETERA_NPM_TOKEN}. npm expands this at runtime from the
environment variable — the real token value never touches the repository.
npm info @asseteragmbh/metakyc
# Should print the latest version, description, and dist-tags.
npm install @asseteragmbh/metakyc
# or: yarn add @asseteragmbh/metakyc
# or: pnpm add @asseteragmbh/metakyc
Wrap the part of your app that hosts the SDK in MetaKYCProvider:
import { MetaKYCProvider, MetaKYC } from '@asseteragmbh/metakyc';
function KYCPage() {
return (
<MetaKYCProvider
config={{
baseUrl: 'https://kycapi.assetera.com',
clientId: 'your-client-id',
getAccessToken: async () => {
const r = await fetch('/api/sdk-token', { method: 'POST' });
const { accessToken } = await r.json();
return accessToken;
},
showLanguagePicker: true,
locale: 'en',
}}
>
<MetaKYC
onApplicantCreated={(id) => console.log('applicant id:', id)}
onComplete={(result) => console.log('workflow finished:', result)}
onError={(err) => console.error(err)}
/>
</MetaKYCProvider>
);
}
<MetaKYC />
The MetaKYC component handles everything automatically based
on what the session token says:
Props:
| Prop | Type | Description |
|---|---|---|
isCompany | boolean | Force company (KYB) form. Otherwise read from the token. |
onApplicantCreated | (id: number) => void | Fires after the applicant record is created. |
onComplete | (result: WorkflowResultType) => void | Fires when the entire workflow finishes. |
onError | (error: Error) => void | Fires on any unrecoverable error. |
className | string | Additional CSS classes on the SDK shell. |
If you want to build your own UI on top of MetaKYC, use the hooks instead of the drop-in component. All hooks share the same provider and session token.
import {
useKycWorkflow,
useQuestionnaire,
useRiskScoring,
useUploadDocument,
useAppropriatenessTest,
useOverview,
useIdentityVerification,
} from '@vpdev2/metakyc';
function CustomFlow({ applicantId }: { applicantId: number }) {
const {
progress, // ProgressApplicantResult — see §5
currentStep, // FlowStep | undefined
isLoading,
refreshProgress, // call after any step submit
moveToNext,
moveToPrevious,
} = useKycWorkflow(applicantId);
const questionnaire = useQuestionnaire(applicantId);
const risk = useRiskScoring(applicantId);
// …
if (isLoading) return <Spinner />;
return <YourCustomStepRouter step={currentStep} />;
}
| Field | Type | Default | Description |
|---|---|---|---|
baseUrl | string | — | MetaKYC API base URL. |
getAccessToken | () => string | — | Returns the session token. Required unless apiKey is set. |
apiKey | string | — | Direct API key (testing only — never ship to production). |
tenantId / clientId | number / string | — | Sets the Abp-TenantId / ClientId header. |
endpoints.pattern | 'host-controller' | 'application-service' | 'custom' | host-controller | URL pattern for backend calls. |
locale | string | 'en' | Initial UI locale. |
showLanguagePicker | boolean | false | Show language dropdown in the header. |
showVersion | boolean | false | Show SDK version badge. |
debug / logLevel | boolean / string | off | Stripe-style request trace logging. |
applicantId | number | — | Resume a previous KYC session by ID. |
applicantForm | object | — | Form field visibility / pre-fill (see below). |
identityProviders | object | — | Per-provider settings (Sumsub, Sardine, Onfido). |
theme | object | fetched from backend | Override theme; otherwise loaded from your tenant settings. |
configVersion | string | — | Pin the SDK to a saved config snapshot. |
applicantForm| Field | Description |
|---|---|
applicantType | 'individual' (default) or 'company'. |
visibleFields | Whitelist of fields to render. If omitted, uses the configured defaults from the admin panel. |
workflowKey | Default workflow key (overridden per session by the token). |
externalRefId | External ID to send if not in visibleFields. |
initialValues | Pre-fill and lock field values (read-only in the UI, still submitted). |
hiddenValues | Values submitted but never shown in the UI. Unknown keys become applicantAdditionalDatas. |
fieldLabelMode | 'label' (default) or 'placeholder'. |
identityProvidersidentityProviders: {
sumsub: {
theme: 'light', // or 'dark'
lang: 'en',
customizationName: 'my-customization', // WebSDK 2.0 — from Sumsub dashboard
adaptIframeHeight: true,
},
sardinai: {
clientId: process.env.SARDINE_CLIENT_ID!,
environment: 'sandbox', // or 'production'
region: 'eu', // 'us' | 'eu' | 'ca' | 'au'
enableBiometrics: true,
enablePortScanning: false,
},
}
This is the SDK's polling endpoint and the single most important API for understanding "where the user is" in the workflow.
| Name | Type | Required | Description |
|---|---|---|---|
applicantId | long | yes | The applicant ID returned by CreateToken or
ApplicantRegistrationRequest. |
fetchStepInfo | bool | no | When true, the server re-evaluates and returns the full
localized step metadata (display names, copy) in the active
Accept-Language. The SDK passes this on the initial load
and whenever the user changes language, but uses false for
ordinary polling to keep the call cheap. |
| Header | Value |
|---|---|
Authorization | Bearer {accessToken} |
Accept-Language | ISO 639-1 locale (e.g. en, de, ar). Controls localized step copy. |
Abp-TenantId / ClientId | Tenant scope. |
ProgressApplicantResult){
"success": true,
"result": {
"applicantId": 1248,
"externalUserId": "USER-12345",
"workFlowKey": "INDIVIDUAL_FULL_KYC",
"workflowResult": "InProgress", // NotStarted | InProgress | Success | PartialSuccess | Failed | Skipped | AdminReview | SkippedAndAdminReview
"riskLevel": "LowRisk", // null | LowRisk | MediumRisk | HighRisk | Alert
"kycStatus": "Pending", // null | None | Pending | Approved | Rejected | ResendRequested
"reviewStatus": "InProgress", // Approved | Reject | UnderReview | Closed | AdminReview | New | InProgress
"nextWorkflowKey": null,
"nextWorkflowName": null,
"status": "InProgress", // see ApplicantProgressStatus in §9
"customMessage": "",
"customMessageDE": "",
"currentStep": {
"order": 2,
"action": "Questionaries", // see WorkflowStepAction in §9
"name": "QuestionnaireStep",
"title": "Knowledge questionnaire",
"description": "Please answer the following questions"
},
"flowSteps": [ /* same FlowStep shape, all steps in this workflow */ ],
"stepInfoList": [ /* full localized info, only when fetchStepInfo=true */ ]
}
}
For dashboards that only need a status badge, there is a simpler variant that skips workflow tree data:
{ applicantId, workFlowKey, workflowResult, riskLevel, kycStatus, reviewStatus, status, nextWorkflowKey, nextWorkflowName }. (Note the existing typo in the endpoint name: "Staus" not "Status".)OnProgressChanged webhook (§ 7)
over polling.
All endpoints below use the host-controller pattern
(/api/[Controller]/[Action]). Authentication is either the
Authorization: Bearer {accessToken} header (preferred) or an
API key header.
https://kycapi.assetera.com. Prepend it to every
/api/… path below and use it as the baseUrl in your
backend and provider config.
Outbound webhooks are configured per tenant in the admin panel under
Config → Webhooks. Each subscription has a WebhookType
and a target URL. Multiple subscriptions can listen to the same event.
Every outbound webhook is delivered as a JSON POST. MetaKYC always augments the type-specific payload with these two helper fields:
{
… // type-specific fields
"internalAppId": { "applicantId": 1248 }, // MetaKYC's internal applicant ID
"provider": { "name": "sumsub" } // identity provider, when applicable
}
Delivery details:
Content-Type: application/json.WebhookCallLogs with HTTP status
code and response body — visible in the admin panel.| WebhookType | When it fires | Payload |
|---|---|---|
OnApplicantCreated |
After
ApplicantRegistrationRequest /
ApplicantCompanyRegistrationRequest /
DuplicateApplicantRegistrationRequest succeeds when
called with WebSdk = true. |
ProgressApplicantResult (see § 5). |
OnApplicantResult |
When an identity provider finishes verification and we have a structured result (Sumsub / Sardine / Onfido). | Raw provider result payload (provider-specific). |
OnWatchlistResult |
When a sanction / PEP screening completes. | Watchlist screening result. |
OnRiskAssessmentCompleted |
When a risk-scoring plan finishes for the applicant. | { riskLevel, riskPlan, score }. |
OnFinishWorkflow |
When the applicant reaches the end of the workflow tree. | WorkflowFinishOutput (see below). |
OnHoldPrgocess (spelling kept for back-compat) |
Workflow paused — typically because Sardine flagged the user or a manual review is required. | Workflow snapshot + hold reason. |
OnBeneficiariesAdded |
KYB: each time a UBO / Director / Corporate Shareholder / Representative is added. | { company, type, email, link: { url } }. |
OnProgressChanged |
After every workflow step transition (incl. on-hold ↔ in-progress flips). | ProgressApplicantStatusResult (lightweight status). |
OnApplicantCreated{
"applicantId": 1248,
"externalUserId": "USER-12345",
"workFlowKey": "INDIVIDUAL_FULL_KYC",
"status": "InProgress",
"kycStatus": "Pending",
"reviewStatus": "InProgress",
"currentStep": { "order": 1, "action": "IdentitySdk", "name": "…" },
"flowSteps": [ … ],
"internalAppId": { "applicantId": 1248 },
"provider": { "name": null }
}
OnRiskAssessmentCompleted{
"riskLevel": "MediumRisk",
"riskPlan": "Default risk plan",
"score": 42,
"internalAppId": { "applicantId": 1248 },
"provider": { "name": null }
}
OnFinishWorkflow{
"applicantId": 1248,
"workflowRisks": [
{ "planName": "Default", "riskLevel": "LowRisk", "riskAssesmentProvider": "internal" }
],
"finalStatus": "Approved", // ReviewStatus
"workflowResult": "Success", // WorkflowResultType
"nextWorkflowKey": null,
"nextWorkflowName": null,
"internalAppId": { "applicantId": 1248 },
"provider": { "name": null }
}
OnBeneficiariesAdded{
"company": "Acme Holdings Ltd.",
"type": "UBO",
"email": "jane.doe@example.com",
"link": { "url": "https://kyc.example.com/u/abc123" },
"internalAppId": { "applicantId": 1248 },
"provider": { "name": null }
}
OnProgressChanged{
"applicantId": 1248,
"workFlowKey": "INDIVIDUAL_FULL_KYC",
"workflowResult": "InProgress",
"riskLevel": null,
"kycStatus": "Pending",
"reviewStatus": "InProgress",
"status": "InProgress", // ApplicantProgressStatus
"nextWorkflowKey": null,
"nextWorkflowName": null,
"internalAppId": { "applicantId": 1248 },
"provider": { "name": null }
}
Subscriptions can also be managed via the admin REST API:
POST /api/services/app/WebhookInfo/CreateOrEditWebhookInfo
{
"webhookType": "OnApplicantCreated", // see WebhookType enum, §9
"url": "https://your-domain.com/api/webhooks/metakyc",
"isEnabled": true
}
ApplicantProgressStatus| Value | Numeric | Meaning |
|---|---|---|
None | 0 | Not started. |
InProgress | 1 | Workflow is active on a step. |
Finished | 2 | Workflow tree complete. |
OnHold | 3 | Paused — manual review, doc review, rep KYC, etc. |
IdentityPending | 4 | User submitted to ID provider; waiting on webhook. |
WorkflowStepAction| Value | Numeric |
|---|---|
IdentitySdk | 0 |
RiskScoring | 1 |
Questionaries | 2 |
AdditionalData | 3 |
AppropriatenessTest | 4 |
UploadDocument | 5 |
ManualReview | 6 |
Overview | 7 |
ReviewStatus| Value | Meaning |
|---|---|
New | Just created, no decisions yet. |
InProgress | Workflow running. |
UnderReview | Waiting on the admin team. |
AdminReview | Routed to admin for explicit decision. |
Approved | Final state — passed. |
Reject | Final state — failed. |
Closed | Final state — archived. |
KycStatus| Value | Numeric |
|---|---|
None | 0 |
Pending | 1 |
Approved | 2 |
Rejected | 3 |
ResendRequested | 4 |
RiskLevel| Value | Numeric |
|---|---|
LowRisk | 0 |
MediumRisk | 1 |
HighRisk | 2 |
Alert | 3 |
WorkflowResultType| Value | Numeric |
|---|---|
NotStarted | 0 |
InProgress | 1 |
Success | 2 |
PartialSuccess | 3 |
Failed | 4 |
Skipped | 5 |
AdminReview | 6 |
SkippedAndAdminReview | 7 |
WebhookType| Value | Numeric |
|---|---|
OnApplicantCreated | 0 |
OnApplicantResult | 1 |
OnWatchlistResult | 2 |
OnFinishWorkflow | 3 |
OnHoldPrgocess | 4 |
OnBeneficiariesAdded | 5 |
OnRiskAssessmentCompleted | 6 |
OnProgressChanged | 7 |
// app/api/sdk-token/route.ts
import { NextRequest } from 'next/server';
import { MetaKYCSession } from '@vpdev2/metakyc';
export async function POST(req: NextRequest) {
const user = await requireUser(req);
const session = await MetaKYCSession.createToken({
baseUrl: process.env.METAKYC_BASE_URL!,
apiKey: process.env.METAKYC_API_KEY!,
secretKey: process.env.METAKYC_SECRET_KEY!,
clientId: process.env.METAKYC_CLIENT_ID!,
externalRefId: user.id,
workflowKey: 'INDIVIDUAL_FULL_KYC',
email: user.email,
});
return Response.json({
accessToken: session.accessToken,
applicantId: session.applicantId,
expiresInSeconds: session.expiresInSeconds,
});
}
// app/api/webhooks/metakyc/route.ts
import { NextRequest } from 'next/server';
import { db } from '@/lib/db';
export async function POST(req: NextRequest) {
const event = await req.json();
const applicantId = event.internalAppId?.applicantId;
if (!applicantId) return new Response('ignored', { status: 200 });
// ... derive type from your config or from the payload shape
if ('finalStatus' in event) {
await db.kyc.update({
where: { metakycApplicantId: applicantId },
data: { reviewStatus: event.finalStatus, result: event.workflowResult },
});
} else if ('riskLevel' in event && 'score' in event) {
await db.kyc.update({
where: { metakycApplicantId: applicantId },
data: { riskLevel: event.riskLevel, riskScore: event.score },
});
} else if ('status' in event && 'workFlowKey' in event) {
await db.kyc.update({
where: { metakycApplicantId: applicantId },
data: { progressStatus: event.status },
});
}
return new Response('ok');
}
// app/kyc/page.tsx
'use client';
import { MetaKYCProvider, MetaKYC } from '@vpdev2/metakyc';
export default function KYCPage() {
return (
<MetaKYCProvider
config={{
baseUrl: process.env.NEXT_PUBLIC_METAKYC_BASE_URL!,
clientId: process.env.NEXT_PUBLIC_METAKYC_CLIENT_ID!,
getAccessToken: async () => {
const r = await fetch('/api/sdk-token', { method: 'POST' });
const { accessToken } = await r.json();
return accessToken;
},
showLanguagePicker: true,
identityProviders: {
sumsub: { theme: 'light' },
},
}}
>
<MetaKYC
onApplicantCreated={(id) => console.log('applicant created', id)}
onComplete={(result) => window.location.href = '/kyc/done'}
onError={(err) => console.error(err)}
/>
</MetaKYCProvider>
);
}