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. Test and live endpoints are separate registrations: a test assessment never reaches a live endpoint.

curl -X POST https://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 test 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:

  • interview.created
  • interview.completed — the one to act on
  • interview.transcript_available
  • interview.recording_available
  • interview.cancelled
  • resume.scored

An endpoint with no explicit subscriptions receives everything except high-volume families, which are opt-in.

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