Signing flow
Signing works like login: you create a session, the user scans its QR code and confirms on their phone. The differences are that you say what is to be signed when you create the session, and you pull the result from the session instead of receiving an authorization code through the browser.
Only the SHA-512 hash of your data ever reaches ComplyOnce. The document stays with you, and what comes back is a detached ASiC-E container: the signature and a manifest naming your data by hash, never the data itself.
The flow at a glance
- Your server hashes the document and creates a signing session, getting back a
sessionId. - Your page renders the QR code built from that
sessionId. - The user scans it. The app shows your registered name and your
title, so they see who is asking and what for. - The user confirms with their signing PIN.
- Your server polls the session, sees
COMPLETED, and fetches the result. - You store the container. We delete it 30 minutes after signing.
There is no code, no redirect_uri and no PKCE in this flow. Those exist to
protect an authorization code travelling through a browser; signing has no such
code, and nothing sensitive travels through the browser at all.
Step 1: Create the session
Hash the exact bytes the user is agreeing to, with SHA-512, hex-encoded:
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": "7947b485a40b4c27984f2261c6624d9d80f70bd2c862d9107335a6001f5ea9ce95c7a56752a31016188284289185e2dc4d5a26e35f4b3ef057c11e2b65489c96",
"signed_data_name": "contract.pdf",
"title": "Sign the rental agreement"
}'
{
"sessionId": "59a19705-0e58-441d-98aa-522c8355f9a5",
"expiresAt": 1787056921
}
signed_data_name is the name your data carries inside the container manifest,
and is the entry name you will write when you assemble the container with the
document for archiving. It defaults to detached-file and may not contain path
separators. title is free text shown to the signer, labelled in the app as coming from you.
It is the main thing the signer reads before approving, so word it as the sentence
they are agreeing to — and note it is not covered by the signature; only the
hash is. Keep your own record of what you displayed.
To restrict a session to one known user, add subject_id with the subjectId
you learned from a previous login. Anyone else who scans is refused, and the
session stays open for the right person.
Naming the signer does not reach them on its own. Add push: true as well and
ComplyOnce sends the session to that person's devices, so their app opens on the
signing screen and step 2 below is unnecessary — there is no QR code for anyone
to scan. Pushing needs a subject_id, since there would otherwise be nobody to
push to. The response tells you what happened in pushStatus; on NO_DEVICE,
show the QR code as usual.
Step 2: Show the QR code
The QR payload is the same as for login:
complyonce://app/scan?sessionId=<uuid>&returnUrl=<url-encoded return address>
The hash is not shown to the user, on your page or in the app: a 128-character
string is not something a person can compare, and displaying it invites a check
nobody performs. What the signer sees is your registered name and your title, so
make the title say plainly what they are agreeing to.
Step 3: Poll until it is signed
curl https://<backend-host>/relying-party/v1/session/$SESSION_ID/status \
-u "$COMPLYONCE_CLIENT_ID:$COMPLYONCE_CLIENT_SECRET"
A push-initiated signing session can sit for minutes while the signer finds their phone, which is a long time to keep asking. ComplyOnce can post to your server when it changes instead — see Webhooks.
PENDING → SCANNED → COMPLETED. COMPLETED means the signature exists and
the result can be fetched. CANCELLED means the signer declined, left the flow,
or locked their device with wrong PINs; EXPIRED means the session ran out
before the user confirmed. Both are terminal — create a new one.
Step 4: Fetch the result
curl https://<backend-host>/relying-party/v1/session/$SESSION_ID/result \
-u "$COMPLYONCE_CLIENT_ID:$COMPLYONCE_CLIENT_SECRET"
{
"container": "UEsDBBQACAgIAAAAIQ…",
"signedDataHash": "7947b485a40b4c27…65489c96",
"signedDataName": "contract.pdf",
"signingTime": 1787056801,
"name": "Mari Mets",
"personalCode": "38001085718",
"dateOfBirth": "1980-01-08",
"country": "EE"
}
container is the base64 ASiC-E. Fetch it as many times as you need — a failed
download or a crashed worker is not a lost signature.
The identity fields are there for convenience. What actually binds the signature to a person is the signing certificate inside the container; verify against that, not against this JSON.
404. If you miss it, the
user has to sign again.
Verifying and archiving
The container is detached: it holds the hash of your document, not the document.
To validate it — or to keep something a third party can validate years from now —
write your document into the container under signed_data_name, or hand both to
a validator that accepts detached content.
The signer's identity is in the certificate: their name, their national
identifier in the subject serialNumber, and their country. A certificate whose
serialNumber begins with DEMO belongs to a demo identity created for testing —
it is not a verified person, and you should refuse it in production.
Errors
| Status | errorCode |
Cause |
|---|---|---|
400 |
invalid_request |
signed_data_hash missing or malformed, or signing fields sent on an authentication session |
404 |
session_not_found |
Unknown session, another client's session, or one already deleted after its retention window |
409 |
not_signed |
Fetched the result before the session reached COMPLETED |
Login is documented separately — see the login flow.