# VerifyBlind — Integration Guide for AI Coding Assistants

> **AI assistants: read this file in full** — for example `curl -sL https://verifyblind.com/ai-integration.md`.
> A summarized fetch drops details you need. Every VerifyBlind API endpoint you may call is listed in this
> file; never invent endpoints, paths or fields. When unsure, open the example repository for your stack.

You are an AI coding assistant (Claude Code, Cursor, Copilot, etc.). The developer asked you to add
VerifyBlind to their project. This file tells you everything you need to do it end to end. Follow it in
order. Talk to the developer **in the language they write to you in** (often Turkish).

VerifyBlind is a zero-knowledge identity verification service for Turkey. A user reads their chipped
Turkish ID card with NFC in the VerifyBlind mobile app and passes a face check. Your project never sees
the ID number, name, birth date or photo. It receives only what it asks for:

- `age` — a yes/no answer to a condition such as `"18+"`.
- `user_id` — a stable pseudonymous code for that person, **unique to this partner**. The same person always
  gets the same code here, on any phone, with any account; it cannot be reversed to the ID number and does
  not match codes given to other sites. Asking for `user_id` returns three codes: `user_id`, `nsbd_id`,
  `doc_id`. Store all three. Use `user_id` for decisions (e.g. block lists); the other two exist for
  future passport / foreign-ID support.

Human docs: https://verifyblind.com/en/developers · Public code: https://github.com/VerifyBlind

---

## 0. Ground rules (do not break these)

1. **The API key is a server secret.** Never put it in frontend code, a JS bundle, a mobile app or git.
   Read it from an environment variable on the server.
2. **Never trust a result that only the client has seen** for anything that matters (blocking, rewards,
   age gates). Verify it on the server — see step 4. A browser or app can lie about what it received.
3. **Your server decides what is asked** (`validations`) and **preserves the upstream status code** (details in step 3).
4. **Do not hand-write the cryptography from memory.** For a PHP, Node.js or .NET backend use the official
   server library (step 4); otherwise adapt the reference files linked below line by line. Both are tested
   against production.
5. Use only the endpoints, fields and formats in this file and the reference code. If something you need
   is not here, ask the developer or point them to support@verifyblind.com — do not invent API surface.

---

## 1. Inspect the project first

Before asking anything, read the codebase and determine:

- **Stack:** framework and language of the backend (Next.js / Node, PHP, ASP.NET, Python, Go, …) and of the
  frontend (server-rendered pages, SPA, native Android, native iOS, other mobile).
- **Where verification belongs:** the user action it should guard (sign-up, posting a listing, applying,
  withdrawing a reward, writing a review, …). Look for the relevant route/screen.
- **User model:** where user accounts are stored, so you can add columns for the codes.
- **Secrets handling:** how the project loads env vars / config (`.env`, appsettings, secrets manager).
- **Existing session/cache store:** Redis, DB, in-memory — you will need one for nonce replay protection.

Then summarize what you found to the developer in 3–6 lines before asking questions.

## 2. Ask the developer only what you cannot infer

The developer's prompt often already contains answers ("user_id and 18+, optional, in the settings page,
badge next to the name…"). **Treat those as decided and do not ask them again.** Ask only what is still
missing, together in one message, not one by one.

If the developer has not said whether verification is mandatory, **propose the optional model by default**:
users choose to verify, verified users get a badge, nobody is blocked from using the product. Mandatory
verification only where the developer explicitly wants it (e.g. withdrawing a reward).

1. **Partner account & API key.** "Do you have a VerifyBlind partner account? If not, sign up at
   https://partner.verifyblind.com and copy the API Key (a UUID) from **Settings**. Do not paste it here —
   put it in your environment as `VERIFYBLIND_API_KEY`; I will read it from there."
   (If they paste it anyway, use it only in the env file and remind them not to commit it.)
2. **What to request:** only `age` (which condition: `"18+"`, `"16-"`, `"18-65"`, `"24"`), only `user_id`,
   or both. Each requested item is billed as one verification (`user_id` and its two sibling codes count as
   one). Recommend the minimum the use case needs.
