Skip to navigation

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:

curl https://api.confirm.global/partner/v1/candidates \
-H "Authorization: Bearer cfk_..." \
-H "Content-Type: application/json" \
-d '{
"external_ref": "cl_app_84321",
"location_ref": "cl_loc_88213",
"first_name": "Maria",
"last_name": "Alvarez",
"phone": "+18135550172",
"consent": { "contact": true },
"webhook_url": "https://hooks.example.com/confirm/8f2c41aa-1d64-4f0b-9c33-2a7e5b104db8"
}'

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:

{
"candidate_id": "9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
"application_id": "51c0a7de-2f18-4a3e-96b7-1d0c8b2e4a77",
"external_ref": "cl_app_84321",
"deduped": false,
"screening": "enqueued",
"portal_url": "https://app.confirm.global/c/suncoast-nurse-registry/candidates/9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
"webhook_url": "https://hooks.example.com/confirm/8f2c41aa-1d64-4f0b-9c33-2a7e5b104db8"
}

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.

RuleError codeWhy
An absolute URL with a hostinvalid_webhook_urlAn empty value, a relative URL, or a URL with no host can’t be delivered to.
HTTPS onlywebhook_url_not_httpsPayloads carry applicant PII.
No credentials in the URLinvalid_webhook_urlhttps://user:pass@… would end up in delivery logs.
A public hostwebhook_url_forbidden_hostlocalhost, private and reserved IP ranges (RFC 1918, loopback, link-local), internal names (*.internal, *.local, *.localhost), cloud metadata endpoints, and names that resolve to any of these are refused.
A host that exists in DNSwebhook_url_unresolvableA name that doesn’t resolve (for example anything under .invalid) can never receive a delivery. Bring a developer tunnel up, so its name resolves, before you send its URL.
2048 characters maxwebhook_url_too_longThe longest URL we store.

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).

{
"error": {
"type": "invalid_request_error",
"code": "webhook_url_not_https",
"message": "webhook_url must use https. Payloads carry applicant PII and are never sent in the clear.",
"request_id": "7aM4tQzK-2f8d-4b63-9c10-6e5a3d1b8f24"
}
}

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_url on a new applicant and they emit no events at all. This is the default.
  • Re-send an applicant with a different webhook_url and 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:

curl -X PUT https://api.confirm.global/partner/v1/organizations/cl_reg_4471/webhook \
-H "Authorization: Bearer cfk_..." \
-H "Content-Type: application/json" \
-d '{ "url": "https://hooks.example.com/confirm/registry/4471" }'

Resolution order is:

  1. the applicant’s own webhook_url, if they have one
  2. the registry’s default endpoint
  3. 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.

  • DELETE is idempotent: it returns 204 whether or not a default endpoint was set.
  • GET returns 404 no_webhook_endpoint when 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": "0f9d3c14-8a2b-4e77-b6c1-5d2e8a4f1093",
"type": "candidate.status_changed",
"created_at": "2026-07-28T14:03:22.481Z",
"data": { }
}
  • 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:

FieldWhat it is
candidate_idCONFIRM’s applicant ID.
application_idThe application at that location.
external_refYour applicant ID, as sent on ingest.
location_refYour location ID.
org_refYour registry ID.
portal_urlDeep link to the applicant in the registry’s portal.

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.

Event typeWhen it firesStatus
candidate.createdCONFIRM sources an applicant you have not seenLive
candidate.status_changedThe applicant’s status at a location changesLive
screening.completedAn AI screening finishes and its analysis is readyLive
candidate.placedThe registry office chooses a location for the applicant in CONFIRMLive
candidate.location_addedThe applicant is added to another locationLive
candidate.location_changedThe applicant is moved from one location to anotherLive
interview.scheduled / .rescheduled / .cancelledInterview booking changesRoadmap
background_check.completedA background check finishesRoadmap

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.

{
"id": "b1c07a49-2f83-4d16-91ee-0c7d5a3e8b24",
"type": "candidate.created",
"created_at": "2026-07-30T15:41:09.220Z",
"data": {
"candidate_id": "7d2b91f0-4c68-4a55-b3e9-1f80c2d64a17",
"application_id": "e04f7c3a-91d2-4b60-8a17-6c5e2f9b0d43",
"external_ref": null,
"location_ref": "cl_loc_88213",
"org_ref": "cl_reg_4471",
"portal_url": "https://app.confirm.global/c/suncoast-nurse-registry/candidates/7d2b91f0-4c68-4a55-b3e9-1f80c2d64a17",
"status": "screening",
"first_name": "Devon",
"last_name": "Marsh",
"email": "devon.marsh@example.com",
"phone": "+18135550199",
"source": "indeed",
"applied_at": "2026-07-30T15:41:08.744Z"
}
}

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

