MetaKYC SDK

Full Integration Guide — Backend, Frontend & Webhooks
SDK v1.0.143 · Updated May 2026

1. Overview

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:

  1. Your server exchanges its long-lived API key & secret for a short-lived SDK session token, then hands the token to the browser.
  2. The browser SDK mounts <MetaKYC />, uses the token to call the MetaKYC API, and renders every workflow step.
  3. Webhooks deliver state changes from MetaKYC to your server (applicant created, risk completed, workflow finished, etc.) so you can sync your own database without having to poll.
Polling vs webhooks The SDK polls 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.

2. Architecture & data flow

┌────────────────┐ 1. session token ┌──────────────────────────┐ │ Your Server │ ──────────────────────────▶ │ MetaKYC API │ │ (Node, .NET, │ ◀────────────────────────── │ /api/SdkSession │ │ PHP, etc.) │ 2. accessToken │ /CreateToken │ └──────┬─────────┘ └──────────┬───────────────┘ │ 3. token to browser │ ▼ │ ┌────────────────┐ 4. all SDK API calls │ │ Browser (SDK) │ ─────────────────────────────────────▶ │ │ <MetaKYC /> │ Authorization: Bearer … │ └────────────────┘ │ │ ┌────────────────┐ 5. Sumsub / Sardine / Onfido │ │ ID Provider │ ──────────────────────────────────────▶│ │ (webhook) │ result callback (POST) │ └────────────────┘ │ ▼ ┌────────────────┐ ◀──── 6. outbound webhooks ──── ┌─────────────────────┐ │ Your Server │ OnApplicantCreated, │ MetaKYC pushes │ │ /api/webhooks/ │ OnRiskAssessmentCompleted │ status changes & │ │ metakyc │ OnFinishWorkflow, … │ results to you │ └────────────────┘ └─────────────────────┘

Step-by-step

  1. Token creation — your server posts apiKey + secretKey + externalRefId + workflowKey to POST /api/SdkSession/CreateToken and receives a Bearer token plus the resolved applicantId.
  2. SDK init — your front-end calls a route on your server to get the token, then passes it to MetaKYCProvider.
  3. SDK runs — the user steps through the workflow. The SDK polls GetApplicantProgress after each step to know what to render next.
  4. Identity step — the SDK launches the configured provider (Sumsub Web SDK, Sardine iframe, Onfido SDK, etc.). The provider posts back to MetaKYC's /api/CallBack/*Webhook endpoints when it has a result.
  5. Outbound webhooks — MetaKYC pushes each lifecycle event to URLs you registered in the admin panel.

3. Backend setup (your server)

There are exactly two things your server must expose:

  1. An endpoint that mints an SDK session token for the currently signed-in user (called from your browser).
  2. One or more endpoints that receive MetaKYC webhooks (called from MetaKYC).

3.1 Session token endpoint

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:

POST{baseUrl}/api/SdkSession/CreateToken
Validates your API key, resolves or creates the applicant for externalRefId, returns a short-lived Bearer token scoped to that applicant.

Request body

FieldTypeRequiredDescription
apiKeystringyesYour tenant API key.
secretKeystringyesYour tenant secret.
tenantIdnumberone ofNumeric ABP tenant ID.
clientIdstringone ofString client ID (alternative to tenantId).
externalRefIdstringyesYour system's unique user identifier. The server upserts an applicant against this ID.
workflowKeystringyesWorkflow key from the admin panel (e.g. INDIVIDUAL_FULL_KYC).
emailstringyesApplicant email.
isCompanybooleannoWhen true, KYB flow.
tenantClientIdnumbernoSub-client ID inside your tenant (maps to OwnerClientId).
applicantobjectnoPre-fill fields (firstName, lastName, phonenumber, dateOfBirth, country, nationality).

Response

{
  "success": true,
  "result": {
    "accessToken":      "eyJhbGciOi…",
    "expiresInSeconds": 3600,
    "applicantId":      1248,
    "encSessionKey":    "…",      // present when encryption is negotiated
    "encSessionId":     "…",
    "sardinSessionKey": "…"        // present when workflow includes SardinAI
  }
}
Scope A token created with an 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.

3.2 Webhook receiver endpoint

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.

Idempotency MetaKYC retries failed deliveries. Use applicantId + the event timestamp as your idempotency key.

4. Frontend SDK setup

4.1 npm package access

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.

Token required Contact Assetera to receive your npm access token. Keep it confidential — treat it like a password. Never commit it to source control. Use environment variables or a secrets manager instead.

Step 1 — configure your npm token

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 }}
Security note The .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.

Step 2 — verify access (optional)

npm info @asseteragmbh/metakyc
# Should print the latest version, description, and dist-tags.

4.2 Install & provider

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

4.3 Drop-in <MetaKYC />

The MetaKYC component handles everything automatically based on what the session token says:

Props:

PropTypeDescription
isCompanybooleanForce company (KYB) form. Otherwise read from the token.
onApplicantCreated(id: number) => voidFires after the applicant record is created.
onComplete(result: WorkflowResultType) => voidFires when the entire workflow finishes.
onError(error: Error) => voidFires on any unrecoverable error.
classNamestringAdditional CSS classes on the SDK shell.

4.4 Custom UI with hooks

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} />;
}

4.5 Provider config reference

