Skip to main content

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.

201The secret is shown once and never again
{
  "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.

HeaderContents
X-Webhook-IdThe event id, whevt_...
X-Webhook-TimestampUnix seconds when the signature was computed
X-Webhook-SignatureHMAC-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.

200What we POST to your endpoint
{
  "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:

EventFires forAct on it?
interview.createdai_interview, coding_test, prompt_engineeringNo, you already know
interview.startedthe same threeOptional
interview.endedthe same threeNo, scoring is still running
interview.completedthe same threeYes, this is the one
interview.summary_availableai_interviewOptional
interview.transcript_availableai_interviewOptional
interview.recording_availableai_interviewOptional
interview.whiteboard_scoredai_interview with a whiteboardOptional
interview.rescheduledscheduled assessmentsOptional
interview.cancelledanyYes
resume.scoredresume_screen onlyYes, 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.

CallAnswers
POST /v1/webhook-endpoints/{id}/testDoes my endpoint accept a real, signed delivery?
GET /v1/eventsDid the event fire at all?
GET /v1/events/{id}What exactly did you send?
GET /v1/webhook-endpoints/{id}/deliveriesWhat did my server answer, and how slowly?
POST /v1/events/{id}/redeliverSend that one again
POST /v1/webhook-endpoints/{id}/rotate-secretReplace 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.

Next