{
"id": "0f9d3c14-8a2b-4e77-b6c1-5d2e8a4f1093",
"type": "candidate.status_changed",
"created_at": "2026-07-28T14:03:22.481Z",
"data": {
"candidate_id": "9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
"application_id": "51c0a7de-2f18-4a3e-96b7-1d0c8b2e4a77",
"external_ref": "cl_app_84321",
"location_ref": "cl_loc_88213",
"org_ref": "cl_reg_4471",
"portal_url": "https://app.confirm.global/c/suncoast-nurse-registry/candidates/9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
"status": "review",
"previous_status": "screening",
"occurred_at": "2026-07-28T14:03:22.402Z"
}
}

The status vocabulary

status is one of five values, and only these five:

StatusMeans
screeningWith CONFIRM. Applied, being contacted, or mid-AI-screening. No verdict yet.
reviewWith the registry. A person is deciding — before an interview, or after one while the hire decision is pending.
interviewOn the interview track: passed review, being scheduled, booked, or rescheduling.
hiredHired.
rejectedNo longer progressing, for any reason.

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 review events in a row as an applicant walks through a registry’s internal review steps.
  • rejected is 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 one candidate.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.

{
"id": "c4a71e88-0b93-4a1d-9e2f-73c1d6b0a5f2",
"type": "screening.completed",
"created_at": "2026-07-28T14:11:49.106Z",
"data": {
"candidate_id": "9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
"application_id": "51c0a7de-2f18-4a3e-96b7-1d0c8b2e4a77",
"external_ref": "cl_app_84321",
"location_ref": "cl_loc_88213",
"org_ref": "cl_reg_4471",
"portal_url": "https://app.confirm.global/c/suncoast-nurse-registry/candidates/9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
"status": "review",
"result": "passed",
"tier": "strong",
"completed_at": "2026-07-28T14:11:47.882Z",
"transcript_url": "https://app.confirm.global/c/suncoast-nurse-registry/candidates/9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16?tab=calls",
"recording_url": "https://app.confirm.global/c/suncoast-nurse-registry/candidates/9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16?tab=calls"
}
}
FieldValues
resultpassed · failed · incomplete (the applicant engaged but the screen did not reach a verdict)
tierstrong · moderate · limited · pending · ineligible — CONFIRM’s fit assessment, blending résumé and screening signals
transcript_url, recording_urlLinks, not content. Both point at the applicant’s Calls tab, where the transcript and the recording player live.

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.

{
"id": "5e8a2c71-3b94-4f06-a1d8-9c7e0b4f2a63",
"type": "candidate.placed",
"created_at": "2026-10-02T16:20:11.538Z",
"data": {
"candidate_id": "9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
"application_id": "51c0a7de-2f18-4a3e-96b7-1d0c8b2e4a77",
"external_ref": "cl_app_84321",
"location_ref": "cl_loc_88213",
"org_ref": "cl_reg_4471",
"portal_url": "https://app.confirm.global/c/suncoast-nurse-registry/candidates/9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
"status": "interview",
"placed_at": "2026-10-02T16:20:11.402Z",
"placed_via": "portal"
}
}
FieldValues
statusThe applicant’s current status at this location (one of the five).
placed_atWhen the office placed them.
placed_viaportal (a registry user in CONFIRM) or partner. Today placement is only done in the portal, so expect portal.

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.

{
"id": "a2f6d0b9-7c31-4e58-9b24-6d1e3a8c5f07",
"type": "candidate.location_added",
"created_at": "2026-10-02T15:02:44.917Z",
"data": {
"candidate_id": "9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
"application_id": "0b6e3f72-5a19-4d8c-a2e4-7c1d9f8b3a60",
"external_ref": "cl_app_84321",
"location_ref": "cl_loc_88214",
"org_ref": "cl_reg_4471",
"portal_url": "https://app.confirm.global/c/suncoast-nurse-registry/candidates/9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
"status": "interview",
"screening": "reused",
"screening_source_application_id": "51c0a7de-2f18-4a3e-96b7-1d0c8b2e4a77",
"occurred_at": "2026-10-02T15:02:44.880Z"
}
}
FieldValues
screeningreused — the applicant’s passed screening covers this location, so they start after screening here. pending — there is a screening, but it doesn’t cover this location (it failed, was partial, or this location asks other questions); the application starts at screening and a follow-up call asks what is missing. none — no completed screening to reuse yet; the application is screened as described there. The value describes the application when it was created and does not change afterwards.
screening_source_application_idThe application whose screening was reused or considered, or null.

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.

