Skip to main content

KYC Integration with Sumsub A Developer's Guide

00:03:57:59

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:

  1. Client clicks "Verify Identity" on your platform
  2. You generate a Sumsub access token via their API
  3. Sumsub's SDK opens in the client's browser
  4. Client uploads documents and completes liveness check
  5. Sumsub processes and sends a webhook with the result
  6. Your backend updates the client's KYC status

Generating Access Tokens

Each verification session needs a unique token tied to the client:

php
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:

php
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:

php
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:

php
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:

php
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:

php
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

  1. Never build KYC verification yourself — use Sumsub, Onfido, or Jumio
  2. Model KYC as a state machine — clear states and enforced transitions
  3. Process webhooks asynchronously with signature verification
  4. Tie platform access to KYC status via middleware — deposits, trading, and withdrawals require approval
  5. Handle document privacy carefully — encryption, access logging, and retention policies
  6. 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.

Want to discuss this topic or work together? Get in touch.

Contact me