# Cognau documentation > Anti-deepfake liveness verification. Confirms a live human is present using > randomized challenge-response, with an optional document check that reads no > identity fields. Not facial recognition against a database. > > Summary: https://cognau.com/llms.txt > Generated from https://cognau.com/docs ======================================================================== Source: https://cognau.com/docs/quickstart ======================================================================== # Quickstart Cognau answers one question: **is a live human present right now?** A short camera check runs a few randomized challenges and returns a pass or fail. Nothing in it reads identity fields. No name, no date of birth, no address, and no lookup against any database. A session can optionally add an identity document check, but that check does not parse the document either: see [what the document check does and does not do](/docs/sessions#the-optional-document-check). The fastest path needs no integration at all. ## 1. Create a verification link From the dashboard, open **Links** and create one. Or from your terminal: ```bash curl -X POST https://machine.cognau.com/api/v1/cockpit/verification-links \ -H "Authorization: Bearer $COGNAU_TEST_KEY" \ -H "Content-Type: application/json" \ -d '{"label": "first test", "maxUses": 1}' ``` ```json { "success": true, "data": { "url": "https://verify.cognau.com/?link=cgn_vl_…", "prefix": "cgn_vl_ab12cd34", "maxUses": 1, "expiresAt": 1760000000000 } } ``` The URL is returned **once**. Only its hash is stored, so it cannot be shown again. Copy it now. ## 2. Open it Open the URL in a browser and complete the check. With a **test** key nothing real happens: no detection runs, no video frame is stored, and the verdict is simulated. It costs nothing. ## 3. Read the result Poll from your backend, or receive a webhook: ```bash curl https://machine.cognau.com/api/v1/cockpit/sessions \ -H "Authorization: Bearer $COGNAU_TEST_KEY" ``` ### Try it right here Paste a **test** key and send the request. It runs against the real API from your browser. ```tryit { "method": "POST", "path": "/cockpit/verification-links", "title": "creates a real link on your test account", "body": "{\n \"label\": \"from the docs\",\n \"maxUses\": 1,\n \"expiresInDays\": 7\n}" } ``` That is the whole loop. When you are ready to put this inside your own product, there are two routes: - **[Embed the widget](/docs/embedding)** in an iframe on your page. - **[Create sessions from your backend](/docs/sessions)** with your secret key, which is the production shape. ## Test and live Every key carries its environment in the string: | Prefix | Behaviour | |---|---| | `cgn_sk_test_…` | Sandbox. No detection, no stored video, simulated verdict, free. | | `cgn_sk_live_…` | Real verification. Billed per check. | Build against `test` until your integration is done. The wire format is identical, so nothing changes when you switch except the key. ======================================================================== Source: https://cognau.com/docs/authentication ======================================================================== # Authentication Cognau uses four credentials, and exactly one of them belongs on your server. Confusing them is the most common integration mistake, so it is worth two minutes. | Credential | Looks like | Lives | Grants | |---|---|---|---| | **API key** | `cgn_sk_live_…` / `cgn_sk_test_…` | your server, only | full account access | | **Session token** | `cgn_st_…` | the browser | one WebSocket, once | | **Document token** | returned beside it | the browser | upload to one session | | **Link token** | `cgn_vl_…` | an email or URL | one session, then spent | ## API keys Send as a bearer token: ```bash curl https://machine.cognau.com/api/v1/cockpit/sessions \ -H "Authorization: Bearer cgn_sk_live_…" ``` The key decides both **who** you are and **which environment** you are in. A test key cannot create live sessions and a live key cannot create sandbox ones, so a misconfigured deployment fails loudly instead of quietly billing you. **Never put an API key in a browser.** It grants read access to every session on your account. Only the hash is stored on our side, so a key is shown in full exactly once, at creation; if you lose it, issue another and revoke the old one. Multiple active keys per environment are allowed on purpose, so rotation is create → deploy → revoke, with no window where your integration is down. ## There is no publishable key If you have integrated Stripe or a similar SDK you will go looking for a `cgn_pk_…` to put in your frontend. There isn't one, and you don't need one. A publishable key exists so a browser can *start* something before any server object exists. Here the session already exists before the browser is involved, so the session token covers it — and it is strictly narrower than a publishable key: | | a publishable key | our session token | |---|---|---| | Scope | the whole account | one session | | Lifetime | until you rotate it | expires with the session | | If leaked | works until you notice | already spent | So **no account-level credential of any kind reaches the browser**. The only thing your page ever holds is a token for the single verification it is running. Controlling *where* the widget may be embedded is a separate, server-side setting: see [restricting who can embed you](/docs/embedding#restricting-who-can-embed-you). ## Session tokens Returned by `POST /session/create` and handed to the browser. A session token authorizes exactly one WebSocket connection and nothing else. It is single-use: once a socket opens, the token is spent, which is why a dropped connection ends the session rather than resuming it. A session that also collects a document returns a second token, `documentToken`. The session token is spent the moment the liveness socket opens, so it is gone before the document screens are reached, and a capture is inherently several calls: front, back, a retake. The document token can do exactly one thing, submit a document side to that one session, and there is no read path that accepts it. Pass the whole session object to the widget and it uses both. ## Link tokens The credential inside a verification URL. Weaker than an API key by construction: it can create the one session it is scoped to, and cannot read verdicts, list sessions, or see anything about your account. Treat the URL itself as the secret. Anyone holding it can run one verification that you are billed for, which is why links default to a single use, always expire, and can be revoked instantly. ======================================================================== Source: https://cognau.com/docs/sessions ======================================================================== # Sessions The production shape. Your backend creates a session with your secret key; the browser receives only a single-use session token. ## 1. Server: create the session ```bash curl -X POST https://machine.cognau.com/api/v1/session/create \ -H "Authorization: Bearer $COGNAU_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "web", "clientReference": "user_4821" }' ``` ```json { "success": true, "data": { "sessionId": "6a7de599a5244ea6a3e65109", "sessionToken": "cgn_st_…", "environment": "live", "livemode": true, "challengeSequence": [{ "type": "headTurn", "index": 0, "params": {} }], "wsEndpoint": "/api/v1/ws/session/6a7de599a5244ea6a3e65109", "expiresAt": 1760000000000 } } ``` ### `clientReference` Your own identifier for whoever is being verified: a user id, an application id, an order number. It is opaque to us, never parsed and never matched across accounts, and it comes back on the webhook. Without it you must store our `sessionId` at create time to know whose result arrived later. ### Other options | Field | Effect | |---|---| | `riskHint` | `low` shortens the sequence to 3 challenges; anything else uses 4 | | `simulate` | sandbox only: `pass` or `fail`, chooses the fake verdict | | `enablePersonId` | opt-in re-recognition; requires you to have collected consent | | `deviceId` | your own device identifier, if you have one | | `trainingConsent` | the user agreed their video may be retained for training | ```tryit { "method": "POST", "path": "/session/create", "title": "creates a sandbox session with a test key", "body": "{\n \"platform\": \"web\",\n \"clientReference\": \"user_4821\",\n \"simulate\": \"pass\"\n}" } ``` ## 2. Browser: run the check Hand the session to the widget. Two shapes, both ending in the same place. **Let the widget fetch it.** Give it your own route and it POSTs there when it is ready, which keeps the session from being created until someone actually reaches the step: ```html ``` Your route just proxies `POST /session/create` with your secret key. Return our response verbatim if you like: the widget accepts the raw session or the `{ success, data }` envelope. **Or hand it over yourself,** if you already created the session: ```js const session = await fetch('/api/start-verification', { method: 'POST' }) .then(r => r.json()); const v = Cognau.open({ session, onVerdict: r => { v.close(); showPending(); } }); ``` Pass the whole session object, not just the token: a session that also collects a document carries a second credential the widget needs. Whichever you use, the token is delivered to the iframe over `postMessage` after it signals readiness, never as a query parameter. A session token in a URL would land in browser history, in the `Referer` of anything the page loads next, and in the access log of whatever served it. **There is no publishable key to add.** Nothing account-level goes to the browser; the session token is the only credential your page holds. See [Authentication](/docs/authentication#there-is-no-publishable-key). More on mounting, sizing and events: [Embedding](/docs/embedding). ## The optional document check A session can also collect an identity document. It is a **separate check**, switched on per account, and it is metered separately from liveness, so a session that runs both is billed for both. A composite session reaches the document step only if liveness passes. Be precise about what it does, because the name invites the wrong assumption. | It does | It does not | |---|---| | Store the submitted image, encrypted | Read anything printed on it | | Check the sides submitted match the declared type | Parse an MRZ or run OCR | | Judge blur, glare, resolution and framing | Check an expiry date | | Produce a recommendation for a reviewer | Compare the portrait against the liveness capture | | | Extract a name, date of birth or address | | | Decide the outcome on its own, unless the account opts in | **There is no text extraction anywhere in this stack.** If you need parsed identity fields, expiry checking, tampering analysis, or AML screening, this is not the tool for that part of the job, and it pairs with one rather than replacing it. The decision is made by a person reviewing the submission, and the automated signal is a recommendation shown to them. An account can opt in to having a sufficiently clear-cut recommendation applied automatically; that is off unless you ask for it. Accepted types are `passport`, `id_card`, `residence_permit` and `drivers_license`. A document image is kept on its own retention schedule, separate from liveness frames: see the [biometric data policy](/biometric-policy). ## 3. Server: trust the webhook The verdict the browser sees is convenient for UX, but it arrives over a channel the end user controls. **Make decisions from the webhook**, which is signed and server-to-server. See [Webhooks](/docs/webhooks). ## Sessions expire A session is short-lived by design, measured in minutes. Create one when the user is about to verify, not when the page is built. If you need something that survives an email, use a [verification link](/docs/verification-links) instead. ## Reading results ```bash curl "https://machine.cognau.com/api/v1/cockpit/sessions?clientReference=user_4821" \ -H "Authorization: Bearer $COGNAU_SECRET_KEY" ``` Filtering by your own reference is the point of setting it: you can answer "did user 4821 ever pass?" without having kept our identifiers. ======================================================================== Source: https://cognau.com/docs/verification-links ======================================================================== # Verification links A link is a hosted verification: send someone a URL, they complete the check, you get the result. No backend, no frontend work. Use it to trial the product, to verify one person over email, or to let a support agent re-verify someone. For production traffic inside your own product, create [sessions](/docs/sessions) from your backend. ## Create ```bash curl -X POST https://machine.cognau.com/api/v1/cockpit/verification-links \ -H "Authorization: Bearer $COGNAU_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "label": "Acme onboarding, J. Doe", "clientReference": "user_4821", "expiresInDays": 7, "maxUses": 1, "redirectUrl": "https://acme.com/verified" }' ``` | Field | Default | Notes | |---|---|---| | `label` | empty | For your eyes only; how you find the result later | | `clientReference` | none | Your id for this person; lands on the webhook | | `expiresInDays` | 7 | Capped at 30 | | `maxUses` | 1 | Single use is the safe default | | `redirectUrl` | none | We append `session_id` and `result` | The response contains the full URL **once**. Only its hash is stored, so it can never be shown again. Copy it into your email at that moment or issue another. ## Why a link and not a session Sessions expire in minutes. A session baked into an email would be dead before the recipient opened it. A link is the durable object, valid for days, and it mints a fresh short-lived session at the moment somebody opens it. ## Treat the URL as a credential Anyone holding it can run one verification billed to you. The defaults exist for that reason: single use, always expiring, revocable instantly. ```bash curl -X DELETE https://machine.cognau.com/api/v1/cockpit/verification-links/$ID \ -H "Authorization: Bearer $COGNAU_SECRET_KEY" ``` A spent link returns `410` with a message you can show the recipient as-is ("This verification link has already been used"). Redemption is rate limited per IP. ## No retries If someone fails, the check is over. The widget offers no "try again", because every attempt is a billable verification and letting the end user restart at will is a way to spend your money and exhaust a multi-use link. Whether they get another attempt is your decision: issue another link. ======================================================================== Source: https://cognau.com/docs/embedding ======================================================================== # Embedding The widget opens as a modal over your page. Load the script and open it: ```html ``` There is no element to attach to and no container to add. The modal is `position: fixed`, so opening it does not reflow, move, or resize anything on your page, and closing it leaves the DOM exactly as it was. Nothing is written to your ``: no class, no inline style, no scroll lock. It draws no backdrop either. The frame is sized to the modal rather than the viewport, so the rest of your page stays visible, scrollable, and clickable while verification runs. If you want a dimmed background, render your own and put it behind the modal. Close it yourself with `v.close()`. It is the same as `v.destroy()`. ## Three ways to supply a session ```js // 1. A verification link. No backend needed. Cognau.open({ link: 'cgn_vl_…' }); // 2. A session your backend already created. Cognau.open({ session: sessionFromYourApi }); // 3. Your own endpoint, which we POST to when the widget is ready. Cognau.open({ sessionEndpoint: '/api/start-verification' }); ``` Options 2 and 3 are the production shape, and they are the same flow seen from two ends: 1. your backend calls `POST /session/create` with your secret key `cgn_sk_…` 2. it gets back a session containing a single-use `sessionToken` 3. your page passes that session object to `Cognau.open` 4. the widget asks for it over `postMessage` and runs **Nothing account-level goes to the browser, and there is no publishable key to add.** If you are looking for a `cgn_pk_…` to pair with the session, it does not exist: the session token is already the browser-side credential, scoped to one verification and spent the moment the socket opens. See [Authentication](/docs/authentication#there-is-no-publishable-key). Pass the **whole session object**, not just the token. A session that also collects a document carries a second credential, and the widget needs both. The token never travels in a URL. It is handed to the iframe over `postMessage` once the widget signals readiness, so the credential never lands in browser history, a `Referer` header, or a server log. ## Putting it in your layout instead Pass `inline: true` and a target, and the widget sits in your page's flow rather than over it. This is the one mode that takes up space, so it is opt-in: ```js Cognau.mount('#cognau', { link: 'cgn_vl_…', inline: true }); ``` An inline frame grows and shrinks to fit its content. A modal does not: its screens are sized against the viewport, and inside a frame the viewport IS the frame, so a frame that also followed its content would chase itself. Pass `height` to choose a different modal size (the default is 640, always capped to the viewport). `Cognau.mount(target, options)` without `inline` is the same as `Cognau.open`, and ignores the target. ## Writing the iframe yourself ```html ``` `frame=inline` tells the widget to size itself from its content, which is what you want for a frame sitting in your layout. Leave it off and the widget fills whatever box you give it, as it does inside the modal. Two things become your responsibility, and both fail silently if missed. **`allow="camera"` is mandatory.** Permissions Policy blocks camera access inside a cross-origin iframe unless the embedding page delegates it. Without the attribute the widget reaches the permission screen and stops, which looks like our bug rather than a missing attribute. Your page must also be HTTPS. **Validate `event.origin` on every message.** Any page can `postMessage` to your window, including a forged "passed". ```js window.addEventListener('message', e => { if (e.origin !== 'https://verify.cognau.com') return; // REQUIRED if (e.data?.source !== 'cognau') return; // … }); ``` `embed.js` handles both. That is the only reason it exists. ## Events | `type` | Payload | Meaning | |---|---|---| | `ready` | | mounted, waiting for the user | | `started` | | camera granted, session running | | `verdict` | `{ passed, sessionId }` | finished (**advisory**) | | `consent_declined` | `{ sessionId }` | declined at the consent screen | | `error` | `{ code }` | could not continue | | `resize` | `{ height }` | content height. Sizes an `inline` frame; ignored by a modal unless `autoResize: true` | **The verdict message is not authoritative.** It travels through the end user's browser, so a determined user can post a fake "passed" to your own page. Use it to drive UI; decide from the [webhook](/docs/webhooks). The payload deliberately carries no scores, risk factors, or person identifiers. ## Restricting who can embed you By default any site can iframe your widget. To limit it: ```bash curl -X PUT https://machine.cognau.com/api/v1/cockpit/embed-origins \ -H "Authorization: Bearer $COGNAU_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"origins": ["https://app.acme.com", "https://*.acme.com"]}' ``` Scheme and host only. `https://*.acme.com` matches any subdomain but **not** the bare apex, so add `https://acme.com` separately if you need it. An empty list means unrestricted, which is the default. This governs **embedding**, not visiting: a link opened directly from an email is not embedded in anything and keeps working whatever the list says. Be clear about its strength. The origin is reported by the visitor's browser, so it stops a leaked link being farmed from an unrelated site and makes misconfiguration loud, but it is not proof against a scripted browser. For a browser-enforced guarantee, pair it with a CSP `frame-ancestors` header at your own edge. ======================================================================== Source: https://cognau.com/docs/webhooks ======================================================================== # Webhooks The browser sees a verdict, but it reaches you through the end user's device. Webhooks are signed and server-to-server, so this is where decisions belong. ## Register an endpoint ```bash curl -X POST https://machine.cognau.com/api/v1/cockpit/webhooks \ -H "Authorization: Bearer $COGNAU_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://acme.com/hooks/cognau", "enabledEvents": ["verification.completed"] }' ``` The signing secret is returned **once**. Endpoints are scoped to the environment of the key that created them, so test traffic never reaches your production receiver. ## `verification.completed` ```json { "sessionId": "6a7de599a5244ea6a3e65109", "clientReference": "user_4821", "result": "passed", "confidence": 0.91, "riskFactors": [], "personKey": null, "personSeenBefore": null, "completedAt": "2026-08-13T12:04:20.000Z" } ``` `clientReference` is whatever you set at session or link creation. It is the field that lets you join this event to your own record without having stored our `sessionId`. ## Verify the signature Every delivery carries `x-cognau-signature`. Compute HMAC-SHA256 over the raw body with your endpoint secret and compare in constant time. Reject anything that does not match, and do it **before** parsing the body. ## Delivery Deliveries are retried with backoff and claimed atomically, so exactly one of your instances handles each one even when several are running. Respond `2xx` quickly; do the work afterwards. A persistently failing endpoint is disabled and can be re-enabled from the dashboard, where you can also inspect the event log and redeliver. Handle duplicates. Retries mean the same event can arrive more than once, so key on `sessionId`. ======================================================================== Source: https://cognau.com/docs/errors ======================================================================== # Errors Every error response has the same shape: ```json { "success": false, "error": "human readable message" } ``` | Status | Meaning | Retry? | |---|---|---| | `400` | Malformed request | No, fix the request | | `401` | Missing or invalid API key | No | | `402` | Monthly quota exceeded | Not until the period resets | | `403` | Admin disabled, or embed origin not allowed | No | | `404` | Not found, or a link that was never issued | No | | `410` | Link expired or already used | No, issue a new link | | `429` | Rate limited | Yes, after `Retry-After` | | `500` | Our fault | Yes, with backoff | ## 402 is not 429 A quota is a commercial ceiling, not a speed limit. It clears when the billing period rolls or when the plan changes, so retrying sooner will not help. The body tells you where you stand: ```json { "success": false, "error": "Monthly verification quota exceeded", "details": { "used": 100, "quota": 100, "periodResetsAt": "2026-09-01T00:00:00.000Z" } } ``` Sandbox traffic is never blocked by a quota, so your developers keep working while live traffic is capped. ## Link errors `410` distinguishes "already used" from "expired", and both messages are written to be shown to the recipient as-is. Every other rejection returns the same generic `404`, deliberately: distinguishing "no such link" from "revoked link" would turn the endpoint into an oracle for probing token validity.