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 authentication only Max 2000, must exact-match a registered URI. Required for AUTHENTICATION, and for SIGNING only with hosted_page: true
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 authentication only 43–128 characters from A–Z a–z 0–9 - . _ ~. Signing has no authorization code, so PKCE does not apply
code_challenge_method string authentication only S256 only
signed_data_hash string signing only SHA-512 of the data, 128 hex characters
signed_data_name string no Max 255, no path separators. Names the data in the container manifest; defaults to detached-file
title string no Max 255. Free text shown to the signer, labelled in the app as coming from you
subject_id uuid no Restricts the session to one subjectId; anyone else scanning is refused and the session stays pending
push boolean no Defaults to false. Sends the session to that subject's devices so their app opens without a QR code. Requires subject_id
properties string no base64url-encoded JSON requesting identity attributes and named 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,
  "pushStatus": "NOT_REQUESTED"
}

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

pushStatus says what became of the push:

Value Meaning
NOT_REQUESTED You did not set push, so show a QR code
DISPATCHED Sent to every device that subject has. It says nothing about delivery — the send is asynchronous, and a phone may be off
NO_DEVICE Nothing was sent, because that subject has no device able to receive one. Fall back to a QR code

NO_DEVICE is also what you get for a subject_id we cannot resolve, so it is not a way to find out whether a given person has an account here.

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
400 invalid_request signed_data_hash missing on a signing session, or signing fields sent on an authentication session. 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. Your server can be notified of these changes instead — see Webhooks.

Response 200, one of:

{ "status": "PENDING", "expiresAt": 1753876543 }
{ "status": "SCANNED", "expiresAt": 1753876543 }
{ "status": "CONFIRMED" }
{ "status": "COMPLETED" }
{ "status": "CANCELLED" }
{ "status": "EXPIRED" }
Status Meaning
PENDING Created, waiting for a scan
SCANNED A device scanned it; the user is confirming on their phone
CONFIRMED Authentication only: the user confirmed with their PIN; an authorization code has been minted
COMPLETED Authentication: the browser has already been sent back to your redirect_uri. Signing: the signature exists and the result can be fetched
CANCELLED The signer declined in the app, left the flow, or locked their device with wrong PINs. Terminal — stop polling and start a new session
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 expiresAt is absent unless the status above shows it.

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

Returns the signature. Called by your server, once status is COMPLETED. Signing sessions only — see the signing flow.

Response 200:

{
  "container": "UEsDBBQACAgIAAAAIQ…",
  "signedDataHash": "7947b485a40b4c27…65489c96",
  "signedDataName": "contract.pdf",
  "signingTime": 1787056801,
  "name": "Mari Mets",
  "personalCode": "38001085718",
  "dateOfBirth": "1980-01-08",
  "country": "EE"
}

container is a base64 ASiC-E holding the signature and a manifest naming your data by hash — the data itself is never sent to us and never comes back. signingTime is epoch seconds and is the time covered by the signature.

The call is repeatable: fetch it as often as you need until the retention window closes. The identity fields are a convenience; the binding one is the signing certificate inside the container.

Status errorCode Cause
400 invalid_request The session is an authentication session
404 session_not_found Unknown session, another client's session, or one deleted after its retention window
409 not_signed The session has not reached COMPLETED

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": { "over18": true }
}

Both fields are absent when no properties were requested. A requested attribute with no value is omitted from attributes; predicates holds one fail-closed boolean per requested predicate, keyed by the name the request gave it. 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
Session validity with push 300 seconds
Authorization code validity 60 seconds
Handoff token validity 60 seconds
Signing result retention 1800 seconds (30 minutes) after signing

A pushed session gets longer because the signer has to notice a notification and pick the phone up, where a QR code is already in front of them.

All of these 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.