Certula Verify

Remote identity verification (eKYC) — verify users' government-issued documents, liveness, and face match through a Certula-hosted UI. Your app creates a session, redirects the user, and checks the result.

eKYC Document OCR Liveness Detection Face Match PDPA / GDPR
⬇ Download LLM Implementation Guide (.md)

Certula Verify vs Certula Connect

These are two separate products with different credentials, different endpoints, and different use cases.

Certula Connect (OAuth/SSO)

  • Auth method: client_id + client_secret
  • Use: "Login with Certula" button
  • Result: access token + user profile
  • Main endpoint: /oauth/authorize
  • No identity verification required

Certula Verify (eKYC)

  • Auth method: X-API-Key header
  • Use: "Verify my identity" flow
  • Result: kyc_verified flag + document data
  • Main endpoint: /api/v1/sessions/
  • Document scan + liveness + face match
Independent products — A user can log in via Certula Connect without completing eKYC. The kyc_verified claim in the Connect userinfo response shows their current verification status. Run both flows independently based on your requirements.

Verification Flow

sequenceDiagram autonumber participant U as User participant A as Your App participant C as Certula Verify U->>A: "Verify my identity" A->>C: POST /api/v1/sessions/ (X-API-Key + user details) C-->>A: { session_token, expires_at } A->>U: Redirect to certula.com/ekyc?session=TOKEN U->>C: Document capture + liveness check C-->>U: Redirect to your redirect_url U->>A: Lands on redirect_url A->>C: GET /api/v1/users/{certula_id}/profile (X-API-Key) C-->>A: { kyc_verified: true/false, ... } A->>U: ✓ Verified (or retry)
Important: The redirect back to your redirect_url happens before Certula finishes processing. Always call the status API to confirm — never treat the redirect alone as verification success.

API Key Setup

Contact the Certula team to provision API access for your application. You receive:

CredentialUsageWhere to keep
CERTULA_API_KEYSent as X-API-Key header on every requestServer-side only. Never expose in browser or mobile app bundles.
CERTULA_SERVER_URLAPI base URL (e.g. https://api.certula.com)Server env var
CERTULA_FRONTEND_URLCertula frontend URL for constructing the eKYC redirect (e.g. https://certula.com)Server env var
Security: The API Key grants access to identity data. Store it in a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.). Rotate immediately if compromised.

Quick Start

1 — Create a session (server-side)

// Node.js — POST to Certula to create a verification session
const res = await fetch(`${process.env.CERTULA_SERVER_URL}/api/v1/sessions/`, {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.CERTULA_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    applicant_id:            user.certula_id,       // "sub" from OAuth userinfo
    user_email:              user.email,
    user_name:               user.name,
    workflow_id:             'identity_verification', // must be this exact value
    redirect_url:            `https://yourapp.com/verification/complete`,
    language:                'en',
    country:                 'MY',
    send_email_notification: false,
    metadata:                { user_id: user.id, source: 'your-app' },
  }),
})
const { session_token, expires_at } = await res.json()
const verificationUrl = `${process.env.CERTULA_FRONTEND_URL}/ekyc?session=${session_token}`

2 — Redirect the user

res.redirect(verificationUrl)   // or: return NextResponse.redirect(verificationUrl)

3 — Check status after redirect

// GET /verification/complete — user landed back after eKYC
const profile = await fetch(
  `${process.env.CERTULA_SERVER_URL}/api/v1/users/${user.certula_id}/profile`,
  { headers: { 'X-API-Key': process.env.CERTULA_API_KEY } }
).then(r => r.json())

const verified = profile.kyc_verified ?? profile.ekyc_verified ?? false
// Update your DB, notify the user

Create Session Endpoint

POST{CERTULA_SERVER_URL}/api/v1/sessions/
Creates an eKYC verification session. Returns a session_token — append to {CERTULA_FRONTEND_URL}/ekyc?session= to get the URL for the user.

Request Headers

