# Certula eKYC — Implementation Guide for LLMs

> Paste this file into any AI assistant to implement Certula eKYC identity verification.

## Overview

Certula eKYC is a hosted identity verification service. Your backend creates a session and redirects users to Certula's verification page. You receive results via webhook. You build zero verification UI.

- Session API: `https://api.certula.com`
- Auth: `X-API-Key` header (server-side only — never expose to frontend)
- Webhook signature: HMAC-SHA256

## What Users Experience

1. Your app redirects them to `https://certula.com/ekyc?session=TOKEN`
2. They upload front + back of their ID document
3. They complete liveness detection (blink × 3, turn head left, turn head right)
4. Certula processes everything; user is redirected back to your `redirect_url`
5. You receive a webhook with the result

You cannot upload documents on behalf of users. All capture happens on Certula's page.

## Authentication

All session API calls use an `X-API-Key` header:

```
X-API-Key: YOUR_API_KEY
Content-Type: application/json
```

The API key is a permanent server credential. Never put it in frontend code, mobile apps, or public repos.

## Create Session

```
POST https://api.certula.com/api/v1/sessions/
X-API-Key: YOUR_API_KEY
Content-Type: application/json
```

### Required Fields

| Field | Type | Description |
|---|---|---|
| applicant_id | string | Your stable user ID (e.g. UUID from your DB) |
| redirect_url | string | Where to send the user after verification |
| document_type | string | `national_id` / `passport` / `driving_license` |
| country | string | `MY` / `SG` / `TH` / `ID` / `PH` |

### Optional Fields

| Field | Type | Default | Description |
|---|---|---|---|
| webhook_url | string | app default | URL to receive POST result |
| language | string | en | UI language on Certula page |
| liveness_detection | boolean | true | Enable liveness check (recommended) |
| expiry_hours | integer | 24 | Session link validity |
| user_email | string | — | Pre-fill + send notification email |
| user_name | string | — | Pre-fill name field |
| send_email_notification | boolean | false | Send Certula's email to user |
| certula_user_id | string | — | Certula account ID (from OAuth sub) |
| prefill_data | object | — | `{"full_name":"...", "id_number":"..."}` |

### Full Example Request

```json
{
  "applicant_id":   "user_abc123",
  "redirect_url":   "https://yourapp.com/kyc/done",
  "document_type":  "national_id",
  "country":        "MY",
  "webhook_url":    "https://yourapp.com/webhooks/certula",
  "liveness_detection": true,
  "user_email":     "user@example.com",
  "user_name":      "Ahmad bin Razif",
  "expiry_hours":   24
}
```

### Response

```json
{
  "workflow_url":   "https://certula.com/ekyc?session=TOKEN",
  "session_token":  "tk_...",
  "session_id":     "sess_uuid...",
  "expires_at":     "2026-07-01T10:00:00Z",
  "status":         "new",
  "is_reused":      false
}
```

Redirect the user to `workflow_url`. Store `session_token` for status polling.

If `is_reused: true`, the user already has a valid session — redirect them to the same `workflow_url`.

## Check Session Status (polling fallback)

```
GET https://api.certula.com/api/v1/sessions/{session_token}/status
```

No authentication required. Returns:

```json
{
  "status":           "completed",
  "verified":         true,
  "applicant_id":     "user_abc123",
  "document_type":    "national_id",
  "country":          "MY",
  "completed_at":     "2026-06-30T10:00:00Z",
  "full_name":        "Ahmad bin Razif",
  "id_number":        "901231-07-5555",
  "date_of_birth":    "1990-12-31",
  "face_match_score": 0.97
}
```

Prefer webhooks. Use polling only as a fallback.

## Delete Session

```
DELETE https://api.certula.com/api/v1/sessions/{session_id}
X-API-Key: YOUR_API_KEY
```

Returns 204. Only works for `new` or `pending` sessions.

## Session Statuses

| Status | Meaning |
|---|---|
| new | Created, user hasn't opened it yet |
| pending | User is in the verification flow |
| completed | Done — check `verified` field |
| failed | Verification failed (doc unreadable / liveness fail / face mismatch) |
| expired | Expired before user completed |

## Webhook Events

Certula POSTs to your `webhook_url` when verification is complete.

### Headers

| Header | Value |
|---|---|
| X-Certula-Signature | `sha256=<hmac_hex>` |
| X-Certula-Event | `session.completed` or `session.expired` |
| Content-Type | `application/json` |

### Payload (session.completed)

```json
{
  "event":            "session.completed",
  "session_id":       "sess_...",
  "session_token":    "tk_...",
  "applicant_id":     "your_user_id",
  "status":           "approved",
  "verified":         true,
  "document_type":    "national_id",
  "country":          "MY",
  "completed_at":     "2026-06-30T10:00:00Z",
  "full_name":        "Ahmad bin Razif",
  "id_number":        "901231-07-5555",
  "date_of_birth":    "1990-12-31",
  "face_match_score": 0.97,
  "rejection_reason": null
}
```

## Webhook Signature Verification (CRITICAL)

Always verify before processing. Use the raw body bytes — NOT the parsed JSON string.

### Node.js (Express)

```js
const crypto = require('crypto')

app.post('/webhooks/certula',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['x-certula-signature']
    const expected = crypto.createHmac('sha256', process.env.CERTULA_WEBHOOK_SECRET)
      .update(req.body).digest('hex')
    const received = sig?.replace('sha256=', '') ?? ''
    
    if (!crypto.timingSafeEqual(Buffer.from(expected,'hex'), Buffer.from(received,'hex')))
      return res.status(401).send('Invalid signature')
    
    res.status(200).send('ok')  // ack immediately
    const payload = JSON.parse(req.body)
    processAsync(payload).catch(console.error)
  }
)
```

