KYC Is a Compliance Requirement, Not a Feature
Every fintech platform that handles money needs KYC (Know Your Customer) verification. It's mandated by regulators across jurisdictions — CySEC, FCA, ASIC, and others. The goal is to verify that clients are who they claim to be, prevent money laundering, and comply with anti-fraud regulations.
The worst thing you can do is build KYC verification yourself. Document verification, liveness detection, and sanctions screening are specialized problems. Use a provider like Sumsub, Onfido, or Jumio. They handle the hard parts — you handle the integration.
Here's how we integrate Sumsub across our fintech platforms.
Architecture Overview
Client Browser
│
▼
Sumsub Web SDK (iframe/modal)
│
▼
Sumsub Servers (verification)
│
├──▶ Webhook → Your Backend → Update KYC Status
│
└──▶ Dashboard (manual review if needed)
The flow is:
- Client clicks "Verify Identity" on your platform
- You generate a Sumsub access token via their API
- Sumsub's SDK opens in the client's browser
- Client uploads documents and completes liveness check
- Sumsub processes and sends a webhook with the result
- Your backend updates the client's KYC status
Generating Access Tokens
Each verification session needs a unique token tied to the client:
class SumsubService
{
public function createAccessToken(Client $client): string
{
$timestamp = time();
$path = "/resources/accessTokens?userId={$client->id}&levelName=basic-kyc";
$signature = hash_hmac(
'sha256',
$timestamp . 'POST' . $path,
config('services.sumsub.secret_key')
);
$response = Http::withHeaders([
'X-App-Token' => config('services.sumsub.app_token'),
'X-App-Access-Sig' => $signature,
'X-App-Access-Ts' => $timestamp,
])->post("https://api.sumsub.com{$path}");
return $response->json('token');
}
}
The levelName parameter maps to verification levels configured in your Sumsub dashboard. You might have:
basic-kyc— ID document + selfie (for standard accounts)enhanced-kyc— proof of address + source of funds (for high-value accounts)
KYC State Machine
KYC status should be modeled as a state machine with clear transitions:
enum KycStatus: string
{
case NotStarted = 'not_started';
case Pending = 'pending';
case DocumentsSubmitted = 'documents_submitted';
case UnderReview = 'under_review';
case Approved = 'approved';
case Rejected = 'rejected';
case Expired = 'expired';
}
State transitions are enforced:
class KycStateMachine
{
private array $transitions = [
'not_started' => ['pending'],
'pending' => ['documents_submitted'],
'documents_submitted' => ['under_review', 'approved', 'rejected'],
'under_review' => ['approved', 'rejected'],
'rejected' => ['pending'], // Allow resubmission
'expired' => ['pending'], // Allow re-verification
];
public function canTransition(KycStatus $from, KycStatus $to): bool
{
return in_array($to->value, $this->transitions[$from->value] ?? []);
}
}
Webhook Processing
Sumsub sends webhooks for every status change. Process them reliably:
class SumsubWebhookController
{
public function handle(Request $request): Response
{
// Verify webhook signature
$signature = hash_hmac(
'sha256',
$request->getContent(),
config('services.sumsub.webhook_secret')
);
if (!hash_equals($signature, $request->header('X-Payload-Digest'))) {
return response('Invalid signature', 401);
}
// Process asynchronously
ProcessKycWebhook::dispatch($request->all());
return response('OK', 200);
}
}
class ProcessKycWebhook implements ShouldQueue
{
public function handle(): void
{
$applicantId = $this->data['applicantId'];
$reviewStatus = $this->data['reviewResult']['reviewAnswer'];
$client = Client::where('sumsub_applicant_id', $applicantId)->first();
match ($reviewStatus) {
'GREEN' => $this->approveClient($client),
'RED' => $this->rejectClient($client, $this->data['reviewResult']),
'YELLOW' => $this->flagForManualReview($client),
};
}
private function approveClient(Client $client): void
{
$client->updateKycStatus(KycStatus::Approved);
// Unlock platform features
$client->enableDeposits();
$client->enableTrading();
// Notify client
KycApproved::dispatch($client);
}
}
Platform Access Control
Tie feature access to KYC status via middleware:
class RequireKycApproval
{
public function handle(Request $request, Closure $next): Response
{
$client = $request->user();
if ($client->kyc_status !== KycStatus::Approved) {
return response()->json([
'error' => 'KYC verification required',
'kyc_status' => $client->kyc_status,
'message' => $this->getMessage($client->kyc_status),
], 403);
}
return $next($request);
}
private function getMessage(KycStatus $status): string
{
return match ($status) {
KycStatus::NotStarted => 'Please complete identity verification',
KycStatus::Pending => 'Verification in progress',
KycStatus::Rejected => 'Please resubmit your documents',
default => 'Verification required',
};
}
}
Apply this middleware to sensitive routes:
Route::middleware('kyc.approved')->group(function () {
Route::post('/deposits', [DepositController::class, 'store']);
Route::post('/withdrawals', [WithdrawalController::class, 'store']);
Route::post('/trading/orders', [OrderController::class, 'store']);
});
Document Storage and Privacy
KYC documents contain sensitive personal data. Handle with care:
- Don't store documents locally — let Sumsub retain them. Query their API when you need to view documents.
- If you must store — use encrypted S3 buckets with strict IAM policies
- Retention policies — GDPR requires deletion upon request, but regulatory requirements may mandate 5-7 year retention
- Access logging — every time someone views a client's KYC documents, log who accessed what and when
Key Takeaways
- Never build KYC verification yourself — use Sumsub, Onfido, or Jumio
- Model KYC as a state machine — clear states and enforced transitions
- Process webhooks asynchronously with signature verification
- Tie platform access to KYC status via middleware — deposits, trading, and withdrawals require approval
- Handle document privacy carefully — encryption, access logging, and retention policies
- Support resubmission — rejected clients should be able to try again easily
KYC integration is where compliance meets user experience. A smooth verification flow reduces client abandonment, while robust backend processing keeps regulators happy.