HeaderValue
X-API-KeyYour API key
Content-Typeapplication/json

Request Body

applicant_id required
string
The user's Certula ID (sub from userinfo) or your internal UUID. Used to link the verification to a Certula account.
user_email required
string
User's email address. Used by Certula for the session context.
user_name required
string
User's display name. Shown on the Certula verification UI.
workflow_id required
string
Must be exactly "identity_verification".
redirect_url required
string
Where Certula redirects the user after the verification attempt. This is your app's "complete" route.
language optional
string
UI language. Default: "en". Supported: "en", "ms", "zh".
country optional
string
Country context for document type detection. Default: "MY".
send_email_notification optional
boolean
Whether Certula sends a notification email to the user. Default: false.
metadata optional
object
Arbitrary key-value data stored with the session. Useful for correlating sessions to your internal records (e.g. { user_id, source }).

Response

{
  "session_token": "eyJhbGciOiJIUzI1NiJ9...",
  "expires_at":     "2026-06-30T14:30:00Z"
}

Construct the redirect URL: {CERTULA_FRONTEND_URL}/ekyc?session={session_token}

Status Check Endpoint

GET{CERTULA_SERVER_URL}/api/v1/users/{certula_user_id}/profile
Check whether a user has completed eKYC. Use the sub value from Certula Connect's userinfo as the certula_user_id.

Request headers: X-API-Key: YOUR_API_KEY

Response

{
  "kyc_verified":   true,
  "ekyc_verified":  true,
  "kyc_status":    "verified",
  "full_name":     "Ahmad bin Razif",
  "phone_number":  "+60123456789"
}
Field normalisation: The response may contain kyc_verified, ekyc_verified, kyc_status, or ekyc_status depending on the API version. Always check all four — see the code examples below.

Detailed Identity Data

GET{CERTULA_SERVER_URL}/api/v1/apps/me/user/{user_id}?include_details=true
Returns the full extracted document data. Requires the kyc.details scope to be enabled on your connected app. Only call this when your application genuinely needs the document fields — it returns sensitive PII.
{
  "user_id":       "22f35f9a-d82f-4f81-afec-e8a9864621c9",
  "email":         "user@example.com",
  "kyc_verified":  true,
  "kyc_level":     "basic",
  "verified_at":   "2026-06-30T10:00:00Z",
  "kyc_details": {
    "status":                "verified",
    "overall_confidence":    0.92,
    "face_match_confidence": 0.95,
    "document_type":        "national_id",
    "document_country":     "MY",
    "extracted_data": {
      "full_name":    "AHMAD BIN RAZIF",
      "id_number":   "900512-10-1234",
      "date_of_birth":"1990-05-12",
      "gender":      "M",
      "address_line_1":"123 Jalan Ampang",
      "city":        "Kuala Lumpur",
      "postal_code": "50450"
    }
  }
}

Node.js (Express) — Complete Backend

// routes/verification.js
const express = require('express')
const router  = express.Router()
const { CERTULA_SERVER_URL, CERTULA_API_KEY, CERTULA_FRONTEND_URL } = process.env

// Normalize the status across different field/value variants
function resolveKycStatus(profile) {
  for (const k of ['kyc_verified', 'ekyc_verified', 'kyc_status', 'ekyc_status']) {
    if (profile[k] != null) {
      const v = profile[k]
      if (typeof v === 'boolean') return v
      return ['verified', 'approved', 'passed', 'complete'].includes(String(v).toLowerCase())
    }
  }
  return false
}