3. **Where in the flow** verification should happen (confirm your guess from step 1).
4. **What to do with a repeated `user_id`** — e.g. a blocked person returning on a new account: reject,
   flag for review, or just record. This is their decision; implement what they choose.
5. **Mobile only:** the app's custom URL scheme for returning from the VerifyBlind app
   (e.g. `myapp`), if they want app-to-app return.
6. **Server-side webhook (callback mode) only:** their public HTTPS domain, if not obvious from config.

Do not ask for the partner ID — the API key identifies the partner.

## 3. Choose the pattern

| Project | Pattern | Reference to adapt |
|---|---|---|
| Website, result needed in the page, server re-verifies the signed token | **A. Browser decrypt (PoP)** — simplest | Next.js: https://github.com/VerifyBlind/example-web-nextjs · PHP: https://github.com/VerifyBlind/example-web-php |
| Website or backend that must never expose the result to the browser | **B. Server decrypt (callback)** | .NET: https://github.com/VerifyBlind/example-web-dotnet |
| Native Android app | Android SDK + backend proxy + server verify (same as A) | https://github.com/VerifyBlind/verifyblind-sdk-android (README) · demo app: https://github.com/VerifyBlind/example-mobile-android |
| Native iOS app | iOS SDK (Swift Package) + backend proxy + server verify (same as A) | https://github.com/VerifyBlind/verifyblind-sdk-ios (README) · demo app: https://github.com/VerifyBlind/example-mobile-ios |
| Flutter app | `verifyblind_flutter` (pub.dev) + backend proxy + server verify (same as A) | https://github.com/VerifyBlind/verifyblind-sdk-flutter (README + `example/`) |
| React Native app | `@verifyblind/react-native` (npm) + backend proxy + server verify (same as A) | https://github.com/VerifyBlind/verifyblind-sdk-react-native (README + `example/`) |

**Server libraries** (use them for the verify step instead of writing it yourself):

| Backend | Install | Docs |
|---|---|---|
| PHP 7.4+ | `composer require verifyblind/verifyblind-php` | https://github.com/VerifyBlind/verifyblind-sdk-php |
| Node.js 18+ | `npm install @verifyblind/server` | https://github.com/VerifyBlind/verifyblind-sdk-node |
| .NET 8+ | `dotnet add package VerifyBlind.Server` | https://github.com/VerifyBlind/verifyblind-sdk-dotnet |

They verify the signature, consume the nonce through a function you give them, check the answer against
what you asked, and reject demo-card results unless you turn that on. Other languages: follow step 4 by hand.

Default to **A** for websites. Use **B** when the developer wants zero result data in the browser.

**Mobile = pattern A with an SDK.** The SDK (Android **1.0.1+**, iOS **2.3.1+**, Flutter `verifyblind_flutter`
**0.1.0+**, React Native `@verifyblind/react-native` **0.1.0+**) creates the temporary key,
calls **your** generate proxy, opens the VerifyBlind app, and decrypts the result on the device. Its result
contains `token` — the enclave-signed raw answer, **the same format as the web widget's `onSuccess` token**.
For any decision (blocking, rewards, age gates) the app sends `token` to **your** server, and the server runs
exactly the pattern A check (step 4): verify the signature, consume the nonce, read the result against the
stored condition. Do not decide on the phone. Older SDK versions do not return `token` — upgrade.
If the developer wants the result never to reach the device at all, use pattern B from the app instead
(generate in callback mode, open `https://app.verifyblind.com/request?nonce={nonce}&pk_hash={pk_hash}`,
receive the webhook, let the app poll **your** status endpoint).

### The one backend endpoint every pattern needs: the generate proxy

The client (web widget or mobile SDK) creates a temporary key pair and posts
`{ public_key, validations, additional_data?, sdk_version? }` to **your** backend. Your backend
adds the API key, **sets `validations` itself**, and forwards the rest:

