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.rs — kyc_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:
| Provider | Status |
|---|---|
| Manual | Implemented. Admin reviews in Ops Console; no real webhook traffic. |
| Sumsub | Implemented. HMAC-SHA256 signature on X-Payload-Digest, parsed SumsubWebhookPayload. |
| Onfido | Not implemented — factory returns an error explaining the gap. |
| Persona | Not implemented. |
| Jumio | Not 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):
| Provider | Header | Scheme |
|---|---|---|
| Sumsub | X-Payload-Digest | HMAC-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. |
| Onfido | X-SHA2-Signature (mapped, not verified) | Implementation pending. |
| Other | X-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:
- Looks up the
KycApplicationvia theProviderSession.external_refreturned by the provider'shandle_webhook. - Calls
application_repo.update_statuswithApplicationStatus::ApprovedorApplicationStatus::Rejected, and areasonstring of the formAutomated 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
- Payment Webhooks — a sibling inbound surface, slightly more mature.
- Signature Verification — covers both Stripe and Sumsub schemes.