// Step 1: User requests eKYC — create a session and redirect
router.post('/verification/start', requireAuth, async (req, res) => {
  const { user } = req
  if (user.kyc_verified) return res.json({ already_verified: true })

  const apiRes = await fetch(`${CERTULA_SERVER_URL}/api/v1/sessions/`, {
    method: 'POST',
    headers: { 'X-API-Key': CERTULA_API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      applicant_id:            user.certula_id || user.id,
      user_email:              user.email,
      user_name:               user.name,
      workflow_id:             'identity_verification',
      redirect_url:            `${process.env.APP_URL}/verification/complete`,
      language:                'en',
      country:                 'MY',
      send_email_notification: false,
      metadata:                { user_id: user.id, source: 'my-app' },
    })
  })
  if (!apiRes.ok) return res.status(503).json({ error: 'Verification service unavailable' })
  const { session_token, expires_at } = await apiRes.json()
  const verificationUrl = `${CERTULA_FRONTEND_URL}/ekyc?session=${session_token}`
  res.json({ verification_url: verificationUrl, expires_at })
})

// Step 2: User returns after eKYC — check status
router.get('/verification/complete', requireAuth, async (req, res) => {
  const { user } = req
  if (!user.certula_id) return res.redirect('/dashboard?kyc=unknown')

  const statusRes = await fetch(
    `${CERTULA_SERVER_URL}/api/v1/users/${user.certula_id}/profile`,
    { headers: { 'X-API-Key': CERTULA_API_KEY } }
  )

  if (!statusRes.ok) return res.redirect('/dashboard?kyc=error')
  const profile = await statusRes.json()
  const verified = resolveKycStatus(profile)

  // Update your database
  await db.users.update({ id: user.id }, { kyc_verified: verified })
  res.redirect(verified ? '/dashboard?kyc=success' : '/dashboard?kyc=pending')
})

// Get detailed identity data (requires kyc.details scope)
router.get('/verification/details', requireAuth, async (req, res) => {
  const { user } = req
  const detailRes = await fetch(
    `${CERTULA_SERVER_URL}/api/v1/apps/me/user/${user.certula_id}?include_details=true`,
    { headers: { 'X-API-Key': CERTULA_API_KEY } }
  )
  if (!detailRes.ok) return res.status(503).json({ error: 'Failed to load identity data' })
  // ⚠ Don't log the full response body — it contains PII
  const data = await detailRes.json()
  res.json({ kyc_verified: data.kyc_verified, kyc_level: data.kyc_level })  // return minimal
})

module.exports = router

Python (FastAPI) — Complete Backend

# routers/verification.py
import httpx, logging
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import RedirectResponse

router = APIRouter(prefix="/verification")
logger = logging.getLogger(__name__)

def resolve_kyc_status(payload: dict) -> bool:
    """Normalize kyc status across field/value variants."""
    for key in ("kyc_verified", "ekyc_verified", "kyc_status", "ekyc_status"):
        if key in payload:
            val = payload[key]
            if isinstance(val, bool): return val
            return str(val).lower() in {"verified", "approved", "passed", "complete"}
    return False


@router.post("/start")
async def start_verification(session: dict = Depends(get_current_session),
                             db: Session = Depends(get_db)):
    user = get_user(db, session["sub"])
    if user.profile.ekyc_verified:
        raise HTTPException(400, "eKYC already verified")

    session_data = {
        "applicant_id":            user.certula_id or str(user.id),
        "user_email":              user.email,
        "user_name":               user.display_name or user.full_name,
        "workflow_id":             "identity_verification",
        "redirect_url":            f"{settings.app_url}/verification/complete",
        "language":                "en",
        "country":                 "MY",
        "send_email_notification": False,
        "metadata":                {"user_id": str(user.id), "source": "my-app"},
    }
    headers = {"X-API-Key": settings.certula_api_key, "Content-Type": "application/json"}

    async with httpx.AsyncClient(timeout=10) as client:
        resp = await client.post(
            f"{settings.certula_server_url}/api/v1/sessions/",
            json=session_data, headers=headers
        )
    resp.raise_for_status()
    payload = resp.json()
    session_token = payload["session_token"]
    verification_url = f"{settings.certula_frontend_url}/ekyc?session={session_token}"
    return {"verification_url": verification_url, "expires_at": payload.get("expires_at")}


