https://api.certo.bio/v1openapi.json

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

EventWhen
request.submitteda candidate sent their checklist to you, or certo sent it because it became complete
webhook.testsomeone pressed Send a test event
invitation.accepted, invitation.declined, invitation.expireda candidate answered your invitation, or nobody did 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: they 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, 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

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:

Eventdata
request.submittedrequest: 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_endedcandidate: id, reason (withdrawn, expired or lapsed), custom_data
candidate.identity_verifiedcandidate_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:

HeaderWhat
certo-event-idThe event's id. If you have handled it already, answer 200 and do nothing.
certo-event-typeThe event, such as request.submitted.
certo-signaturet=<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.