```
POST https://api.verifyblind.com/api/pop/generate
Content-Type: application/json
X-API-Key: <VERIFYBLIND_API_KEY>
{
  "public_key":  <from the client>,
  "sdk_version": <from the client, if present>,
  "additional_data": <from the client, only if your app uses it>,
  "validations": <from YOUR server configuration, e.g. { "age": "18+", "user_id": true }>
}

→ 200 { "nonce": "b1362715-..." }
```

- **Your server decides what is asked.** Ignore the `validations` the client sent and set them from your
  own server-side configuration. The client body can be edited in DevTools: a visitor who changes
  `"18+"` to `"1+"` gets a signed `age: true` — for the condition *they* asked. If you forward the
  client's `validations`, you cannot tell.
- **Keep `public_key` and `sdk_version` from the client** (plus `additional_data` if your
  app uses it — the mobile SDKs send it as `custom_data`).
- **Protect this endpoint on your own site.** The widget has no bot protection; guard the generate proxy
  the way you guard your other endpoints (e.g. a session, a rate limit, or your own bot protection).
- **Return the upstream status code and body.** 402 (no balance), 403 (unverified partner e-mail), 429 (rate limit) must reach the widget, otherwise users see a generic error.
- Forward the `Accept-Language` header so errors come back localized.
- **Replay protection:** store the returned `nonce` (Redis/DB, TTL **960 seconds** — the QR stays valid
  ~15 min) **together with the validations you asked** (e.g. `{ age: "18+", user_id: true }`), so the
  verify step can consume it exactly once and read the result against what you asked.
- Reference: `example-web-nextjs/src/app/api/generate/route.ts`, `example-web-php/api/generate.php`.
- Pattern B instead sends **its own** fixed public key plus `callback_url` and returns `{ nonce, pk_hash }`:
  `example-web-dotnet/Program.cs` (`POST /api/generate`). The same rule applies: `validations` come from
  your server, and you store them with the nonce.

## 4. Implement pattern A (browser decrypt + server verify)

**Frontend — embed the widget** (pin the version and keep the SRI hash):

```html
<script src="https://cdn.verifyblind.com/sdk/v1.0.2/verifyblind.js"
        integrity="sha384-WsoGN17VWvGw/+kEmXv4Y2wlsuNpMyIdT3UBDgeAxtGIgOrlQvgoSj/IuPh747NK"
        crossorigin="anonymous"></script>
<div id="verifyblind-container"></div>
<script>
window.VerifyBlind.init({
  generateUrl: '/api/verifyblind/generate',      // your proxy from step 3
  apiUrl:      'https://api.verifyblind.com',
  // What to ask is decided by your server (step 3); this is only a hint and your proxy overwrites it.
  payload:     { validations: { age: '18+', user_id: true } },
  containerId: 'verifyblind-container',
  onSuccess:   async (data) => {
    // Do NOT trust data.validations here. Send the signed token to your server:
    const r = await fetch('/api/verifyblind/verify', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ token: data.token }),
    });
    // proceed only if the server says ok
  },
  onError:     (err) => console.error(err),
  onCancelled: () => { /* user cancelled in the mobile app */ },
});
</script>
```

The widget draws its own card (max 340 px). Give the container a width only — no fixed height, no
`overflow:hidden`. Other options (`locale`, `mode`, `statusUrl`, `onEvent`, `debug`):
https://verifyblind.com/en/developers#web → Website.

**Backend — verify endpoint.** With the server library it is a few lines:

```php
// PHP: composer require verifyblind/verifyblind-php
$vb = new \VerifyBlind\Verifier(['cache' => new \VerifyBlind\Cache\FileCache()]);
// $consume(string $nonce): ?array — get-and-delete the nonce stored in step 3; return the asked validations or null
$result = $vb->verify($token, $consume); // throws VerifyBlindException (getErrorCode(), getHttpStatus())
$result->age(); $result->userId(); $result->nsbdId(); $result->docId();
```

