Webhooks
Send a callback URL with each applicant and get their status pushed to you — no polling, nothing to register.
Webhooks in the CONFIRM Partner API are per applicant, not per endpoint. You send a webhook_url when you create a candidate, and that applicant’s lifecycle events are POSTed to that exact URL.
For applicants you send us, there is no endpoint to register, no endpoint ID to store, and no configuration to keep in sync between your environments. (Applicants CONFIRM finds on its own reach you through the optional registry default endpoint.) Append your own identifier to the URL and every delivery arrives already correlated to your record — you never need a CONFIRM ID to match an event.
Why per applicant? Because it makes multi-environment routing free. Each developer sends their own tunnel subdomain, staging sends staging, production sends production, with no per-environment endpoint configuration to drift.
Availability: everything on this page is live in both sandbox and production. Deliveries are only sent once your signing secret for that environment has been issued (see Verifying signatures).
There is one case per-applicant URLs structurally cannot cover, and a registry default endpoint exists for it.
Sending a callback URL
Add webhook_url to any candidate ingest call:
The trailing 8f2c41aa-… is yours — a tracking ID you generated before the call. When a delivery lands on that path you already know which applicant it is about, without reading the body.
The response echoes back what we stored:
Rules for the URL
A URL that breaks a rule is refused with a 400 whose error.code says which rule. The same rules and codes apply to webhook_url on POST /candidates and to url on PUT /organizations/{ref}/webhook.
A value that isn’t a string (or, on the PUT, a missing url) is the general invalid_request. webhook_url: null is not an error: it’s treated as if you left the field out (see below).
A URL that fails validation is rejected before the applicant is created, so you never end up with a half-wired applicant. Fix it and retry — retrying is always safe.
Changing or omitting it
- Omit
webhook_urlon a new applicant and they emit no events at all. This is the default. - Re-send an applicant with a different
webhook_urland they are re-pointed, immediately. This is how you move an applicant from one developer’s tunnel to another, or from staging to production. - Omit it on a re-send, or send
webhook_url: null, and the stored URL is left untouched — neither is read as “stop sending me events”.
The registry default endpoint
Some applicants never pass through POST /candidates at all — the ones we find for you on a job board or your career site. There is no request on which to send a webhook_url, so without a fallback those people would be invisible to you.
Set one URL per registry:
Resolution order is:
- the applicant’s own
webhook_url, if they have one - the registry’s default endpoint
- nothing
Setting a default never overrides an applicant’s own URL. Your per-developer and per-environment routing keeps working exactly as before; this only catches applicants that had nowhere to go.
GET the same path to read it, DELETE to stop fallback deliveries. Same URL rules as above, same error codes. There is exactly one endpoint per registry, so there is no endpoint ID for you to store.
DELETEis idempotent: it returns204whether or not a default endpoint was set.GETreturns404 no_webhook_endpointwhen the registry has no default endpoint.- On all three, a registry ref we don’t know returns
404 unknown_organization_ref.
The event envelope
Every delivery is a JSON POST with the same envelope:
id— a UUID unique to this event. Deduplicate on it; delivery is at-least-once.type— the event type (see below).created_at— when the event occurred, ISO 8601.data— the event payload. Always carries both systems’ identifiers.
Every data block includes the same identity fields, so no event ever requires a lookup to interpret:
One status per location
Every event is about one application: one applicant at one location. An applicant you put at several locations (with location_refs, or with POST /candidates/{external_ref}/locations) has a separate application, and a separate status, at each one. They can be rejected at one location and in interview at another.
So key your records on application_id, or on external_ref plus location_ref, never on external_ref alone. Every event carries both location_ref and application_id for exactly this reason.
Event types
v1 delivers six events: candidate.created, candidate.status_changed, screening.completed, candidate.placed, candidate.location_added and candidate.location_changed. The rest of the catalog is committed roadmap — write your handler to ignore types it does not recognise and you will not need to change it when they arrive.
candidate.created
Fires when we find someone for you — a job-board applicant, a career-site applicant — who you have never seen. It never fires for applicants you sent us: you already hold both IDs from the POST /candidates response, and echoing it back would only invite you to create a duplicate.
This is the ID exchange running in the other direction. POST /candidates gives us your ID and hands you ours; this gives you ours first.
external_ref is null — you have no ID for this person yet. That is the one thing to do with this event:
Completing the pairing. Create your own record, then POST the applicant to /candidates with your external_ref and the same phone number. Ingest matches them to the existing candidate and binds your ID to it — you get deduped: true and the same candidate_id back. No duplicate person, no duplicate application, and from then on every event about them carries your external_ref like any other.
source is where we found them (indeed, public_apply, …) — useful when deciding whether to claim someone.
candidate.status_changed
The status vocabulary
status is one of five values, and only these five:
Do not expect finer granularity, and do not parse around it. CONFIRM’s internal pipeline has dozens of stages, they differ per registry, and they change as the product changes. This five-value enum is the stable contract precisely so that our internal changes cannot break your integration. If you need to know why an applicant was rejected, or exactly where in a multi-round pipeline they are, open the portal_url.
Two consequences worth designing around:
- Not every internal move produces an event. Movements that do not change the coarse status are suppressed — you will not get five
reviewevents in a row as an applicant walks through a registry’s internal review steps. rejectedis terminal for your purposes, but not always for ours. A registry can reconsider an applicant; if they do, you will receive a fresh event moving them back.- Your own outcomes come back to you. When you set an outcome with
PATCH /candidates/{external_ref}?location_ref=…(withdrawn,not_hired,hired) and it changes the status, this event fires for that location like any other status change. Expect the echo of your own change and deduplicate it. - A move is not a rejection. When an applicant is moved to another location, the location they left sends no
rejected; you get onecandidate.location_changed(below) instead.
screening.completed
Fires when the AI screening call has completed and its analysis has finished — so the result is populated rather than pending.
With one screening call covering several locations, each location gets its own screening.completed, with its own result and tier. Knockout questions count per location: failing one location’s knockout question does not fail the others.
A location added after the applicant’s call gets its own screening.completed too: after its follow-up call, or straight away when the earlier answers already settle it with no call. In that last case completed_at is the time of the call the answers came from.
We never put transcripts or audio in a webhook. A screening transcript is a person’s words in a recorded call. It stays behind the portal’s authentication, where an authorised registry user reads it — the webhook tells you it exists and where to find it.
candidate.placed
Fires when the registry office chooses this location for the applicant in CONFIRM. Placement is a decision, not a status: it does not change status, and it does not close the applicant’s other locations, which can still hire the same person later. It fires once per application; placing the same application again sends nothing new.
candidate.location_added
Fires when the applicant gets an application at another location through POST /candidates/{external_ref}/locations (without move_from_location_ref), or when the office does the same in CONFIRM. The identity block is the new location. The applicant’s other applications are unchanged. Once per application.
candidate.location_changed
Fires when the applicant is moved: POST /candidates/{external_ref}/locations with move_from_location_ref, or a reassignment in CONFIRM. The application at the old location is closed, but not as a rejection: there is no candidate.status_changed → rejected for it. This one event replaces it.
It is delivered against the new location’s application, so location_ref equals to_location_ref and application_id equals to_application_id. Once per moved-away application.
screening has the same values as on candidate.location_added. Treat the from_ application as closed; the applicant carries on at the to_ location.
Delivery semantics
- At-least-once. You may receive the same event more than once. Deduplicate on
id. - Retries with backoff. Any non-2xx response, timeout (10s), or connection failure is retried at roughly 1 min → 5 min → 30 min → 2 h → 6 h → 15 h. After about 24 hours the delivery is abandoned and CONFIRM engineering is alerted.
- Ordering is NOT guaranteed. A
screening.completedmay arrive before thecandidate.status_changedthat accompanied it. Treat each event as a statement about the moment in itscreated_at, and let a latercreated_atwin. - Payloads are snapshots. A delivery retried six hours later carries what was true when the event fired, not an updated version of it. The current state is always in the portal.
- Bulk actions are throttled on our side. A registry rejecting 300 applicants at once produces 300 events, but they are dispatched in small batches over a few minutes rather than as a burst.
Verifying signatures
Every delivery carries a signature header:
t— Unix timestamp (seconds) when the delivery was signed.v1— hex-encoded HMAC-SHA256 of the string"<t>.<raw body>", keyed with your partner signing secret (cfwh_...).
Your signing secret is issued by CONFIRM, separately from your API keys — it is not on the Developer access tab. Ask your CONFIRM contact; it is delivered privately. There is one per environment (sandbox and production each have their own), and it is partner-level: one secret verifies every delivery for every registry under your account, and minting or revoking API keys never changes it. It is shown once and never recoverable — a lost secret is rotated, and the old one stops verifying immediately.
Two more headers are set for convenience, and are not a substitute for verifying the signature: Confirm-Event-Id and Confirm-Event-Type.
To verify: recompute the HMAC over the raw, unparsed request body, compare in constant time, and reject if the clock skew |now - t| exceeds 5 minutes. The timestamp is inside the signed string, so an attacker cannot replay a captured delivery under a fresh t.
Node (Express)
Python (Flask)
Testing your consumer
Point a webhook_url at your endpoint and drive a real applicant through the sandbox (https://api-staging.confirm.global/partner/v1). Sandbox telephony is restricted to an allowlist of phone numbers you give us, so you can create test applicants freely and exercise the full loop — ingest, screening call, status changes — with no risk of contacting a real person. Deliveries are signed exactly as in production, so your verification path gets a genuine workout.
Building a consumer: checklist
- Generate your own tracking ID and append it to the
webhook_urlyou send. Correlation becomes a routing concern, not a lookup. - Verify the signature before parsing the body, using the raw bytes.
- Respond 2xx fast — within seconds, before doing any real work.
- Process asynchronously — enqueue the event and handle it off the request path.
- Dedupe on event
id— delivery is at-least-once and unordered. - Ignore unknown event types — the catalog grows; your handler should not break when it does.
- Treat
statusas five values. Never parse or branch on anything finer. - Handle
external_ref: nulloncandidate.created— that applicant is new to you, and completing the pairing is a POST back to/candidates. - Key status on the location. Store status per
application_id(orexternal_ref+location_ref), not per applicant — each location has its own. - Expect your own outcomes back. A
PATCHoutcome that changes the status comes back ascandidate.status_changed; don’t treat it as a new decision.

