Webhooks
Getting the result without polling.
Register an endpoint and we post events to it. Do not poll for completion: you will either hammer the API or miss the transition.
Registering
POST /v1/webhook-endpoints with an https:// URL. You can register more than
one, and each carries its own signing secret. Sandbox and live endpoints are
separate registrations: a sandbox assessment never reaches a live endpoint.
curl -X POST https://hiring-api.experthire.cloud/v1/webhook-endpoints \
-H "Authorization: Bearer $EH_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/experthire"}'
Still a placeholder: $EH_SECRET_KEY. Add it under Your values above.
{
"object": "webhook_endpoint",
"id": "b1d9f4e2-7a35-4c68-9e01-3f5d2c8a6b47",
"url": "https://example.com/hooks/experthire",
"enabled_events": [],
"livemode": true,
"status": "active",
"secret": "whsec_9f3c1a08d7b24e6591cd02af7e4b8635",
"created_at": 1767229200
}
An empty enabled_events means every event. Pass a list to narrow it.
Endpoints are cached for up to five minutes per process, so a newly registered endpoint can take that long before every replica is delivering to it. This matters mainly when you register one and immediately fire a sandbox assessment.
Verifying
Three headers travel with every delivery.
| Header | Contents |
|---|---|
X-Webhook-Id | The event id, whevt_... |
X-Webhook-Timestamp | Unix seconds when the signature was computed |
X-Webhook-Signature | HMAC-SHA256, hex encoded |
The signature is computed over "{id}.{timestamp}.{raw body}" using your
endpoint secret, including the whsec_ prefix.
Verify against the raw body, before any JSON parsing. The classic failure is
express.json() consuming the stream first, which passes locally and then fails
intermittently in production.
import express from "express";
import crypto from "crypto";
const app = express();
app.post(
"/hooks/experthire",
express.raw({ type: "application/json" }),
(req, res) => {
const id = req.get("X-Webhook-Id");
const timestamp = req.get("X-Webhook-Timestamp");
const signature = req.get("X-Webhook-Signature");
// Reject anything older than five minutes so a captured delivery cannot be
// replayed back at you later.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send("stale");
}
const expected = crypto
.createHmac("sha256", process.env.EH_WEBHOOK_SECRET)
.update(`${id}.${timestamp}.${req.body}`)
.digest("hex");
const ok =
signature &&
expected.length === signature.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
if (!ok) return res.status(400).send("bad signature");
// Acknowledge first, work afterwards: a slow handler reads as a failed
// delivery and gets retried.
res.sendStatus(200);
handle(JSON.parse(req.body));
},
);
Compare with a constant-time function, not ===. Every delivery body is the
same envelope.
{
"id": "whevt_4f1c8a20d3b64e7e9c02",
"type": "interview.completed",
"created_at": 1767229200,
"livemode": true,
"data": {
"object": {
"id": "3b8e1d02-5c77-4c2a-8a41-9b2f7e6d4c10",
"status": 5,
"overall_score": 68,
"candidate_email": "[email protected]",
"job_id": "9c1f0b7e-2f4a-4a51-9a2e-6f0d5b3c1a88"
}
}
}
Webhook payloads carry the raw numeric status, while the REST API returns a
string. "status": 5 here is the same state GET /v1/assessments/{id} calls
"completed". Read the event type rather than mapping the number.
One event, many endpoints
An event has one id. If you have two endpoints, both receive the same event id in separate deliveries. Deduplicate on that id: it is what makes reprocessing safe.
What you will receive
The ones that matter for a hiring integration:
| Event | Fires for | Act on it? |
|---|---|---|
interview.created | ai_interview, coding_test, prompt_engineering | No, you already know |
interview.started | the same three | Optional |
interview.ended | the same three | No, scoring is still running |
interview.completed | the same three | Yes, this is the one |
interview.summary_available | ai_interview | Optional |
interview.transcript_available | ai_interview | Optional |
interview.recording_available | ai_interview | Optional |
interview.whiteboard_scored | ai_interview with a whiteboard | Optional |
interview.rescheduled | scheduled assessments | Optional |
interview.cancelled | any | Yes |
resume.scored | resume_screen only | Yes, this is its completion signal |
A resume_screen has no interviews row, so it never emits an interview.* event.
Its completion signal is resume.scored, and the payload carries submission_id, which
is the assessment id you created. interview_id on that payload repeats the same value,
so match on submission_id when you need to know which kind of object you received.
An endpoint with no explicit subscriptions receives everything except high-volume families, which are opt-in.
When they do not arrive
Six endpoints exist for exactly this, and they are the first thing to reach for.
| Call | Answers |
|---|---|
POST /v1/webhook-endpoints/{id}/test | Does my endpoint accept a real, signed delivery? |
GET /v1/events | Did the event fire at all? |
GET /v1/events/{id} | What exactly did you send? |
GET /v1/webhook-endpoints/{id}/deliveries | What did my server answer, and how slowly? |
POST /v1/events/{id}/redeliver | Send that one again |
POST /v1/webhook-endpoints/{id}/rotate-secret | Replace the signing secret |
Start with the test event. It goes through the same queue, worker, signing and retry path as a real one, so an endpoint that accepts it accepts the rest.
curl -X POST https://hiring-api.experthire.cloud/v1/webhook-endpoints/$ENDPOINT_ID/test \
-H "Authorization: Bearer $EH_SECRET_KEY"
Still a placeholder: $ENDPOINT_ID, $EH_SECRET_KEY. Add them under Your values above.
A redelivery keeps the original event id, so your deduplication still recognises it and its history stays in one place.
Rotating the secret
curl -X POST https://hiring-api.experthire.cloud/v1/webhook-endpoints/$ENDPOINT_ID/rotate-secret \
-H "Authorization: Bearer $EH_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"grace_seconds": 86400}'
Still a placeholder: $ENDPOINT_ID, $EH_SECRET_KEY. Add them under Your values above.
The new secret is returned once. During the grace window, 24 hours by default and 7 days
at most, both secrets sign every delivery and X-Webhook-Signature carries them comma
separated. Split on the comma and accept if either matches, and you can deploy the new
secret without dropping a delivery.
A handler that compares the header to one signature with === breaks the moment you
rotate. Split first. Outside a rotation there is only ever one value, so splitting is
safe to write today.
Retries
A non-2xx is retried with backoff, five attempts in total. Return 2xx as soon as you have stored the event and do the work afterwards: a slow handler that times out looks identical to a failure and gets redelivered.