KYC Webhooks

How identity-verification providers (Sumsub today) call back to BetterSuite, and what's exposed to your code once the result lands.

Last updated May 19, 2026

KYC providers run async — your driver or vendor uploads documents, the provider reviews (often human-in-the-loop), and they call back hours later with a decision. This page describes the inbound path BetterSuite handles. Like payment webhooks, there is no outbound webhook surface that fans the decision out to your code — once the application is updated, you read it back through GraphQL.

The endpoint

POST /webhooks/kyc/{provider_config_id}

Mounted in the monolith router alongside the PSP webhook (monolith/src/unified_schema/router.rskyc_provider_webhook_handler). {provider_config_id} is the ID of a TenantProviderConfig row — one config per provider, per tenant. The handler uses that ID to load the config (including the webhook secret) before verifying the signature.

This is the URL you paste into the provider's dashboard when wiring up callbacks. The route is also stored on the TenantProviderConfig.webhook_url column so admin tooling can surface it.

Supported providers

The provider enum (kyc/domain/src/kyc_provider_type_enum.rs) lists Sumsub, Persona, Manual, Onfido, and Jumio, but the factory (kyc_provider_factory.rs:21-49) bails out for everything except Manual and Sumsub:

ProviderStatus
ManualImplemented. Admin reviews in Ops Console; no real webhook traffic.
SumsubImplemented. HMAC-SHA256 signature on X-Payload-Digest, parsed SumsubWebhookPayload.
OnfidoNot implemented — factory returns an error explaining the gap.
PersonaNot implemented.
JumioNot implemented.

So in practice the only provider that drives the webhook handler today is Sumsub. The Onfido: X-SHA2-Signature header mapping you'll see in webhook_handler.rs:78-88 is the signature header name the handler will look for if and when the Onfido provider is built — the verification code isn't there yet.

The signature

Each provider has its own signature scheme. The handler extracts the header by provider type, then hands the bytes plus the header value to the provider impl (extract_signature in webhook_handler.rs:80-94):

ProviderHeaderScheme
SumsubX-Payload-DigestHMAC-SHA256 of the raw body, keyed by the stored webhook_secret, hex-encoded. The handler also tolerates the literal prefix sha256-hmac.. See sumsub_kyc_provider.rs:222-254.
OnfidoX-SHA2-Signature (mapped, not verified)Implementation pending.
OtherX-Signature (generic fallback)Implementation pending.

There is no BetterSuite-branded signature header on this surface — the platform forwards whatever the provider sent.

What lands in the database

HandleKycWebhookUseCase::execute (handle_kyc_webhook_usecase.rs:38-124) does exactly two state changes:

  1. Looks up the KycApplication via the ProviderSession.external_ref returned by the provider's handle_webhook.
  2. Calls application_repo.update_status with ApplicationStatus::Approved or ApplicationStatus::Rejected, and a reason string of the form Automated decision from provider: {external_ref}.

For rejections, rejection_kind is set to Soft by default — operators can manually escalate to Hard in the Ops Console. There is no per-document flagging path through the webhook, no under_review intermediate state, and no expired state coming out of the provider directly.

Possible application statuses (kyc/domain/src/application_status_enum.rs) are:

Draft → Pending → Approved | Rejected | Expired

Expired is set by tenant-side policy logic, not by a webhook.

Reading the result

After the webhook settles, the application is queryable as kycApplication(id: KycApplicationId!) (defined in the kyc subgraph, see backend/schemas/schema.graphql:14149-14224):

query Application($id: KycApplicationId!) {
  kycApplication(id: $id) {
    id
    status            # ApplicationStatus enum
    rejectionKind     # Soft | Hard, only set when status is Rejected
    reason            # Single string — the free-form reason
    provider          # KycProviderType
    externalRef       # The provider's applicant ID
    decidedAt
    documents {
      id
      kind
      status
    }
    applicant {
      # account, driver, or merchant details
    }
  }
}

reason is a single String?, not an array. If you saw a rejectionReasons field in earlier docs — that doesn't exist; it was wishful. Surface reason to the applicant verbatim if your UX needs to explain a re-submission.

There is no kycEvents GraphQL subscription — the type Subscription block in the federated schema does not carry KYC events. Poll the query, or wait for the application status to flip on the relevant entity (driver, merchant) you already render.

PII handling

The webhook payload from the provider does carry document metadata and review fields, but BetterSuite only persists the identifiers and the decision. Document uploads themselves go through the dedicated submitKycForVerification and uploadKycDocument flows and live in the upload service's storage, not in webhook payloads.

What's next

Build the foundation once. Expand without limits.

BetterSuite is built for teams who see on-demand as a business — not a feature.