{
"id": "e93b5f14-0d67-4a2c-8f19-2b4c7e6a0d58",
"type": "candidate.location_changed",
"created_at": "2026-10-02T15:07:31.226Z",
"data": {
"candidate_id": "9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
"application_id": "0b6e3f72-5a19-4d8c-a2e4-7c1d9f8b3a60",
"external_ref": "cl_app_84321",
"location_ref": "cl_loc_88214",
"org_ref": "cl_reg_4471",
"portal_url": "https://app.confirm.global/c/suncoast-nurse-registry/candidates/9f4c2e81-6d3a-4b7e-a1c2-8e5f0d743b16",
"status": "screening",
"from_location_ref": "cl_loc_88213",
"to_location_ref": "cl_loc_88214",
"from_application_id": "51c0a7de-2f18-4a3e-96b7-1d0c8b2e4a77",
"to_application_id": "0b6e3f72-5a19-4d8c-a2e4-7c1d9f8b3a60",
"screening": "pending",
"occurred_at": "2026-10-02T15:07:31.190Z"
}
}

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.completed may arrive before the candidate.status_changed that accompanied it. Treat each event as a statement about the moment in its created_at, and let a later created_at win.
  • 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:

Confirm-Signature: t=1781532202,v1=5f8c1d2e...
  • 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)

const crypto = require("crypto");
const express = require("express");
const app = express();
const SECRET = process.env.CONFIRM_WEBHOOK_SECRET; // cfwh_...
// IMPORTANT: use the raw body — JSON parsing changes the bytes.
app.post(
"/confirm/:trackingId",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("Confirm-Signature") || "";
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=", 2))
);
const t = parseInt(parts.t, 10);
const v1 = parts.v1 || "";
// Reject stale timestamps (clock skew > 5 minutes)
if (!t || Math.abs(Date.now() / 1000 - t) > 300) {
return res.status(400).send("timestamp out of tolerance");
}
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${t}.${req.body.toString("utf8")}`)
.digest("hex");
const ok =
v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
if (!ok) return res.status(400).send("invalid signature");
res.sendStatus(200); // acknowledge fast
const event = JSON.parse(req.body.toString("utf8"));
// req.params.trackingId is the ID you appended to the webhook_url —
// you already know which applicant this is.
queueForProcessing(req.params.trackingId, event); // dedupe on event.id
}
);

Python (Flask)

import hashlib
import hmac
import json
import os
import time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["CONFIRM_WEBHOOK_SECRET"] # cfwh_...
@app.post("/confirm/<tracking_id>")
def confirm_webhook(tracking_id):
raw_body = request.get_data() # raw bytes, before any parsing
header = request.headers.get("Confirm-Signature", "")
parts = dict(kv.split("=", 1) for kv in header.split(",") if "=" in kv)
try:
t = int(parts.get("t", "0"))
except ValueError:
abort(400)
# Reject stale timestamps (clock skew > 5 minutes)
if abs(time.time() - t) > 300:
abort(400)
signed_payload = f"{t}.".encode() + raw_body
expected = hmac.new(SECRET.encode(), signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(parts.get("v1", ""), expected):
abort(400)
event = json.loads(raw_body)
queue_for_processing(tracking_id, event) # dedupe on event["id"]
return "", 200

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

  1. Generate your own tracking ID and append it to the webhook_url you send. Correlation becomes a routing concern, not a lookup.
  2. Verify the signature before parsing the body, using the raw bytes.
  3. Respond 2xx fast — within seconds, before doing any real work.
  4. Process asynchronously — enqueue the event and handle it off the request path.
  5. Dedupe on event id — delivery is at-least-once and unordered.
  6. Ignore unknown event types — the catalog grows; your handler should not break when it does.
  7. Treat status as five values. Never parse or branch on anything finer.
  8. Handle external_ref: null on candidate.created — that applicant is new to you, and completing the pairing is a POST back to /candidates.
  9. Key status on the location. Store status per application_id (or external_ref + location_ref), not per applicant — each location has its own.
  10. Expect your own outcomes back. A PATCH outcome that changes the status comes back as candidate.status_changed; don’t treat it as a new decision.