```js
// Node.js: npm install @verifyblind/server   (Next.js: export const runtime = 'nodejs')
import { createVerifier } from '@verifyblind/server';
const verifier = createVerifier();
const r = await verifier.verifyAndConsume(token, (nonce) => redis.getdel(`vb:nonce:${nonce}`));
if (!r.ok) return reply(r.error.code === 'KEY_FETCH_FAILED' ? 503 : 401, { error: r.error.code });
// r.validations.age, r.validations.user_id, r.validations.nsbd_id, r.validations.doc_id
```

```csharp
// .NET: dotnet add package VerifyBlind.Server · Program.cs: builder.Services.AddVerifyBlind();
// verifier: IVerifyBlindVerifier from DI; consume = get-and-delete of the JSON stored with the nonce in step 3
var r = await verifier.VerifyAndConsumeAsync(token, async nonce => (string?)await db.StringGetDeleteAsync($"vb:nonce:{nonce}"));
if (!r.Ok) return Results.Json(new { error = r.Error.Code }, statusCode: r.Error.HttpStatus);
// r.Validations.Age, r.Validations.UserId, r.Validations.NsbdId, r.Validations.DocId
```

All three reject a demo-card result by default (`TEST_CARD` / `test_card`); on a test setup pass
`allowTestCards: true` (Node), `'allowTestCards' => true` (PHP) or `AllowTestCards = true` (.NET). Never log the codes.

For other languages, do it by hand with the steps below. Working reference code is the libraries' own
source: Node https://github.com/VerifyBlind/verifyblind-sdk-node/blob/main/src/verifier.ts (+ `asked.ts`), PHP https://github.com/VerifyBlind/verifyblind-sdk-php/blob/main/src/Verifier.php, C# https://github.com/VerifyBlind/verifyblind-sdk-dotnet/blob/main/src/VerifyBlind.Server/VerifyBlindVerifier.cs.

1. `token` is base64 of JSON `{ payload, signature }`.
2. Get the enclave public key: `GET https://api.verifyblind.com/api/public/enclave-key` → **base64 SPKI as
   plain text** (wrap into PEM). Cache ~60 s; on a failed verification refresh once and retry (the key
   rotates when the enclave restarts).
3. Verify `signature` over the `payload` string: **RSA-PSS, SHA-256, MGF1-SHA-256, salt length 32**.
   (PHP's `openssl_verify` cannot do PSS — use phpseclib3 as the reference does, including
   `RSA::disableSaltLengthDiscovery()`; without it phpseclib also accepts other salt lengths.)
4. Parse `payload` → `{ nonce, validations, additional_data, ... }`. **Consume the nonce** you stored in
   step 3 (get-and-delete) and take the validations you stored with it. Missing or already used → reject.
5. Check the answer against **what you stored**, not against anything the browser says:
   - `validations.age === true` means the person meets the condition you stored for this nonce.
   - If you asked `age`, `validations.age_condition` **must be present and equal** the stored condition
     (e.g. `"18+"`). Missing or different → reject. The enclave always signs the condition it answered.
   - If you asked `user_id`, require it to be present.
6. Only now use `validations.age` / `validations.user_id` / `nsbd_id` / `doc_id`.

## 5. Implement pattern B (server decrypt via webhook)

