Webhooks
certo posts signed events to your endpoint. The events, the headers, how to check the signature in Node and Python, and how retries work.
5 minute read
A webhook tells your system the moment something happens, so it does not have to ask. Setting one up in the console is in Webhooks for employers; this page is the technical part.
The events
| Event | When |
|---|---|
request.submitted | a candidate sent their checklist to you, or certo sent it because it became complete |
webhook.test | someone pressed Send a test event |
invitation.accepted, invitation.declined, invitation.expired | a candidate answered your invitation, or nobody did before it ran out |
access_request.approved, access_request.declined, access_request.expired | the same for an ask to read a passport |
candidate.access_ended | your access to a candidate's passport ended: they withdrew it, its end date passed, or the free reading behind it lapsed |
candidate.identity_verified | a candidate who accepted your invitation, or approved your ask, passed the identity check; if they had passed it already, right after invitation.accepted or access_request.approved |
issuance.claimed, issuance.merge_review | a person claimed a record you issued, or it waits for certo to add it to the passport they already hold |
An event carries ids, states and counts, and the custom_data you sent with what it is about (an id from your ATS or HR system, say), or null. It never carries a record's content, a contact detail, an e-mail address or a date of birth: those you read through the API or the console, where the candidate's approval applies and the read is logged. Only request.submitted names the candidate, and only while the request is open to you.
Each change sends one event, once. Events are sent only while your endpoint is switched on: nothing is saved up while it is off or before you set it up. Withdrawing your own invitation or ask sends nothing.
Every event's data starts with your organisation_id. The rest, by event:
| Event | data |
|---|---|
request.submitted | request: kind (invitation or ask), id, custom_data; the submission, its items and, while the request is open to you, the candidate |
invitation.* | invitation: id, state (accepted, declined or expired), position_id, custom_data; candidate_id, once accepted |
access_request.* | access_request: id, state (approved, declined or expired), position_id, custom_data; candidate_id, once approved |
candidate.access_ended | candidate: id, reason (withdrawn, expired or lapsed), custom_data |
candidate.identity_verified | candidate_id, invitation_id (null for an ask), position_id (or null), verified_at (when the identity check passed), custom_data |
issuance.* | record: id, kind (employment, education or certification), status (claimed or merge_review), custom_data |
candidate_id is the id GET /v1/candidates/{id} takes, and a record's id is the one GET /v1/issuance/records/{id} takes. custom_data is your own, as you sent it with the invitation, the ask or the issued record; a candidate's is their invitation's, else your newest ask's. For example:
{
"id": "01a17b20-...",
"type": "invitation.accepted",
"created_at": "2026-10-24T10:00:00Z",
"data": {
"organisation_id": "...",
"invitation": { "id": "...", "state": "accepted", "position_id": "...", "custom_data": { "ats_candidate_id": "GH-48213" } },
"candidate_id": "..."
}
}
candidate.identity_verified is sent once per candidate you can read. Its body:
{
"id": "01a2f3c4-...",
"type": "candidate.identity_verified",
"created_at": "2026-10-31T10:00:00Z",
"data": {
"organisation_id": "...",
"candidate_id": "...",
"invitation_id": "...",
"position_id": null,
"verified_at": "2026-10-31T09:59:58.412+00:00",
"custom_data": null
}
}
It is sent when the identity check passes, or, if it had passed before, right behind the invitation.accepted or access_request.approved it follows, so you never wait for an event that already happened. A later check (a new name, say) sends nothing more. For a candidate whose check passed before your endpoint was on, read identity_verified on GET /v1/candidates/{id}.
An end date passing or a free reading lapsing changes nothing you could watch for, so certo looks for those every 15 minutes: candidate.access_ended with expired or lapsed can arrive up to a quarter of an hour after the moment itself.
The request
A POST with content-type: application/json and three headers:
| Header | What |
|---|---|
certo-event-id | The event's id. If you have handled it already, answer 200 and do nothing. |
certo-event-type | The event, such as request.submitted. |
certo-signature | t=<unix seconds>,v1=<hex signature> |
Checking the signature
v1 is the HMAC-SHA256 of <t>.<raw body>, keyed with your whole signing secret, in hex. Check it over the raw bytes before you parse the JSON, compare in constant time, and refuse a t more than five minutes from your clock.
Node:
import crypto from 'node:crypto'
export function verify(secret, header, rawBody, now = Math.floor(Date.now() / 1000)) {
const parts = Object.fromEntries(header.split(',').map((p) => p.trim().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'))
}
Python:
import hashlib, hmac, time
def verify(secret: str, header: str, raw_body: bytes, now: int | None = None) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
t = int(parts.get("t", "0"))
if abs((now or int(time.time())) - t) > 300:
return False
want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, parts.get("v1", ""))
Answering and retries
Answer with any 2xx within ten seconds. 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. A test event is tried once. Your endpoint must be https on a public host name, port 443, with no user name or password in the address; certo does not follow redirects.
Rotating the secret
Make a new secret in the console (or, with the API, POST /v1/webhook/rotate). The old secret stops at once, so put the new one in place straight after.