Unstable API: endpoints and payloads are still changing. Expect breaking changes without notice, and plan to update your integration.

API reference

The endpoints fall into two groups.

The four under /relying-party/v1/session are called by your server, with your client credentials. They are the whole API if you host the waiting page yourself.

The three under /login-ui/v1/session are called by the ComplyOnce-hosted login page, and only if you choose to use it. They carry no client credential — the page proves which login it is driving with a cookie — and you never call them.

Authentication

Every /relying-party/v1/session endpoint requires HTTP Basic authentication with your client_id and client_secret, so no request body carries a client_id. A 401 is not wrapped in the error envelope described next, and invalid_client is not a reachable response. Sessions belong to the client that created them. See Authentication for the details.

The /login-ui/v1/session endpoints authenticate differently: the browser spends a one-time handoff token, minted for your server at session creation, for a HttpOnly cookie that authorises polling and completion of that one login. No client credential is involved, and no browser can reach the endpoints above.

Response conventions

Successful responses are plain JSON objects with camelCase fields. Errors are wrapped in an envelope:

{
  "errorCode": "invalid_request",
  "defaultTranslation": "redirect_uri is not registered",
  "resultMessageLangCode": "en",
  "payload": null
}

errorCode is the field to branch on. defaultTranslation is a developer-facing description, not user-facing copy.

Request bodies use snake_case for OAuth wire parameters (client_id, code_verifier) while responses use camelCase (sessionId, subjectId). That is inconsistent but deliberate: the requests follow OAuth naming and the responses follow the backend's own convention.

Bodies are JSON, including for the token endpoint — not the form encoding RFC 6749 specifies.

POST /relying-party/v1/session

Creates a login session. Called by your server, as step 1 of the flow.

Request:

Field Type Required Constraints
redirect_uri string yes Non-blank, max 2000, must exact-match a registered URI
state string no Max 500. Optional to the backend, but omitting it means completion returns a redirect with no state to check
signature_type enum yes AUTHENTICATION or SIGNING
code_challenge string yes 43–128 characters from A–Z a–z 0–9 - . _ ~
code_challenge_method string yes S256 only
properties string no base64url-encoded JSON requesting identity attributes/predicates. See Identity properties
hosted_page boolean no Defaults to false. Set it to use the ComplyOnce-hosted login page; the response then carries a handoffToken

Response 200:

{
  "sessionId": "3f2a7c18-9b4e-4d6a-8f11-2c5e7a90b3d4",
  "expiresAt": 1753876543
}

expiresAt is epoch seconds. Note this response is not wrapped in the error envelope described above.

With hosted_page: true the response gains one field:

{
  "sessionId": "3f2a7c18-9b4e-4d6a-8f11-2c5e7a90b3d4",
  "expiresAt": 1753876543,
  "handoffToken": "MNc4L4nFPND5C57Gx-D7oXd9dGyCXLGxNVa1CYpTh6A"
}

handoffToken is single-use, valid for 60 seconds, and is what you put in the URL when you redirect the browser to the hosted page. It is a credential for that one login: keep it out of logs, and do not ask for it if you are not going to use it — without hosted_page, no token exists and no browser can claim the session.

Status errorCode Cause
400 invalid_request redirect_uri not registered, failed field validation, a malformed request body, or a malformed/unsound properties request
400 invalid_scope properties asks for an attribute or predicate not in the client's allow-list. See Identity properties
501 not_implemented signature_type=SIGNING. See Signing flow

These all fail before a session exists, so there is nothing to show the user yet — treat them as configuration errors on your side rather than failed logins.

GET /relying-party/v1/session/{sessionId}/status

Polls session state. Called by your server while your waiting page is open.

Response 200, one of:

{ "status": "PENDING", "expiresAt": 1753876543 }
{ "status": "SCANNED", "pairingCode": "4071", "expiresAt": 1753876543 }
{ "status": "CONFIRMED" }
{ "status": "COMPLETED" }
{ "status": "EXPIRED" }
Status Meaning
PENDING Created, waiting for a scan
SCANNED A device scanned it; pairingCode is now revealed for the user to compare
CONFIRMED The user confirmed with their PIN; an authorization code has been minted
COMPLETED The browser has already been sent back to your redirect_uri
EXPIRED Passed its expiry before confirmation

An unknown sessionId — or one belonging to another client_id — returns 404 with errorCode: session_not_found.

Null fields are omitted, so pairingCode and expiresAt are absent unless the status above shows them.

POST /relying-party/v1/session/{sessionId}/completion

Exchanges a confirmed session for the redirect back to you. Called by your server once it sees CONFIRMED.