FieldTypeDefaultDescription
baseUrlstringMetaKYC API base URL.
getAccessToken() => stringReturns the session token. Required unless apiKey is set.
apiKeystringDirect API key (testing only — never ship to production).
tenantId / clientIdnumber / stringSets the Abp-TenantId / ClientId header.
endpoints.pattern'host-controller' | 'application-service' | 'custom'host-controllerURL pattern for backend calls.
localestring'en'Initial UI locale.
showLanguagePickerbooleanfalseShow language dropdown in the header.
showVersionbooleanfalseShow SDK version badge.
debug / logLevelboolean / stringoffStripe-style request trace logging.
applicantIdnumberResume a previous KYC session by ID.
applicantFormobjectForm field visibility / pre-fill (see below).
identityProvidersobjectPer-provider settings (Sumsub, Sardine, Onfido).
themeobjectfetched from backendOverride theme; otherwise loaded from your tenant settings.
configVersionstringPin the SDK to a saved config snapshot.

applicantForm

FieldDescription
applicantType'individual' (default) or 'company'.
visibleFieldsWhitelist of fields to render. If omitted, uses the configured defaults from the admin panel.
workflowKeyDefault workflow key (overridden per session by the token).
externalRefIdExternal ID to send if not in visibleFields.
initialValuesPre-fill and lock field values (read-only in the UI, still submitted).
hiddenValuesValues submitted but never shown in the UI. Unknown keys become applicantAdditionalDatas.
fieldLabelMode'label' (default) or 'placeholder'.

identityProviders

identityProviders: {
  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,
  },
}

5. GetApplicantProgress API

This is the SDK's polling endpoint and the single most important API for understanding "where the user is" in the workflow.

GET{baseUrl}/api/Applicant/GetApplicantProgress?applicantId={id}&fetchStepInfo={bool}
Returns the applicant's current progress, current step, and (optionally) the full localized step metadata.

Query parameters

NameTypeRequiredDescription
applicantIdlongyesThe applicant ID returned by CreateToken or ApplicantRegistrationRequest.
fetchStepInfoboolnoWhen 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.

Headers

HeaderValue
AuthorizationBearer {accessToken}
Accept-LanguageISO 639-1 locale (e.g. en, de, ar). Controls localized step copy.
Abp-TenantId / ClientIdTenant scope.

Response shape (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 */ ]
  }
}

Lightweight variant

For dashboards that only need a status badge, there is a simpler variant that skips workflow tree data:

GET{baseUrl}/api/Applicant/GetApplicantProgressStaus?applicantId={id}
Returns just { applicantId, workFlowKey, workflowResult, riskLevel, kycStatus, reviewStatus, status, nextWorkflowKey, nextWorkflowName }. (Note the existing typo in the endpoint name: "Staus" not "Status".)

When to call it

6. MetaKYC HTTP API reference

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.

Base URL Production base URL is https://kycapi.assetera.com. Prepend it to every /api/… path below and use it as the baseUrl in your backend and provider config.

Session

POST/api/SdkSession/CreateToken
Mint a session token. See § 3.1.
GET/api/SdkSession/GetSdkState
Returns the SDK phase resolved from the token (create_applicant, workflow, finished, …). Used by the SDK to pick the right screen on first load.

Applicant

GET/api/Applicant/GetApplicantProgress
Main progress polling endpoint (§ 5).
GET/api/Applicant/GetApplicantProgressStaus
Lightweight status-only variant.
GET/api/Applicant/GetApplicantStatus
Simple status enum.

7. Outbound webhooks (MetaKYC → you)

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.

Common envelope

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:

Event types

WebhookTypeWhen it firesPayload
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).

Sample payloads

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 }
}

Registering subscriptions via API (optional)

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
}

8. Enum reference

ApplicantProgressStatus

ValueNumericMeaning
None0Not started.
InProgress1Workflow is active on a step.
Finished2Workflow tree complete.
OnHold3Paused — manual review, doc review, rep KYC, etc.
IdentityPending4User submitted to ID provider; waiting on webhook.

WorkflowStepAction

ValueNumeric
IdentitySdk0
RiskScoring1
Questionaries2
AdditionalData3
AppropriatenessTest4
UploadDocument5
ManualReview6
Overview7

ReviewStatus

ValueMeaning
NewJust created, no decisions yet.
InProgressWorkflow running.
UnderReviewWaiting on the admin team.
AdminReviewRouted to admin for explicit decision.
ApprovedFinal state — passed.
RejectFinal state — failed.
ClosedFinal state — archived.

KycStatus

ValueNumeric
None0
Pending1
Approved2
Rejected3
ResendRequested4

RiskLevel

ValueNumeric
LowRisk0
MediumRisk1
HighRisk2
Alert3

WorkflowResultType

ValueNumeric
NotStarted0
InProgress1
Success2
PartialSuccess3
Failed4
Skipped5
AdminReview6
SkippedAndAdminReview7

WebhookType

ValueNumeric
OnApplicantCreated0
OnApplicantResult1
OnWatchlistResult2
OnFinishWorkflow3
OnHoldPrgocess4
OnBeneficiariesAdded5
OnRiskAssessmentCompleted6
OnProgressChanged7

9. End-to-end example (Next.js)

Server: token route

// 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,
  });
}

Server: webhook route

// 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');
}

Client: KYC page

// 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>
  );
}