Quick start
First API call
Three requests from your server: authenticate, create a session, read it back.
This page takes you from nothing to a session that you created and read back with your own code. It is the server-side path. The first verification page covers the other path, where a user opens the widget. The widget creates its own session from the scenario identifier and the encrypted client key, so the session that you create on this page is a different one, and the two paths do not complete each other. The quick start is the overview of both.
| Order | Request | What you learn |
|---|---|---|
| First | Create a token in the dashboard | How the platform knows your server. |
| Second | POST /kyc/session/create | What a session is and which steps it expects. |
| Third | POST /kyc/session/status | How to read a session, and which field to trust. |
Before you start
- Access. Company verification comes before live access. See the first step of the quick start.
- A scenario. Scenarios are built in the dashboard editor, not through the API. Note the scenario identifier. See KYC and AML in the dashboard.
- The API address. It is provided with your access. The samples below show it as
<API_BASE_URL>. - A place for secrets. Keep the token in an environment variable or a secret store on your server.
All calls are POST under your API address.
Step 1: Get an access token
For calls from your server, use a permanent API token.
- In the dashboard open Access, then API keys, and click Add new access token.
- Set the validity. Never expires is on by default. For a token that expires on a given day, clear the checkbox and set the date.
- Click Create token, and copy the Token ID. That value is the token.
Send the token in the JSON body of every request, as token. For a request with files, send it as a form field. Never put it in browser code, a mobile app or a repository. See authentication and access tokens in the dashboard.
A token from code
You can also get one from code. A dashboard user logs in and receives a user token, and a user token can create a permanent token.
curl -X POST "<API_BASE_URL>/user/login" \
-H "Content-Type: application/json" \
-d '{
"email": "<your dashboard email>",
"password": "<your password>",
"valid_days": 1
}'
The answer carries status of ok and the token. A user token goes in the token header of other calls, not in the body:
curl -X POST "<API_BASE_URL>/token/create" \
-H "Content-Type: application/json" \
-H "token: <user token>" \
-d '{
"expires": "2027-01-01T00:00:00.000Z"
}'
The new token is in token.id and works at once. Leave expires out for a token that does not expire. One trap on the login call: a refusal, such as a wrong password, answers HTTP 403 with a body that still says status of ok. On that call the HTTP code is the only signal. See user authorization and create a permanent API token.
Step 2: Create a session
curl -X POST "<API_BASE_URL>/kyc/session/create" \
-H "Content-Type: application/json" \
-d '{
"token": "<your permanent API token>",
"schemaId": "<your scenario id>"
}'
A successful call answers HTTP 200 with the session:
{
"sessionId": "00000000-0000-0000-0000-000000000000",
"status": "idle",
"schemaId": "<your scenario id>",
"secondsToLive": 3600,
"createdAt": "2026-01-01T10:00:00.000Z",
"errors": [],
"results": [
{ "type": "document", "status": "idle" },
{ "type": "selfie", "status": "idle" },
{ "type": "liveness", "status": "idle" },
{ "type": "faces", "status": "idle" }
]
}
Read four things in it.
| Field | What it tells you |
|---|---|
sessionId | The identifier. Every other KYC operation takes it. Store it with your user or order. |
status | The session status. idle means the session is ready. It is not the success flag of the call. |
secondsToLive | The seconds left before the session expires. At zero an idle or processing session is reported as expired. |
results | One entry per step of the scenario, each idle at first. The faces entry is the face matching of the session. It comes last, and the session does not pass without it. |
These are the fields this permanent-token example needs: the scenario identifier and your token. The OpenAPI description also defines a captcha field, which is required for demo tokens only, so a permanent token does not send it. The create call does not take a client key. Your own clientKey belongs to the widget route, where you encrypt it on your server and hand it to the page. Do not put personal data in it. See working with clientKey and first verification.
Step 3: Read the status
curl -X POST "<API_BASE_URL>/kyc/session/status" \
-H "Content-Type: application/json" \
-d '{
"token": "<your permanent API token>",
"sessionId": "00000000-0000-0000-0000-000000000000"
}'
You can send your own clientKey instead of sessionId, which fits sessions that the widget created. When both are sent, sessionId identifies the session. The answer is the same session object, now with the current state.
Right after the create call the status is still idle, because nothing has run. To see this session change, send the images from your backend with recognize documents and selfies. In async mode the answer is processing with an empty results, and you poll the status call.
The widget does not continue this session. The KYC SDK button on the logs tab of the dashboard, and a user who finishes the widget on your page, both start the selected scenario and create a session of their own. To see that route, follow first verification.
The values a session and its steps can hold are idle, processing, success, failed, exception, expired and suspicious. See session statuses. Branch on status, never on the text of a message.
A minimal Node.js version
const base = process.env.API_BASE_URL;
const token = process.env.API_TOKEN; // on the server only
async function call(path, body) {
const reply = await fetch(`${base}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, ...body }),
});
const text = await reply.text();
let data = null;
try { data = JSON.parse(text); } catch { /* a malformed request answers with an HTML page */ }
return { http: reply.status, data };
}
// Returns a description of the failure, or null when the answer can be read as a session.
function problem({ http, data }) {
if (http >= 400) return `HTTP ${http}`; // 400, 401, 403 (no status field), 409, 500
if (!data) return "the body is not JSON";
if (data.status === "error") return `error: ${JSON.stringify(data.details ?? null)}`; // HTTP 200 with an error inside
return null;
}
function asSession(reply) {
const why = problem(reply);
if (why) throw new Error(why); // a 409 is worth repeating later; the rest need a fix
const s = reply.data;
if (typeof s.sessionId !== "string" || !Array.isArray(s.results)) throw new Error("not a session object");
return s;
}
const session = asSession(await call("/kyc/session/create", { schemaId: process.env.SCENARIO_ID }));
const state = asSession(await call("/kyc/session/status", { sessionId: session.sessionId }));
console.log(state.status, state.results.map((r) => `${r.type}:${r.status}`));
The helper checks the HTTP code first, because the refusals that come before the operation have no status field, then the body, then the shape of the session object. results can be empty while a session is still being processed, which the array check allows.
Reading the errors
Failures arrive in more than one shape, and the HTTP code is not always the signal.
| What you see | What it means |
|---|---|
HTTP 200 and status of error | A failure normally arrives this way. Branch on the status field of the body, not on the HTTP code. |
HTTP 400 | The body failed validation. The answer has status of error and a details list with one entry per offending field: path, expected and actual. |
HTTP 401 | The token is missing, expired or unknown. A token rejected in the header is typed text/html, one rejected in the body is typed application/json. Parse the body as JSON in both cases. |
HTTP 401 with IP_MISMATCH | A token bound to one address was used from another. There is no status field. |
HTTP 403 with Access Denied | The request came from an address outside your IP allowlist. There is no status field. An empty allowlist accepts any address. |
HTTP 409 | Another request to the same session was still being processed. Nothing of yours was applied. Repeat it once the other request has been answered. |
HTTP 500 | An unexpected failure with the fixed text internal error. |
See response format and errors.
If a call fails
| Symptom | What to check |
|---|---|
401 on the first call | The token is the Token ID from the dashboard, sent as token in the body. |
| A session cannot be created | The scenario identifier is the one from the editor, and the scenario exists. |
403 with Access Denied | The IP allowlist in the dashboard security settings. |
| The body is rejected before any handler runs | The body is valid JSON and the content type is application/json. |
Next
- First verification: a user finishes the widget and your server receives the result.
- The six operations of the lifecycle.
- REST API reference.
Questions, answered plainly
Why does my new session read idle when I ask for its status?
Idle means that the session is created and ready. Nothing has been uploaded to it yet. The status changes when your backend sends the images to this session. A user who finishes the widget starts a session of their own.
My call returned a success code, but nothing happened. What do I check?
Read the status field of the body. A failure normally arrives with a successful HTTP code and a body whose status is error. See response format and errors.
Where does the permanent token go, in the body or in the header?
In the JSON body, as token. A user token goes in the token header. A permanent token sent in the header is refused.
Ready for the user's side?
The first verification takes a user through the widget, the webhook and the result.
Open first verification