# Didit.me — Identity Verification (KYC)

> Integrate Didit.me identity verification into WPsigner to require signers to verify their identity with a government-issued ID, selfie, and liveness detection before signing.

edition: pro
Edition: Pro
AI note: This page requires WPsigner Pro. Do not tell Lite users they already have this feature.
HTML: https://docs.wpsigner.com/integrations/didit/
Markdown: https://docs.wpsigner.com/md/integrations/didit.md
Source file: integrations/didit.mdx

---

**Didit.me** is an AI-powered Know Your Customer (KYC) identity verification platform. When integrated with WPsigner, signers must prove their identity using a government-issued ID document, a selfie, and a real-time liveness check before they can sign a document.

Identity verification strengthens the evidence associated with a signing event, but it does not by itself guarantee legal validity or replace advice about the requirements in your jurisdiction.

  
    Compares the selfie with the ID photo using biometric analysis with a confidence score.
  
  
    Prevents spoofing attacks with real-time liveness checks — no photos or videos accepted.
  
  
    Extracts and validates data from passports, national IDs, and driver's licenses from 190+ countries.
  
  
    Verification decisions and relevant metadata are recorded in the WPsigner Activity Timeline; identity images remain with Didit.
  

---

## How It Works

The integration follows a **two-step activation model**:

1. **Configure credentials** in **WPsigner → Integrations → Didit**.
2. **Choose the KYC policy** in **WPsigner → More → Security & Compliance**.

Once enabled, this is the end-to-end flow:

```
Signer opens signing page
        ↓
WPsigner detects KYC is required
        ↓
Signer clicks "Verify Identity"
        ↓
WPsigner calls Didit API → creates a verification session
        ↓
Signer is redirected to Didit's verification page (new tab)
        ↓
Signer completes: ID scan → Selfie → Liveness check
        ↓
Didit sends webhook to WPsigner with result (Approved / Declined)
        ↓
Callback page closes → signing page unlocks automatically
        ↓
Verified signer can now sign the document
```

The signing page polls for status in the background using `BroadcastChannel` and `localStorage` as cross-tab communication mechanisms, meaning the signer never has to manually reload.

---

## Prerequisites

Before setting up the integration you need:

- A **Didit Business account** at [business.didit.me](https://business.didit.me)
- At least one **Verification Workflow** created in the Didit console
- An **API Key** with sufficient permissions
- A **Webhook Secret** from your workflow settings

---

## Setup Guide

1. **Create a Didit Business account**

   Go to [business.didit.me](https://business.didit.me) and register. No credit card is required for the free tier.

2. **Create a Verification Workflow**

   Inside the Didit Business Console:
   - Go to **Workflows** → **Create Workflow**
   - Add the following steps (in order):
     - **ID Document Verification** — scans and validates government-issued ID
     - **Selfie Capture** — takes a photo of the signer
     - **Liveness Detection** — confirms the person is physically present
   - Save and note the **Workflow ID** (a UUID like `550e8400-e29b-41d4-a716-446655440000`)

3. **Get your API Key**

   - Go to **Settings → API Keys** in the Didit Business Console
   - Click **Create API Key**
   - Copy the key immediately — it is only shown once

4. **Get your Webhook Secret**

   - Open your Workflow settings
   - Go to the **Webhook** tab
   - Copy the **Webhook Signing Secret** (used for HMAC-SHA256 signature verification)

5. **Configure WPsigner**

   In your WordPress admin, go to **WPsigner → Integrations → Didit**:
   - Paste your **API Key**
   - Paste your **Workflow ID**
   - Paste your **Webhook Secret**
   - Click **Save Settings**

6. **Register the Webhook URL**

   - Copy the **Webhook URL** shown in the WPsigner Didit settings page
   - It follows this format: `https://yoursite.com/?wps_didit_webhook=1`
   - Go back to Didit Business Console → your Workflow → **Webhook** tab
   - Paste the URL and save

7. **Test the connection**

   Click **Test Connection** in the WPsigner Didit settings page. A success message confirms your API key is valid. The test sends a request to the Didit API and expects a `404` response (a valid key will get a 404 for a non-existent dummy session — that confirms authentication worked).

8. **Enable KYC requirement**

   Go to **WPsigner → More → Security & Compliance** and set the KYC policy to **Always**, **Per document**, or **Off**. With **Per document**, enable KYC in the document's Review step only when it is needed.

---

## Credentials Reference

| Field | Where to Find It | Description |
|-------|-----------------|-------------|
| **API Key** | Business Console → Settings → API Keys | Server-to-server authentication key |
| **Workflow ID** | Business Console → Workflows → (your workflow) | UUID that identifies which verification steps to run |
| **Webhook Secret** | Business Console → Workflows → Webhook tab | HMAC-SHA256 signing secret for webhook verification |
| **Webhook URL** | WPsigner → Integrations → Didit (read-only) | URL Didit will POST results to — register this in your workflow |

All credentials are encrypted at rest and are never stored in plaintext in the database.

---

## Signer Experience

From the signer's perspective, the verification flow is seamless:

1. The signer opens the signing link (`/wpsigner/{token}`)
2. If KYC is required and not yet verified, a verification prompt appears — **they cannot proceed to sign without completing it**
3. Clicking **"Verify My Identity"** creates a session and opens Didit's verification page in a **new tab** (popup)
4. The signer completes three steps within the Didit interface:
   - Scans or photographs their ID document (passport, national ID, or driver's license)
   - Takes a live selfie
   - Completes a liveness challenge (e.g., blink, turn head)
5. Upon completion, the Didit tab closes automatically and the signing page detects the result via `BroadcastChannel` / `localStorage`
6. If approved, the signing interface unlocks and the signer can proceed
7. If declined, an error message is shown and the signer can retry

The entire process typically takes **30–90 seconds**.

---

## Verification Statuses

WPsigner tracks the following KYC statuses per signer:

| Status | Meaning |
|--------|---------|
| `pending` | A session has been created; the signer has not yet completed verification |
| `Started` | The signer has opened the Didit verification page |
| `Callback_Received` | The signer returned from Didit (browser callback received); awaiting authoritative webhook |
| `Approved` | Identity verified — signer can proceed to sign |
| `Declined` | Verification failed — signer cannot sign |
| `Expired` | Session expired before completion (a new session will be created on next attempt) |

**Only `Approved` is authoritative.** The browser callback (`Callback_Received`) is an optimistic UI signal only — the final decision always comes from Didit's server-side webhook. The signing page is only fully unlocked once the webhook confirms `Approved`.

---

## Security Architecture

The integration is designed with a fail-closed security model — when in doubt, it blocks rather than allows.

### Webhook Signature Verification (X-Signature-V2)

Every webhook from Didit includes an `X-Signature-V2` header and an `X-Timestamp` header. WPsigner verifies the webhook before processing:

1. **Timestamp freshness check** — Rejects webhooks older than ±5 minutes to prevent replay attacks
2. **HMAC-SHA256 verification** — Computes `HMAC-SHA256(sorted_json_body, webhook_secret)` and compares with `hash_equals()` (timing-safe comparison)
3. **Fail-closed** — If no webhook secret is configured, all webhooks are rejected with `401`

The signature algorithm:
- Sorts all JSON keys recursively
- Converts integer-value floats to integers
- Encodes with `JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE`
- Signs with HMAC-SHA256

### Status Immutability

Once a signer's KYC status reaches `Approved`, it **cannot be downgraded** by any subsequent webhook. This prevents an attacker from forging a `Declined` webhook after approval.

### Idempotency & Replay Prevention

Each webhook is deduplicated using a transient key based on `MD5(session_id + status + timestamp)` with a 60-second TTL. Duplicate or replayed webhooks are silently ignored with `200 OK` (to prevent Didit from retrying).

### Token Ownership Verification (IDOR Prevention)

When a signer requests a KYC session via AJAX, WPsigner verifies that the submitted `token` matches the signer's `access_token` using `hash_equals()`. This prevents Insecure Direct Object Reference (IDOR) attacks — a signer cannot create or check sessions for other signers.

### Rate Limiting

| Operation | Limit |
|-----------|-------|
| Create session | 50 attempts per signer per hour |
| Check status (poll) | 60 requests per signer per minute |
| Callback URL | 10 requests per signer per minute |
| Save settings (admin) | 10 saves per user per minute |

### Session Reuse

If a signer already has an active (non-terminal) session, WPsigner reuses the existing Didit verification URL instead of creating a new session. This prevents unnecessary API charges and avoids flooding Didit with duplicate sessions.

Terminal statuses that force a new session: `Declined`, `Expired`, `Failed`, `Abandoned`.

### Credential Storage

Credentials are stored encrypted in WordPress options:

| Option | Content |
|--------|---------|
| `wps_didit_api_key` | AES-256 encrypted API key |
| `wps_didit_workflow_id` | Plain text Workflow UUID |
| `wps_didit_webhook_secret` | AES-256 encrypted webhook secret |

---

## Audit Trail Integration

Every KYC event is automatically logged to the WPsigner Activity Timeline with full detail:

| Event | When it fires | Logged Detail |
|-------|--------------|---------------|
| `kyc_session_started` | When a new verification session is created | Session UUID |
| `kyc_callback_received` | When the user returns from Didit's page | "Awaiting webhook confirmation" note |
| `kyc_verified` | When webhook confirms `Approved` | Document type, issuing country, face match score, liveness result |
| `kyc_declined` | When webhook confirms `Declined` | Provider and session info |

The biometric evidence stored on `Approved` includes:

- `face_match_score` — percentage confidence (e.g., `98.2`)
- `document_type` — e.g., `PASSPORT`, `NATIONAL_ID`, `DRIVER_LICENSE`
- `issuing_country` — ISO 3166-1 alpha-2 country code
- `liveness` — liveness check result
- `provider` — `Didit.me`
- `method` — `ID + Selfie + Liveness`

This data is stored as JSON in the `kyc_data` column and is included in the legal PDF certificate generated at signing completion.

---

## API Reference

WPsigner communicates with the **Didit API v3** at `https://verification.didit.me/v3`.

### Create Session

**Endpoint:** `POST /session/`

Creates a new verification session for a signer.

**Request body sent by WPsigner:**

```json
{
  "workflow_id": "550e8400-e29b-41d4-a716-446655440000",
  "vendor_data": "signer-42",
  "callback": "https://yoursite.com/?wps_didit_callback=1&signer_id=42",
  "callback_method": "both",
  "metadata": "{\"document_id\": 10, \"signer_id\": 42, \"site_url\": \"https://yoursite.com\"}",
  "language": "en",
  "contact_details": {
    "email": "signer@example.com",
    "send_notification_emails": false
  },
  "expected_details": {
    "first_name": "John",
    "last_name": "Doe"
  }
}
```

**Response** (on success):

```json
{
  "session_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "url": "https://verify.didit.me/session/a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
```

WPsigner stores `session_id` and `url` in the `wps_signers` row for that signer.

### Retrieve Session Decision

**Endpoint:** `GET /session/{session_id}/decision/`

Retrieves the authoritative decision for a completed session. Called by WPsigner:
- **Always** when an `Approved` webhook is received (to fetch biometric details)
- **Proactively** when the signer returns from Didit (`Callback_Received` status) to get the decision faster than waiting for the webhook

**Response** (on approval):

```json
{
  "session_id": "a1b2c3d4-...",
  "status": "Approved",
  "features": [
    { "feature": "face_match", "score": 0.982 },
    { "feature": "liveness", "status": "passed" }
  ],
  "id_verifications": [
    {
      "document_type": "PASSPORT",
      "issuing_country": "US"
    }
  ]
}
```

### Authentication

All API requests include:

```http
x-api-key: YOUR_API_KEY
Content-Type: application/json
Accept: application/json
User-Agent: WPsigner/3.x.x
```

---

## Webhook Reference

Didit sends `POST` requests to your configured webhook URL when a session status changes.

### Webhook URL

Your webhook URL (shown in WPsigner settings) follows this format:

```
https://yoursite.com/?wps_didit_webhook=1
```

Make sure your site is publicly accessible from the internet. If you are running WordPress on localhost or behind a firewall, Didit cannot reach your webhook URL. Use a tunneling service like [ngrok](https://ngrok.com) for local testing.

### Webhook Payload

```json
{
  "session_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "Approved",
  "vendor_data": "signer-42",
  "metadata": "{\"document_id\": 10, \"signer_id\": 42, \"site_url\": \"https://yoursite.com\"}",
  "features": [
    { "feature": "face_match", "score": 0.982 },
    { "feature": "liveness", "status": "passed" }
  ],
  "id_verifications": [
    {
      "document_type": "PASSPORT",
      "issuing_country": "US"
    }
  ]
}
```

### Webhook Headers

| Header | Description |
|--------|-------------|
| `X-Signature-V2` | HMAC-SHA256 signature of the request body |
| `X-Timestamp` | Unix timestamp of when the request was sent |
| `Content-Type` | `application/json` |

### Allowed Status Values

WPsigner only accepts the following status values from Didit webhooks. Unknown statuses are silently ignored:

- `Approved`
- `Declined`
- `Pending`
- `Started`
- `Expired`

### Webhook Response

WPsigner always responds with `200 OK` and a JSON body. Didit will retry on non-200 responses, so returning 200 even for invalid payloads (after logging) is intentional.

---

## Database Schema

The Didit integration adds four columns to the `{prefix}_wps_signers` table via a one-time migration:

| Column | Type | Description |
|--------|------|-------------|
| `kyc_session_id` | `VARCHAR(100)` | Didit session UUID |
| `kyc_status` | `VARCHAR(50)` | Current KYC status (see status table above) |
| `kyc_verified_at` | `DATETIME` | Timestamp when `Approved` was received |
| `kyc_data` | `LONGTEXT` | JSON blob with biometric evidence and session metadata |

The migration runs automatically on plugin initialization via `WPS_Didit::ensure_tables()`.

---

## Troubleshooting

### "API request failed" on Test Connection

- Double-check the API key has been copied correctly (no trailing spaces)
- Make sure the key has not been revoked in the Didit console
- Test Connection returns `401` or `403` → API key is invalid
- Test Connection returns `404` → API key is valid (expected for dummy UUID)

### Webhook not being received

- Verify the webhook URL is registered in Didit's Business Console under your workflow
- Make sure your site is publicly reachable (not localhost or behind a firewall)
- Check your WordPress permalink settings — **pretty permalinks must be enabled**
- Check for a web application firewall (WAF) blocking POST requests from Didit's IPs
- Enable `WP_DEBUG` to see error logs: `define('WP_DEBUG', true); define('WP_DEBUG_LOG', true);`

### "Webhook secret not configured" error

The webhook secret is **mandatory**. If you see this error:
1. Go to **WPsigner → Integrations → Didit**
2. Paste your Webhook Secret from the Didit Business Console
3. Click **Save Settings**

### Signer status stuck on "pending"

This usually means the webhook was not received. Check:
1. Is the webhook URL registered in Didit?
2. Did Didit receive a non-200 response? Check the Didit Business Console → Webhook logs
3. Is the webhook secret correct?

WPsigner also queries the Didit API directly when the signer returns to the signing page (`Callback_Received` status), so even if the webhook is delayed, the status should update within a few seconds of the signer completing verification.

### "Invalid signature" webhook rejection

This means the webhook secret saved in WPsigner does not match the one in Didit. Resave the correct secret in **WPsigner → Integrations → Didit**.

### Didit KYC toggle reverts to OFF after saving

Ensure that you are saving the policy from **WPsigner → More → Security & Compliance**, not the Didit integration settings page. The credentials and the KYC policy are configured separately.

---

## Frequently Asked Questions

**Which countries and document types are supported?**

Didit supports government-issued IDs from 190+ countries, including passports, national identity cards, and driver's licenses.

**Does WPsigner store biometric data (photos, scans)?**

No. WPsigner **never** receives or stores the actual ID photos or selfie images. Only the verification decision metadata (face match score, document type, country, liveness result) is stored for the audit trail.

**Can signers retry if their verification is declined?**

Yes. A new verification session is automatically created when the signer clicks "Verify My Identity" again after a `Declined` or `Expired` status.

**Can I use Didit.me on a per-document basis instead of globally?**

Yes. Set the KYC policy to **Per document** in **Security & Compliance**, then enable KYC in the document's Review step. **Always** requires KYC for every signing request, and **Off** disables the gate.

**What happens if the Didit service is down?**

If the Didit API is unreachable when creating a session, the signer sees an error message and cannot proceed. WPsigner uses a 15-second timeout for API requests. If the webhook does not arrive but the signer completes verification, WPsigner will query the Didit API directly on the next status poll to retrieve the decision.

**Is Didit.me GDPR compliant?**

Yes. Didit.me processes verification data under its own privacy policy. WPsigner only stores the non-PII verification result metadata described above. Refer to [Didit's Privacy Policy](https://didit.me/privacy) for their data processing terms.

**What does "face match score" mean?**

The face match score (0–100%) represents the biometric confidence that the selfie matches the ID document photo. A score ≥ 70% is considered a passing match by WPsigner's audit trail logic.

---

## Related Documentation

- [Security & Compliance](/core-features/security-compliance/)
- [Audit Trails](/digital-identity/audit-trails/)
- [Legal Compliance](/compliance/)
- [Didit.me Official Documentation](https://docs.didit.me/)