Response 200:

{ "redirect": "https://portal.example.com/auth/callback?code=Yk9c%E2%80%A6&state=xr7Kp2mQ9vB4nL1s" }

The browser is then sent to that URL. state is whatever you supplied, unchanged.

Status errorCode Cause
404 session_not_found Unknown sessionId, or it belongs to another client_id
409 session_already_completed Completion is single-use; the redirect was already handed out

The 409 exists so that a duplicated tab or a re-opened login page cannot mint a second redirect for the same login.

POST /relying-party/v1/session/token

Exchanges the authorization code for the authenticated identity. This is the call you make, from your server, never from a browser.

Request:

Field Type Required Constraints
code string yes Non-blank, max 255. The code from the completion redirect
code_verifier string yes 43–128 characters from A–Z a–z 0–9 - . _ ~. The verifier whose hash you sent as code_challenge

Only those two fields. Your identity comes from the Basic credential.

Response 200:

{ "subjectId": "8f14e45f-ceea-4a1b-9f2c-3d5b7a081c62" }

subjectId is the whole response for a plain login. It identifies the user to your client only: stable across that user's logins to you, unrelated to the id another relying party sees for the same person, and different between environments — see What subjectId is, and is not. If the login carried a properties request, attributes and predicates accompany it:

{
  "subjectId": "8f14e45f-ceea-4a1b-9f2c-3d5b7a081c62",
  "attributes": { "name": "Demo User", "country": "EE" },
  "predicates": [ { "type": "age_at_least", "years": 18, "result": true } ]
}

Both fields are absent when no properties were requested. A requested attribute with no value is omitted from attributes; each predicate echoes its parameters next to a fail-closed boolean result. See Identity properties.

Status errorCode Cause
400 invalid_grant Code unknown, expired, already consumed, or belongs to another client; or SHA256(code_verifier) does not match the stored challenge
400 invalid_request Field validation failed — defaultTranslation names the field

invalid_grant deliberately does not distinguish between these causes, so diagnose by elimination: check the code's age first, then whether something retried the exchange, then your PKCE hashing.

The code is consumed on success. A second exchange of the same code returns 400 invalid_grant.

The hosted login page endpoints

These three are called by the ComplyOnce-hosted login page, not by you. They are documented so you can reason about the flow your users go through; there is nothing to implement against them.

Every call requires the header X-ComplyOnce-Login: 1. It is not a CORS-simple header, so any cross-origin call is preflighted and only the configured login origin survives. A request without it never reaches the handler.

POST /login-ui/v1/session/claim

Spends the handoff token and issues the login cookie.

{ "handoffToken": "MNc4L4nFPND5C57Gx-D7oXd9dGyCXLGxNVa1CYpTh6A" }

Response 200, plus Set-Cookie: colo_login=…; HttpOnly; Secure; SameSite=Lax; Path=/login-ui/v1:

{
  "sessionId": "3f2a7c18-9b4e-4d6a-8f11-2c5e7a90b3d4",
  "expiresAt": 1753876543,
  "relyingPartyName": "Acme"
}

relyingPartyName is your registered name, so the page can tell the user which site they are logging in to.

Status errorCode Cause
400 invalid_handoff Token unknown, expired, already claimed, or its session is no longer pending

The single error code is deliberate: distinguishing the causes would tell a guesser which of their guesses was closest.

GET /login-ui/v1/session/status?sessionId={sessionId}

Same response as the relying-party status endpoint. The cookie identifies the login; sessionId only selects which one the page believes it is showing.

POST /login-ui/v1/session/completion?sessionId={sessionId}

Same { "redirect": … } response as the relying-party completion endpoint, and equally single-use. Clears the cookie on success.

Both of the above share two failures:

Status errorCode Cause
401 no_login_cookie No cookie, or it no longer matches a live login
409 session_superseded The cookie belongs to a different login — a second login started in the same browser

A browser holds one login at a time, so session_superseded is how the older tab finds out it has been abandoned rather than silently following a login the user did not start there.

Timing defaults

Setting Default
Session validity 120 seconds
Authorization code validity 60 seconds
Handoff token validity 60 seconds

All three are backend configuration, so verify them for the environment you integrate against. The poll interval is yours to choose; two seconds is a sensible starting point against a 120-second session.

QR payload

Your waiting page renders this, and the ComplyOnce app consumes it:

complyonce://app/scan?sessionId=<uuid>&returnUrl=<url-encoded return address>

sessionId is the one you got from session creation. returnUrl is where the app should send the user back to on the same device — your own waiting page, able to pick the session back up. Both values are percent-encoded inside the URI.