Getting started
This is the shortest path from nothing to a user logged in with ComplyOnce, and then to a document signed. It is a walkthrough rather than a reference: every step links to the page that documents it properly, and the detail lives there.
Part 1 uses the ComplyOnce-hosted login page, because it is the least you have to build. Your server still starts the login and still finishes it — only the page the user waits on while they scan and confirm is one ComplyOnce operates. If you would rather own that page as well, everything below stays true and two steps are added; see the two ways to integrate.
What you need first
- A server. Every ComplyOnce call carries your
client_secret, so there is no browser-only integration — see You need a server. Any language will do; the examples here are Node.js and Java. - The ComplyOnce app on a phone. You sign in to the relying-party portal with it, so whoever sets this up needs it installed before anything else — and you will need it again to test.
- A registered relying party. That is step 1.
Part 1: Log a user in
At a glance, and none of it involves your client_secret leaving your server:
- The user clicks Log in. Your server creates a session and asks for a handoff token.
- Your server redirects the browser to the hosted page, carrying that token.
- The hosted page shows the QR code and waits. The user scans it and confirms with their PIN.
- The hosted page sends the browser back to your
redirect_uriwithcodeandstate. - Your server checks
stateand exchangescodefor asubjectId. - Your server logs the user in.
Step 1: Get registered
There is no self-service sign-up. Ask ComplyOnce to create your relying party, and say what name you want shown — the user reads that name on their phone before approving, so it must be the name they know your service by.
You get back a client_id (a UUID) and access to the relying-party portal. In
the portal you then do two things yourself:
- Generate a
client_secret. It is shown once and stored only as a hash, so put it straight into your secret manager. - Add your
redirect_uri. It is compared as an exact string, so register every environment separately — production, staging, and each developer's machine. See the redirect URI is matched exactly.
localhost is not usable here: the user's phone is part of the flow, and a
browser sent to localhost lands on the wrong machine. For testing across
devices, register your machine's LAN address, such as
http://192.168.1.50:5175/auth/callback.
Full detail: Registration.
Step 2: Configure your server
Four values, plus the host of the hosted login page:
COMPLYONCE_CLIENT_ID=0f9c2b7e-6d41-4b0a-9c8e-5a2f1d3b7e40
COMPLYONCE_CLIENT_SECRET=… # from a secret store, never in git
COMPLYONCE_BACKEND_URL=https://… # every ComplyOnce API call goes here
COMPLYONCE_LOGIN_URL=https://… # the hosted login page, per environment
PUBLIC_BASE_URL=https://portal.example.com
PUBLIC_BASE_URL is the origin the visitor's browser reaches you on, because
your redirect_uri is derived from it. Deriving it from the incoming request
host instead makes the redirect URI vary, and a varying redirect URI cannot
exact-match a registered one.
Every call you are about to make authenticates with HTTP Basic, username
client_id, password client_secret:
Node.js
export function basicAuthorization() {
const credential = `${config.clientId}:${config.clientSecret}`
return `Basic ${Buffer.from(credential, 'utf8').toString('base64')}`
}
Java
String basicAuthorization() {
String credential = clientId + ":" + clientSecret;
return "Basic " + Base64.getEncoder().encodeToString(credential.getBytes(StandardCharsets.UTF_8));
}
That is standard base64 — not the base64url used for code_challenge. Full
detail: Authentication.
Step 3: Start the login
Per login attempt, generate a state (anti-forgery, comes back unchanged) and a
PKCE pair (code_verifier kept, code_challenge sent). Then create the session
with hosted_page: true, which makes the response carry a handoffToken.
Node.js
import { createHash, randomBytes } from 'node:crypto'
export async function startHostedLogin() {
const state = randomBytes(16).toString('base64url')
const codeVerifier = randomBytes(32).toString('base64url')
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url')
const response = await fetch(`${config.backendUrl}/relying-party/v1/session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: basicAuthorization(),
},
body: JSON.stringify({
redirect_uri: `${config.publicBaseUrl}/auth/callback`,
state,
signature_type: 'AUTHENTICATION',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
hosted_page: true,
}),
})
if (!response.ok) {
throw new Error(`Session creation failed (HTTP ${response.status})`)
}
const { handoffToken } = await response.json()
return { state, codeVerifier, handoffToken }
}
Java
private static final SecureRandom RANDOM = new SecureRandom();
private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
record HostedLogin(String state, String codeVerifier, String handoffToken) {}
HostedLogin startHostedLogin() throws Exception {
byte[] stateBytes = new byte[16];
RANDOM.nextBytes(stateBytes);
String state = ENCODER.encodeToString(stateBytes);
byte[] verifierBytes = new byte[32];
RANDOM.nextBytes(verifierBytes);
String codeVerifier = ENCODER.encodeToString(verifierBytes);
String codeChallenge = ENCODER.encodeToString(MessageDigest.getInstance("SHA-256")
.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)));
Map<String, Object> body = Map.of(
"redirect_uri", publicBaseUrl + "/auth/callback",
"state", state,
"signature_type", "AUTHENTICATION",
"code_challenge", codeChallenge,
"code_challenge_method", "S256",
"hosted_page", true);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(backendUrl + "/relying-party/v1/session"))
.header("Content-Type", "application/json")
.header("Authorization", basicAuthorization())
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IllegalStateException("Session creation failed: HTTP " + response.statusCode());
}
JsonNode json = objectMapper.readTree(response.body());
return new HostedLogin(state, codeVerifier, json.get("handoffToken").asText());
}
Hash the ASCII characters of the verifier string, not the random bytes it
was encoded from. Hashing the raw bytes still produces a valid-looking challenge
and fails much later, at the token exchange, with invalid_grant.
Step 4: Send the browser to the hosted page
Store state and code_verifier against this browser — a short-lived signed
httpOnly cookie, or your server-side session — and redirect:
https://<login-host>/?t=<handoffToken>
Node.js
authRoutes.get('/auth/start', async (request, response) => {
const { state, codeVerifier, handoffToken } = await startHostedLogin()
response.cookie(
LOGIN_COOKIE,
encodeSignedCookie({ state, codeVerifier }, config.sessionSecret, LOGIN_LIFETIME_SECONDS),
{ httpOnly: true, sameSite: 'lax', secure: config.isProduction, path: '/' },
)
response.redirect(`${config.loginUrl}/?t=${encodeURIComponent(handoffToken)}`)
})
Java
String hostedPageUrl(String handoffToken) {
return loginUrl + "/?t=" + URLEncoder.encode(handoffToken, StandardCharsets.UTF_8);
}
Send a 302 to that URL, and keep state and codeVerifier in your HTTP
session or in a signed cookie — anywhere the browser cannot edit them.
The handoff token is single-use and lives 60 seconds. Redirect with it immediately; do not store it and do not log it. It is a credential for one login.
From here the hosted page renders the QR code, polls, and completes the session for you. The user scans, sees your registered name, and confirms with their PIN.
Step 5: Check state, then redeem the code
The browser comes back to your redirect_uri:
https://portal.example.com/auth/callback?code=Yk9c…&state=xr7Kp2mQ9vB4nL1s
Compare state against what you stored before doing anything else, then
exchange the code. Redeeming is the first thing to do after that check — the
code expires 60 seconds after confirmation and is single-use.
Node.js
authRoutes.get('/auth/callback', async (request, response) => {
const login = decodeSignedCookie(request.cookies[LOGIN_COOKIE], config.sessionSecret)
response.clearCookie(LOGIN_COOKIE, cookieOptions())
if (!login || typeof request.query.code !== 'string' || request.query.state !== login.state) {
redirectHome(response, 'login_failed')
return
}
const subjectId = await redeemAuthorizationCode({
code: request.query.code,
codeVerifier: login.codeVerifier,
})
await establishSession(response, subjectId)
})
async function redeemAuthorizationCode({ code, codeVerifier }) {
const response = await fetch(`${config.backendUrl}/relying-party/v1/session/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: basicAuthorization(),
},
body: JSON.stringify({ code, code_verifier: codeVerifier }),
})
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(`Token exchange failed (HTTP ${response.status}): ${body?.errorCode ?? 'no error body'}`)
}
return (await response.json()).subjectId
}
Java
UUID redeemAuthorizationCode(String code, String codeVerifier) throws Exception {
Map<String, String> body = Map.of("code", code, "code_verifier", codeVerifier);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(backendUrl + "/relying-party/v1/session/token"))
.header("Content-Type", "application/json")
.header("Authorization", basicAuthorization())
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IllegalStateException("Token exchange failed: HTTP " + response.statusCode()
+ " " + response.body());
}
return UUID.fromString(objectMapper.readTree(response.body()).get("subjectId").asText());
}
The login cookie is cleared as soon as it is read, so one attempt cannot be
replayed. state is checked to be a string because a repeated query parameter
arrives as an array, which would otherwise sidestep the comparison.
The request body is JSON, not the form encoding RFC 6749 specifies for a token endpoint — check that before reaching for a generic OAuth client library. Full detail: step 5 of the login flow.
Step 6: Log the user in
{ "subjectId": "8f14e45f-ceea-4a1b-9f2c-3d5b7a081c62" }
That is the whole response for a plain login, and it is enough: subjectId is
stable for that user across every login to you, so it is the primary key of your
account record. Look the account up or create it, then start your own session —
regenerating the session identifier at this point, so a pre-login identifier
cannot be fixated.
Never accept a subjectId from the browser. It reaches you only in the token
response, on your server.
The same person logging in to a different relying party gets an unrelated value,
and staging and production give you different values for the same person — see
what subjectId is, and is not.
If you need the user's name, or a check such as "is at least 18", ask for it as
part of the login rather than collecting it yourself; the token response then
carries it alongside the subjectId. See
Identity properties.
Trying it end to end
With the app installed on a phone, open your login route and expect:
- The browser lands on the ComplyOnce login page, showing a QR code.
- Scanning it makes the app show your registered name. If it shows an internal project code, ask ComplyOnce to change the name.
- Entering the PIN sends the browser back to your
/auth/callback. - Your log shows one token exchange, and your session cookie is set.
The session expires 120 seconds after creation, so a phone picked up late means starting over. All the timing defaults are in the API reference, and they are backend configuration — confirm them for your environment.
When the first deployment fails
These are the failures worth recognising on sight:
| What you see | Cause | Fix |
|---|---|---|
401, no errorCode in the body |
Credentials are checked before the endpoint, so a 401 is not the error envelope |
Check which environment's secret you loaded, and that your HTTP client is not dropping the header. Branch on the status before reading body.errorCode |
400 invalid_request on session creation |
redirect_uri does not exact-match a registered one |
Compare character for character — scheme, port, trailing slash |
400 invalid_grant at the token call |
PKCE mismatch, or a code that is expired, reused, or retried | Check you hashed the verifier string; then check how long your callback took |
400 invalid_scope |
You asked for identity properties outside your allow-list | The allow-list is granted per client at registration |
The hosted page fails with invalid_handoff |
The handoff token was reused, stored, or older than 60 seconds | Redirect with it immediately after session creation |
409 session_already_completed |
A duplicated tab tried to complete the same login twice | The login is spent; start a new one |
Do not retry a failed token exchange with the same code — it is single-use, so the retry cannot succeed.
Part 2: Have a document signed
Signing reuses everything you set up above: same credentials, same session
endpoint, same app. What changes is that you say what is being signed, there is
no authorization code and no redirect_uri, and you pull the result yourself.
Only the SHA-512 hash of your document reaches ComplyOnce. What comes back is a detached ASiC-E container — the signature plus a manifest naming your data by hash, never the data itself.
Step 1: Hash the document
Hash the exact bytes the user is agreeing to, SHA-512, hex-encoded:
Node.js
import { createHash } from 'node:crypto'
const signedDataHash = createHash('sha512').update(documentBytes).digest('hex')
Java
String signedDataHash = HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-512").digest(documentBytes));
Step 2: Create the signing session
curl -X POST https://<backend-host>/relying-party/v1/session \
-u "$COMPLYONCE_CLIENT_ID:$COMPLYONCE_CLIENT_SECRET" \
-H 'Content-Type: application/json' \
-d '{
"signature_type": "SIGNING",
"signed_data_hash": "7947b485a40b4c27…65489c96",
"signed_data_name": "contract.pdf",
"title": "Sign the rental agreement"
}'
{
"sessionId": "59a19705-0e58-441d-98aa-522c8355f9a5",
"expiresAt": 1787056921
}
title is what the signer reads before approving, next to your registered name,
so word it as the sentence they are agreeing to. It is not covered by the
signature — only the hash is — so keep your own record of what you displayed.
signed_data_name is the entry name your document takes inside the container
manifest.
If you already know who should sign — you have their subjectId from a previous
login — add subject_id and anyone else who scans is refused. Add push: true
as well and their app opens on the signing screen with no QR code at all.
Step 3: Show the QR code
Unless you pushed the session, render this for the app to scan:
complyonce://app/scan?sessionId=<uuid>&returnUrl=<url-encoded return address>
Do not display the hash. A 128-character string is not something a person can check, and showing it invites a comparison nobody performs.
Step 4: Poll, then fetch the result
PENDING → SCANNED → COMPLETED. Once the status is COMPLETED the
signature exists and the result can be fetched. CANCELLED (the signer declined
or locked their device) and EXPIRED (the session ran out) are both terminal;
you create a new session.
Polling is the simplest thing that works, and what the rest of this page does. Once it does work, Webhooks removes the loop.
Node.js
async function get(path) {
const response = await fetch(`${config.backendUrl}${path}`, {
headers: { Authorization: basicAuthorization() },
})
if (!response.ok) {
throw new Error(`GET ${path} failed (HTTP ${response.status})`)
}
return response.json()
}
export async function awaitSignature(sessionId, { intervalMs = 2000 } = {}) {
for (;;) {
const { status } = await get(`/relying-party/v1/session/${sessionId}/status`)
if (status === 'COMPLETED') {
return get(`/relying-party/v1/session/${sessionId}/result`)
}
if (status === 'CANCELLED' || status === 'EXPIRED') {
throw new Error(`Signing session ${status.toLowerCase()}`)
}
await new Promise((resolve) => setTimeout(resolve, intervalMs))
}
}
Java
JsonNode get(String path) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(backendUrl + path))
.header("Authorization", basicAuthorization())
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IllegalStateException("GET " + path + " failed: HTTP " + response.statusCode());
}
return objectMapper.readTree(response.body());
}
JsonNode awaitSignature(String sessionId) throws Exception {
while (true) {
String status = get("/relying-party/v1/session/" + sessionId + "/status").get("status").asText();
if ("COMPLETED".equals(status)) {
return get("/relying-party/v1/session/" + sessionId + "/result");
}
if ("CANCELLED".equals(status) || "EXPIRED".equals(status)) {
throw new IllegalStateException("Signing session " + status.toLowerCase());
}
Thread.sleep(2000);
}
}
The result carries the base64 ASiC-E container, the hash it covers, the signing time, and the signer's identity:
{
"container": "UEsDBBQACAgIAAAAIQ…",
"signedDataHash": "7947b485a40b4c27…65489c96",
"signedDataName": "contract.pdf",
"signingTime": 1787056801,
"name": "Mari Mets",
"personalCode": "38001085718",
"dateOfBirth": "1980-01-08",
"country": "EE"
}
Those identity fields are a convenience. What binds the signature to a person is
the certificate inside the container — verify against that, and refuse a
certificate whose subject serialNumber begins with DEMO in production.
404 and
the user has to sign again. Decoding the base64 and writing it to your own
storage is part of step 4, not a later job.
Full detail, including what to do with a detached container: Signing flow and Verifying and archiving.
Where to go next
- Own the waiting page instead of using the hosted one: your server creates the session, polls it and completes it, and your page renders the QR code. See Login flow — steps 4, 5 and 6 above are unchanged.
- Ask for identity data as part of a login: attribute values, or named true/false checks ComplyOnce evaluates. See Identity properties.
- Every field of every endpoint: the API reference.
- A running integration to read: the
complyonce-playgroundrepository drives a real login and shows every request and response as it happens, including toggles that break one thing on purpose so you can see what a wrong secret or an unregistered redirect URI actually returns.