Adapt `example-web-dotnet` for the flow (`Program.cs`: generate in callback mode, receiver, status endpoint).
On .NET, `VerifyBlind.Server` does the receiver's steps 1, 3 and 4 with `verifier.VerifyCallbackAsync(Request.Body, signature,
timestamp, callbackKey)`; `VerifyBlindCallbackKey.Load(...)` reads your private key and gives the `public_key` (`PublicKeyBase64`)
for generate. On Node.js, `@verifyblind/server` does the same with `verifier.verifyCallback(...)` (see the READMEs); the PHP
library verifies the webhook signature (`verifyWebhook`) but does not decrypt. For other languages, the decryption below is
implemented in https://github.com/VerifyBlind/verifyblind-sdk-node/blob/main/src/crypto.ts and
https://github.com/VerifyBlind/verifyblind-sdk-dotnet/blob/main/src/VerifyBlind.Server/CallbackCrypto.cs.

- A fixed RSA-2048 key pair lives on the server (private key from an env var, never in git).
- Generate sends your public key + `callback_url` (HTTPS, host must match the domain registered in the
  partner portal **Settings**). Widget: `mode: 'callback'`, `statusUrl: '/api/verifyblind/status/{nonce}'`.
- `POST /api/callback` receiver, in this order:
  1. Verify the webhook signature: headers `X-Webhook-Signature` (base64), `X-Webhook-Timestamp`; signed
     string `"{timestamp}.{rawBody}"`; **RSA-PSS-SHA256, salt 32**, with VerifyBlind's public key from
     `GET https://api.verifyblind.com/api/public/webhook-signing-key` → JSON `{ public_key }` (PEM). It is
     asymmetric — **not** an HMAC with your API key. Reject timestamps older/newer than ±5 minutes.
  2. A body with `"status": "cancelled"` carries a `reason`; store it and stop.
  3. Decrypt `encrypted_response`: `enc_key` → RSA-OAEP-SHA256 with your private key → the bytes are a
     **base64 string**; base64-decode again to get the AES key. `blob` = IV(12) + ciphertext + tag(16),
     AES-256-GCM.
  4. The plaintext is `{ payload, signature }`; verify it with the enclave key exactly as in pattern A step 3,
     and check the validations against the ones you stored with the nonce (pattern A step 5).
  5. Store the result by nonce; `GET /api/status/{nonce}` returns `pending` / `completed` / `cancelled`.

## 6. Mobile apps

Follow the SDK README for setup and the demo apps for a working screen:

- **Android:** from Maven Central — `implementation("com.verifyblind:verifyblind-android:1.0.1")`
  (minSdk 24, Java 17); `VerifyBlindAndroidSDK(VerifyBlindConfig(partnerBackendUrl,
  generateEndpoint, verifyblindAppLinkBase = "https://app.verifyblind.com/request"))`,
  `startAuthentication(context, validations, returnUrl?)`, then poll `checkVerificationResult(nonce)`
  every few seconds (`null` = still waiting; throws `USER_CANCELLED` with `cancelReason`). Send
  `result["token"]` to your server's verify endpoint (pattern A step 4).
- **iOS:** Swift Package `https://github.com/VerifyBlind/verifyblind-sdk-ios.git`; `VerifyBlindSDK(config:)`,
  `startAuthentication(validations:returnUrl:)`, poll `checkVerificationResult(nonce:)` (from **2.3.1**);
  send `data["token"]` to your server's verify endpoint.
- **Flutter:** `flutter pub add verifyblind_flutter`; `VerifyBlind(VerifyBlindConfig(partnerBackendUrl: ...,
  generateEndpoint: ...))`, `startAuthentication(validations:, returnUrl:)`, poll
  `checkVerificationResult(nonce)` on resume; send `result['token']` to your server. Android/iOS setup
  (return scheme, Flutter deep-link routing off): the package README.
- **React Native:** `npm install @verifyblind/react-native` (not Expo Go); `createVerifyBlind({ partnerBackendUrl })`,
  `startAuthentication({ validations, returnUrl })`, poll `checkVerificationResult(nonce)` when the app is
  active; send `result.token` to your server. Setup: the package README.
- **Return to the app:** register the scheme in the partner portal **Settings → app return scheme**
  (VerifyBlind only opens return URLs whose scheme matches) and declare it in `AndroidManifest.xml` /
  `Info.plist`. Return URL form: `myapp://callback?nonce=...&status=success|cancelled`.
- The generate proxy is the same one the web uses (it stores the nonce with the asked validations); the mobile
  SDK sends `custom_data` as `additional_data`. One verify endpoint can serve both web and app.

## 7. Store the codes and enforce the rule

- Add nullable columns to the user (or a verifications table): `vb_user_id`, `vb_nsbd_id`, `vb_doc_id`,
  `vb_verified_at`, and `vb_age_ok` if age was requested. Index `vb_user_id`.
