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.
⬇ 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-Keyheader - Use: "Verify my identity" flow
- Result: kyc_verified flag + document data
- Main endpoint:
/api/v1/sessions/ - Document scan + liveness + face match
kyc_verified claim in the Connect userinfo response shows their current verification status. Run both flows independently based on your requirements.
Verification Flow
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:
| Credential | Usage | Where to keep |
|---|---|---|
CERTULA_API_KEY | Sent as X-API-Key header on every request | Server-side only. Never expose in browser or mobile app bundles. |
CERTULA_SERVER_URL | API base URL (e.g. https://api.certula.com) | Server env var |
CERTULA_FRONTEND_URL | Certula frontend URL for constructing the eKYC redirect (e.g. https://certula.com) | Server env var |
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
session_token — append to {CERTULA_FRONTEND_URL}/ekyc?session= to get the URL for the user.Request Headers
| Header | Value |
|---|---|
X-API-Key | Your API key |
Content-Type | application/json |
Request Body
applicant_id requiredstring
sub from userinfo) or your internal UUID. Used to link the verification to a Certula account.user_email requiredstring
user_name requiredstring
workflow_id requiredstring
"identity_verification".redirect_url requiredstring
language optionalstring
"en". Supported: "en", "ms", "zh".country optionalstring
"MY".send_email_notification optionalboolean
false.metadata optionalobject
{ 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
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"
}
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
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
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/sessions/ | Create a verification session |
| GET | /api/v1/users/{certula_id}/profile | Check user's current KYC status |
| GET | /api/v1/apps/me/user/{user_id}?include_details=true | Get detailed extracted identity data |
All requests require X-API-Key: YOUR_API_KEY header.
Status Values
| Value | Meaning |
|---|---|
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 / missing | No 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"
}
}
Error Reference
| HTTP | Cause | Resolution |
|---|---|---|
| 401 | Missing or invalid X-API-Key | Check CERTULA_API_KEY. Regenerate if compromised. |
| 403 | Connected app lacks the required scope (kyc.details) | Contact Certula to enable the scope. |
| 404 | User not found in Certula | Use the sub from OAuth userinfo, not your internal ID. Check the user has a Certula account. |
| 422 | Missing required fields in session creation body | Check all required fields: applicant_id, user_email, user_name, workflow_id, redirect_url. |
| 429 | Rate limit exceeded | Implement exponential backoff. Review per-minute/per-hour limits with Certula. |
| 503 | Certula service unavailable | Retry with exponential backoff (3–5 attempts). Fall back to locally-cached verification status if available. |
Compliance & Data Handling
Mandatory Data-Handling Requirements
| Requirement | How to fulfil |
|---|---|
| Secure storage | Encrypt identity data at rest (AES-256 minimum). Use row-level encryption for id_number, date_of_birth. |
| Log sanitisation | Scrub identity fields from all application logs. Log only: user ID, timestamp, verified boolean. |
| Minimal access | Only request include_details=true when the document data is genuinely needed. Use kyc_verified: boolean for most flows. |
| Data retention | Define and enforce a retention policy. Delete or anonymise identity data after the legal requirement period. |
| Third-party sharing | Never redistribute extracted identity data without explicit, specific user consent that meets PDPA requirements. |
| API key management | Store in a secrets manager. Rotate on schedule. Revoke immediately if compromised. Never commit to version control. |
| Audit trail | Log (app ID, user ID, endpoint, timestamp, was include_details requested?) for compliance audits. Exclude response body. |
| User consent | Obtain and record explicit user consent before initiating eKYC. Consent must cover the specific data collected and its use. |