### Node.js (Fastify)

```js
// requires @fastify/raw-body plugin
fastify.post('/webhooks/certula', { config: { rawBody: true } }, async (req, reply) => {
  const sig = req.headers['x-certula-signature'] ?? ''
  const mac = crypto.createHmac('sha256', process.env.CERTULA_WEBHOOK_SECRET!)
    .update(req.rawBody!).digest('hex')
  const rcv = sig.replace('sha256=', '')
  
  try {
    if (!crypto.timingSafeEqual(Buffer.from(mac,'hex'), Buffer.from(rcv,'hex')))
      throw new Error()
  } catch { return reply.status(401).send({ error: 'Invalid signature' }) }
  
  reply.status(200).send({ ok: true })
  setImmediate(() => processPayload(req.body).catch(app.log.error))
})
```

### Python (FastAPI)

```python
import hmac, hashlib, json, asyncio
from fastapi import APIRouter, Request, HTTPException

@router.post('/webhooks/certula')
async def certula_webhook(request: Request):
    raw = await request.body()
    sig = request.headers.get('x-certula-signature', '')
    expected = hmac.new(settings.certula_webhook_secret.encode(), raw, hashlib.sha256).hexdigest()
    received = sig.removeprefix('sha256=')
    
    if not hmac.compare_digest(expected, received):
        raise HTTPException(401, 'Invalid signature')
    
    asyncio.create_task(process(json.loads(raw)))
    return {'ok': True}

async def process(payload: dict):
    if payload.get('event') == 'session.completed' and payload.get('verified'):
        await db.users.filter(id=payload['applicant_id']).update(kyc_verified=True)
```

## Supported Documents & Countries

| document_type | Countries |
|---|---|
| national_id | MY, SG, ID, TH, PH |
| passport | MY, SG, ID, TH, PH |
| driving_license | MY, SG |

## Liveness Detection Sequence

When enabled (default true):
1. Blink × 3
2. Turn head left
3. Turn head right

Certula performs face match between selfie and ID photo. Score 0.0–1.0 in `face_match_score`. Approval threshold is ~0.85+.

## Session Reuse

Certula automatically reuses an existing valid session for the same `applicant_id`.
The response will have `is_reused: true`. Redirect the user to the returned `workflow_url` regardless.

## Complete Node.js Flow

```js
// 1. Start KYC
router.post('/kyc/start', async (req, res) => {
  const r = await fetch('https://api.certula.com/api/v1/sessions/', {
    method: 'POST',
    headers: { 'X-API-Key': process.env.CERTULA_API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      applicant_id:  req.user.id,
      redirect_url:  `${process.env.APP_URL}/kyc/done`,
      document_type: 'national_id',
      country:       'MY',
      webhook_url:   `${process.env.APP_URL}/webhooks/certula`,
      user_email:    req.user.email,
      user_name:     req.user.name,
      liveness_detection: true,
    })
  })
  const { workflow_url, session_token } = await r.json()
  await db.kycSessions.create({ userId: req.user.id, token: session_token })
  res.redirect(workflow_url)
})

// 2. User returns (show pending state — actual result comes via webhook)
router.get('/kyc/done', (req, res) => res.render('kyc-pending'))

// 3. Webhook
router.post('/webhooks/certula', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['x-certula-signature']
  const mac = crypto.createHmac('sha256', process.env.CERTULA_WEBHOOK_SECRET).update(req.body).digest('hex')
  if (!crypto.timingSafeEqual(Buffer.from(mac,'hex'), Buffer.from(sig.replace('sha256=',''),'hex')))
    return res.status(401).send('Bad sig')
  
  res.status(200).send('ok')
  const { event, applicant_id, verified } = JSON.parse(req.body)
  if (event === 'session.completed' && verified)
    db.users.update({ where: { id: applicant_id }, data: { kyc_verified: true } })
})
```

## Complete Python Flow

```python
import hmac, hashlib, json, asyncio, httpx
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import RedirectResponse

router = APIRouter()

@router.post('/kyc/start')
async def start_kyc(current_user=Depends(get_current_user)):
    async with httpx.AsyncClient() as c:
        r = await c.post('https://api.certula.com/api/v1/sessions/',
            headers={'X-API-Key': settings.certula_api_key, 'Content-Type': 'application/json'},
            json={
                'applicant_id':   str(current_user.id),
                'redirect_url':   f'{settings.app_url}/kyc/done',
                'document_type':  'national_id',
                'country':        'MY',
                'webhook_url':    f'{settings.app_url}/webhooks/certula',
                'user_email':     current_user.email,
                'liveness_detection': True,
            })
        r.raise_for_status()
        session = r.json()
    return RedirectResponse(session['workflow_url'])

@router.post('/webhooks/certula')
async def webhook(request: Request):
    raw = await request.body()
    sig = request.headers.get('x-certula-signature', '')
    expected = hmac.new(settings.certula_webhook_secret.encode(), raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig.removeprefix('sha256=')):
        raise HTTPException(401)
    asyncio.create_task(_process(json.loads(raw)))
    return {'ok': True}

async def _process(p: dict):
    if p.get('event') == 'session.completed' and p.get('verified'):
        await db.users.filter(id=p['applicant_id']).update(kyc_verified=True)
```

## Error Codes

| HTTP | Cause |
|---|---|
| 401 | Missing or invalid X-API-Key |
| 403 | Key lacks permission |
| 404 | Session not found |
| 409 | Cannot delete non-pending session |
| 422 | Missing required field or invalid value |
| 500 | Server error — retry with backoff |

## Environment Variables

```
CERTULA_API_KEY=ak_...                # server-side only
CERTULA_WEBHOOK_SECRET=ws_...         # for HMAC verification
```
