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

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:

  1. The user clicks Log in. Your server creates a session and asks for a handoff token.
  2. Your server redirects the browser to the hosted page, carrying that token.
  3. The hosted page shows the QR code and waits. The user scans it and confirms with their PIN.
  4. The hosted page sends the browser back to your redirect_uri with code and state.
  5. Your server checks state and exchanges code for a subjectId.
  6. 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:

  1. The browser lands on the ComplyOnce login page, showing a QR code.
  2. Scanning it makes the app show your registered name. If it shows an internal project code, ask ComplyOnce to change the name.
  3. Entering the PIN sends the browser back to your /auth/callback.
  4. 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.

Store the container. Signing results are deleted 30 minutes after signing, after which status and result both return 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-playground repository 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.