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.