Webhooks
Polling works and is not going away. If you would rather not do it, ComplyOnce can post to you when a session changes.
A webhook only tells you which session changed. It carries no personal data
and no signature. Your server still calls /status, /completion, /token and
/result exactly as it does now, under its own credentials — the notification
just saves you asking repeatedly whether anything has happened yet.
expiresAt you get when you create the session, or those flows will
wait forever. Every other ending — confirmed, signed, declined, locked out —
does send one.
Turning it on
In the relying-party portal, open your relying party and find Webhooks:
- Tick the box and enter your endpoint. It must be
httpsand must not carry credentials in the URL. - Save. The first URL you save mints your signing secret, shown once. Copy it — the portal does not show it again, though you can reset it.
- Press Send a test delivery and watch the row appear in the log below.
Changing the URL later leaves the secret alone, so moving your endpoint does not mean redeploying your verification at the same time.
What arrives
A POST with a JSON body:
{
"version": "1",
"deliveryId": "6f1c9a2e-95b1-4f0c-bb3e-7a2f1f0f4c11",
"type": "session.confirmed",
"status": "CONFIRMED",
"sessionId": "c2b1f7a4-1c2f-4a0e-9c3b-2f5d8e1a9b77",
"relyingPartyId": "4f8a1d0b-2c11-4a7e-9c5a-88a9e1d3b210",
"signatureType": "AUTHENTICATION",
"occurredAt": 1789000000
}
| Field | Meaning |
|---|---|
version |
Payload version. Fields may be added; "1" will not change meaning |
deliveryId |
Unique per event and stable across retries — deduplicate on it |
type |
The event, see below |
status |
The session status this event means, same vocabulary as /status |
sessionId |
The session that changed |
relyingPartyId |
Which of your registrations this is for, if several point here |
signatureType |
AUTHENTICATION or SIGNING — tells you which call to make next |
occurredAt |
Epoch seconds of the change itself, not of this attempt |
And these headers:
| Header | |
|---|---|
X-ComplyOnce-Signature |
t=<epoch>,v1=<hex> — see below |
X-ComplyOnce-Delivery |
Same as deliveryId, so you can correlate without parsing |
X-ComplyOnce-Event |
Same as type |
X-ComplyOnce-Attempt |
1 on the first try, 2 on the first retry, and so on |
User-Agent |
ComplyOnce-Webhook/1 |
Events
type |
Sent for | What to do |
|---|---|---|
session.scanned |
Login and signing | Nothing. Worth reflecting on your page so the user knows the scan registered |
session.confirmed |
Login | Call /completion, then exchange the code at /token |
session.completed |
Signing | Fetch /result |
session.cancelled |
Login and signing | The user declined, left the flow, or locked their device. Terminal — start again |
webhook.test |
Never automatically | Only the portal's test button. Carries no sessionId, status or signatureType |
Login never sends session.completed: that transition is caused by your own call
to /completion, so telling you about it would tell you something you just did.
Verifying the signature
Recompute v1 as HMAC-SHA256 over the bytes <t>.<raw request body>, keyed with
your signing secret, and compare. Two things to get right:
- Sign the raw body exactly as received. Parsing the JSON and re-serialising it will not produce the same bytes.
- Reject anything whose
tis more than five minutes old. The timestamp is inside the signed material precisely so a captured delivery cannot be replayed later.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto'
// Give this route the raw body, not a parsed one — express.raw({ type: 'application/json' })
export function verify(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((part) => {
const at = part.indexOf('=')
return [part.slice(0, at), part.slice(at + 1)]
}),
)
if (!parts.t || !parts.v1) {
return false
}
if (Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t)) > 300) {
return false
}
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex')
const received = Buffer.from(parts.v1, 'hex')
const computed = Buffer.from(expected, 'hex')
return received.length === computed.length && timingSafeEqual(received, computed)
}
Java
boolean verify(byte[] rawBody, String signatureHeader, String secret) throws Exception {
Map<String, String> parts = Arrays.stream(signatureHeader.split(","))
.map(part -> part.split("=", 2))
.collect(Collectors.toMap(pair -> pair[0], pair -> pair[1]));
long timestamp = Long.parseLong(parts.get("t"));
if (Math.abs(Instant.now().getEpochSecond() - timestamp) > 300) {
return false;
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(UTF_8));
byte[] expected = mac.doFinal(rawBody);
return MessageDigest.isEqual(expected, HexFormat.of().parseHex(parts.get("v1")));
}
Answering
Return any 2xx within five seconds. Acknowledge first and do the work
afterwards — the call you make back to us will not finish inside that window.
Anything else is a failure, and which kind decides what happens next:
5xx,408,429, a timeout or a refused connection — we try again.- Any other
4xx— we stop. A misconfigured endpoint does not fix itself, so repeating the call only spends attempts. - A redirect — we stop, and do not follow it.
Retries, and when we give up
How long we keep trying is set by how long the call it invites stays useful, not by how much we want it to arrive.
| Event | Attempts | Spread over |
|---|---|---|
session.scanned, session.confirmed, session.cancelled |
4 | about 50 seconds |
session.completed |
5 | about 21 minutes |
webhook.test |
1 | not retried |
The short budget is not arbitrary: an authorization code lives 60 seconds from confirmation, so a notification that has not landed by then points at something you can no longer redeem. The long one fits inside the 30 minutes a signature stays fetchable.
Retries are spread with a little randomness, so a receiver coming back up is not hit by everything it missed at once.
What you should assume
- Deliveries can be duplicated. If your endpoint answers but the response does
not reach us, we try again. Deduplicate on
deliveryId, which is the same for every attempt at the same event. - Deliveries can arrive out of order, and this is safe to ignore: the notification only points at a session, and whatever you call next returns the current state regardless.
- Deliveries can be missed. They are best effort. The state in the API is the truth, so nothing is lost that polling cannot recover.
Keeping an eye on it
The portal shows the recent deliveries for your relying party: the event, how many attempts it took, and what came back — a status code, or a connection error.
We never record the body of your response, only its status. A delivery that ends up somewhere unintended must not become a way to read what was there.
Changing things later
- Changing the URL takes effect immediately, including for deliveries still being retried, which will go to the new endpoint.
- Turning webhooks off stops queued deliveries as well as new ones. Turning them back on does not deliver a backlog; nothing is queued while they are off.
- Resetting the secret is an immediate cutover — in-flight retries are signed with the new one. Reset when you are not mid-flow.