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

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.

One outcome is silent. A session nobody ever scans produces no notification at all; it simply runs out. Keep your own timer on the 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:

  1. Tick the box and enter your endpoint. It must be https and must not carry credentials in the URL.
  2. 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.
  3. 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 t is 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.