Webhooks

certo posts signed events to your endpoint when a candidate sends a request, answers an invitation or an ask, or your access ends. Setting it up, checking the signature.

5 minute read

A webhook tells your own systems the moment something happens to what you started: a candidate's request is sent to you (by the candidate, or by certo because everything on the checklist is ready), a candidate answers your invitation or your ask, your access to a passport ends, or a person claims a record you issued. It carries ids, states and counts only, never a record or a contact detail.

Set it up

An owner or an admin opens Webhooks in the menu.

  1. Enter the Endpoint URL: https only, at a public address, such as https://hooks.northwind.example/certo.
  2. Press Set up the webhook.
  3. certo shows Your signing secret once. Copy it now: certo keeps only a sealed copy and shows you just its last four characters afterwards. Press I have saved it.
  4. Press Send a test event to check your endpoint answers.

Send events switches delivery on and off. New secret makes a new signing secret; the old one stops working at once. Remove stops all events; the deliveries listed stay for 90 days.

The URL must be https, on a public host name: no IP address, no port but 443, no user name or password in it. certo does not follow redirects.

The events

EventWhen
request.submittedA candidate sent a request, or certo sent it because it became complete.
webhook.testYou pressed Send a test event. It carries your organisation's id and a message only. Up to ten a day.
invitation.accepted, invitation.declined, invitation.expiredA candidate accepted or declined your invitation, or nobody answered before it ran out.
access_request.approved, access_request.declined, access_request.expiredThe same for an ask to read a passport.
candidate.access_endedYour access to a candidate's passport ended: the candidate withdrew it, its end date passed, or the free reading behind it lapsed.
candidate.identity_verifiedA candidate who accepted your invitation, or approved your ask, passed the identity check. If they had passed it already, it comes right after invitation.accepted or access_request.approved.
issuance.claimed, issuance.merge_reviewA person claimed a record you issued, or it waits for certo to add it to the passport they already hold.

Each change sends one event, once, and only while Send events is on: nothing is saved up for later. Withdrawing your own invitation or ask sends nothing. An end date passing or a free reading lapsing is noticed within 15 minutes.

The events after webhook.test name what changed by its id and the state it reached: the invitation or the ask with its position, the candidate's id once you can read the passport, why access ended (withdrawn, expired or lapsed), or the record you issued with its kind, or, for candidate.identity_verified, the candidate's id, the invitation, the position and when the check passed. Never the candidate's name or address. Webhooks for developers lists each event's fields.

Each request.submitted carries the request (an ask or an invitation), the send (who sent it, when, and how many items are ready), each item's state and counts, and, for an invitation your ATS made, the ATS's ids. While the request is open to you it also carries the candidate's name and passport address. Items the candidate did not share with you read as missing, with no counts.

Every delivery is a POST with content-type: application/json and these headers:

HeaderWhat
certo-event-idThe delivery's id. Skip one you already have.
certo-event-typeThe event, such as request.submitted.
certo-signaturet, the time in seconds, and v1, the signature.

A shortened example body:

{
  "id": "01a14c52-...",
  "type": "request.submitted",
  "created_at": "2026-10-17T10:00:00Z",
  "data": {
    "organisation_id": "...",
    "request": { "kind": "invitation", "id": "..." },
    "submission": { "id": "...", "trigger": "auto", "sent_at": "...", "complete": true, "ready": 6, "total": 6 },
    "items": [
      { "key": "identity", "state": "ready", "counts": { "ready": 1, "total": 1 } },
      { "key": "work_history", "state": "ready", "counts": { "ready": 4, "total": 4 } }
    ],
    "candidate": { "passport_url": "https://certo.bio/sam-taylor", "name": "Sam Taylor" }
  }
}

Checking the signature

v1 is the hex HMAC-SHA256 of the time, a dot and the raw body, keyed with your whole secret. Check it over the raw bytes before you parse anything, compare in constant time, refuse a time more than five minutes from your clock, and skip an event id you have already handled.

import crypto from 'node:crypto'

function verify(secret, header, rawBody, now = Math.floor(Date.now() / 1000)) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  const t = Number(parts.t)
  if (!Number.isInteger(t) || Math.abs(now - t) > 300) return false
  const want = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
  const got = Buffer.from(parts.v1 ?? '', 'hex')
  return got.length === 32 && crypto.timingSafeEqual(got, Buffer.from(want, 'hex'))
}

Delivery and retries

Answer with any 2xx within ten seconds and the delivery counts. Anything else, a redirect included, is tried again after 1, 2, 4, 8, 16 and 32 minutes and then an hour: eight attempts over about two hours, then certo gives up. A test event is tried once.

Last deliveries on the Webhooks page lists each event with its state (Sending, Delivered, Failed, retrying, Gave up), the attempts and the HTTP status, and why a failure failed, for example "no answer within 10 seconds".

What a webhook never carries

No record's content, no contact detail, no date of birth. Those stay on the passport, read under the candidate's approval and logged there. The webhook is the signal to look; Reading a passport is where you look.