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.