- On a verified result: look up `vb_user_id` among other accounts / your block list and apply the rule the
  developer chose in step 2.4. Do not use `nsbd_id` alone for hard decisions (it can collide for people with
  the same name and birth date).
- Codes are **pseudonymous personal data**, not anonymous: protect them like other user data and keep them
  only as long as needed.
- **Badge (if wanted):** ready-made SVGs for light backgrounds —
  `https://verifyblind.com/badges/verified-with-verifyblind-tr.svg` (with text, Turkish),
  `https://verifyblind.com/badges/verified-with-verifyblind-en.svg` (with text, English),
  `https://verifyblind.com/badges/verified-with-verifyblind-icon-tr.svg` (icon only).
  Show it only for users whose verification your server confirmed; give it an accessible label
  ("Verified with VerifyBlind" / "VerifyBlind ile doğrulandı").
- **User-facing explanation:** next to the verify button, link "How does it work?" to
  `https://verifyblind.com/tr/how-it-works` (or `/en/how-it-works`) — it tells users what the site sees and
  does not see.

## 8. Revocation and legal

- If the user withdraws consent or asks for erasure in the VerifyBlind app, VerifyBlind POSTs a JSON body
  to the **revoke URL** set in partner portal **Settings**, signed exactly like the callback. The body
  always contains `nonce`; the other fields vary by event (e.g. `event_type`: `CONSENT_WITHDRAWN` or
  `DATA_ERASURE`), so key only on `nonce`. Implement the receiver (reference: `example-web-dotnet` →
  `POST /api/revoke`): verify the signature, delete or mark the data tied to that nonce, return 200.
  The server libraries verify it for you: Node `verifier.verifyRevoke({ rawBody, signature, timestamp })`,
  PHP `$vb->verifyWebhook($rawBody, $signatureHeader, $timestampHeader)`, .NET
  `verifier.VerifyRevokeAsync(Request.Body, signatureHeader, timestampHeader)` — pass the raw body, not parsed JSON.
- Tell the developer: their privacy notice should say that identity verification is done through
  VerifyBlind (https://verifyblind.com/en/dpa). The project never processes biometric data itself.

## 9. Errors and limits

- Generate: 400 bad field · 401 bad API key · 402 free quota and balance exhausted · 403 partner e-mail not
  verified · 409 nonce already used · 429 rate limit (respect `Retry-After`) ·
  503 enclave unreachable. Errors look like `{ "error": "...", "code": "..." }`; branch on status/code.
- Cancellation reasons: `user_cancelled`, `no_card_registered`, `user_declined`, `fingerprint_failed`,
  `session_expired` — show a reason-specific message; treat unknown values as `user_cancelled`.
- Nonce lives 15 minutes; stop polling on the client after that.
- Full tables: https://verifyblind.com/en/developers#errors → Error codes.

## 10. Test and hand over

1. Run the project and complete one verification end to end with a real chipped Turkish ID card and an
   NFC phone (VerifyBlind app: Google Play `com.verifyblind.mobile`, App Store "VerifyBlind").
   Live reference to compare against: https://test.verifyblind.com
2. A demo card (no real ID) is available only for partners marked as test partners. If the developer wants
   it, they write to support@verifyblind.com. Demo results carry `validations.is_test: true` and codes
   prefixed `TEST_` — make sure production logic ignores or separates them.
3. Check: API key only in server env · generate proxy sets `validations` from server config, is
   protected on the partner's own site, and returns the upstream status · server verifies the signature, consumes the nonce and
   checks the result against the stored validations · repeated `user_id` handled as agreed · revoke
   receiver works · nothing secret committed.
4. Give the developer a short summary: files changed, env vars to set (`VERIFYBLIND_API_KEY`, plus
   `CALLBACK_PRIVATE_KEY` / `CALLBACK_URL` for pattern B), portal settings to fill (domain, revoke URL,
   app return scheme), and what is left for them.

Pricing: 2,000 verifications per month free, then 20 US cents per verification, no minimum.
Questions: support@verifyblind.com