@router.get("/complete")
async def verification_complete(session: dict = Depends(get_current_session),
                               db: Session = Depends(get_db)):
    user = get_user(db, session["sub"])
    if not user.certula_id:
        return RedirectResponse("/dashboard?kyc=unknown")

    async with httpx.AsyncClient(timeout=10) as client:
        resp = await client.get(
            f"{settings.certula_server_url}/api/v1/users/{user.certula_id}/profile",
            headers={"X-API-Key": settings.certula_api_key},
        )

    if resp.status_code >= 400:
        logger.warning("eKYC status check failed: %s", resp.text)
        return RedirectResponse("/dashboard?kyc=error")

    verified = resolve_kyc_status(resp.json())
    user.profile.ekyc_verified = verified
    db.commit()
    return RedirectResponse("/dashboard?kyc=success" if verified else "/dashboard?kyc=pending")

All Endpoints

MethodPathDescription
POST/api/v1/sessions/Create a verification session
GET/api/v1/users/{certula_id}/profileCheck user's current KYC status
GET/api/v1/apps/me/user/{user_id}?include_details=trueGet detailed extracted identity data

All requests require X-API-Key: YOUR_API_KEY header.

Status Values

ValueMeaning
true / "verified" / "approved"Verification passed ✓
"pending" / "in_progress"Session created, not yet complete
false / "failed" / "rejected"Verification failed (document unclear, liveness failed, name mismatch, etc.)
null / missingNo verification record for this user

The response field name may vary — always check all four: kyc_verified, ekyc_verified, kyc_status, ekyc_status.

Webhooks

Certula can push a webhook notification when a user's verification status changes, so you don't need to poll. Contact the Certula team to configure a webhook endpoint for your connected app.

Payload

{
  "event":        "kyc.completed",
  "applicant_id": "certula-user-uuid",
  "status":       "verified",
  "verified_at":  "2026-06-30T10:00:00Z",
  "metadata": {
    "user_id": "your-internal-uuid",
    "source":  "my-app"
  }
}
Security: Always verify the webhook signature using the shared secret before processing. Treat the payload as untrusted until verified. Never log the full payload body in plaintext — it may contain PII.

Error Reference

HTTPCauseResolution
401Missing or invalid X-API-KeyCheck CERTULA_API_KEY. Regenerate if compromised.
403Connected app lacks the required scope (kyc.details)Contact Certula to enable the scope.
404User not found in CertulaUse the sub from OAuth userinfo, not your internal ID. Check the user has a Certula account.
422Missing required fields in session creation bodyCheck all required fields: applicant_id, user_email, user_name, workflow_id, redirect_url.
429Rate limit exceededImplement exponential backoff. Review per-minute/per-hour limits with Certula.
503Certula service unavailableRetry with exponential backoff (3–5 attempts). Fall back to locally-cached verification status if available.

Compliance & Data Handling

Sensitive PII — handle with care. Extracted identity data (IC number, date of birth, address, face images) is regulated under PDPA (Malaysia), GDPR (EU), and equivalent laws. Mishandling this data is a legal violation with significant penalties.

Mandatory Data-Handling Requirements

RequirementHow to fulfil
Secure storageEncrypt identity data at rest (AES-256 minimum). Use row-level encryption for id_number, date_of_birth.
Log sanitisationScrub identity fields from all application logs. Log only: user ID, timestamp, verified boolean.
Minimal accessOnly request include_details=true when the document data is genuinely needed. Use kyc_verified: boolean for most flows.
Data retentionDefine and enforce a retention policy. Delete or anonymise identity data after the legal requirement period.
Third-party sharingNever redistribute extracted identity data without explicit, specific user consent that meets PDPA requirements.
API key managementStore in a secrets manager. Rotate on schedule. Revoke immediately if compromised. Never commit to version control.
Audit trailLog (app ID, user ID, endpoint, timestamp, was include_details requested?) for compliance audits. Exclude response body.
User consentObtain and record explicit user consent before initiating eKYC. Consent must cover the specific data collected and its use.