Developer docs

Webhooks

Signed HTTP callbacks for hiring events, built on the Standard Webhooks specification.

Short version. Add an endpoint in Settings, tick the events you want, and store the signing secret shown when you create it. We POST a JSON envelope of the form { type, timestamp, data } with three signature headers. Verify the signature over the raw request body, reject anything older than five minutes, return a 2xx within ten seconds, and do the slow work afterwards.

1. The six events

Each endpoint subscribes to one or more of the events below. The value in the type field is exactly the name shown here and exactly the name you tick in Settings, so you can route on it without translation.

EventFires whendata contains
candidate_createdA person is added to the talent pool, either by a recruiter or by applying through a job board form.candidate
candidate_updatedReserved. Not emitted yet, so subscribing to it today receives nothing.candidate
application_createdA candidate is attached to a specific job.application, candidate, job
application_status_changedThe pipeline status moves, whether a recruiter moved it or the platform advanced it after an interview.application (with previousStatus), candidate, job
interview_scheduledAn interview or screening link is created for an application.interview, application, candidate, job
interview_completedThe AI analysis of a finished interview has landed. This is the first moment a score exists, which is why the event fires here rather than the second the call ends.interview, application, candidate, job, score

One action can produce more than one event. Finishing an AI screening emits interview_completed and, because the pipeline moved, application_status_changed. Subscribe only to what you act on.

2. Payload envelope

Every delivery has the same three top level keys. type is the event name, timestamp is when the event happened in ISO 8601, and data holds the event specific body. Everything inside data carries organizationId, so a single endpoint can safely serve several InstaCruit accounts.

Example delivery
{
  "type": "interview_completed",
  "timestamp": "2026-08-12T09:41:07.512Z",
  "data": {
    "organizationId": "8f2b1c44-...",
    "interview": {
      "id": "b31f...",
      "type": "ai",
      "stage": "screening",
      "status": "completed",
      "scheduledAt": "2026-08-12T09:15:00.000Z",
      "completedAt": "2026-08-12T09:41:07.498Z"
    },
    "application": {
      "id": "6d0a...",
      "status": "interview_completed",
      "knockoutFlagged": false
    },
    "candidate": {
      "id": "44c8...",
      "name": "Jane Okafor",
      "email": "[email protected]",
      "phone": null
    },
    "job": { "id": "1a77...", "title": "Senior Backend Engineer" },
    "score": 82
  }
}

Treat the object as additive. We may add fields to data over time, and a consumer that rejects unknown keys will break for no good reason. Existing field names and their meanings do not change.

3. Signature headers

Three headers carry the signature, plus one convenience header for routing.

HeaderSignedWhat it is
webhook-idSignedA unique id for this delivery. It is stable across retries of the same event, which makes it the right key for your idempotency check.
webhook-timestampSignedUnix seconds at the moment the request was signed.
webhook-signatureSignedOne or more space separated signatures, each prefixed with a version. Today we send v1, followed by base64.
X-Instacruit-EventNot signedThe event name again, so you can route before parsing the body. It is not covered by the signature, so never trust it for anything but routing.
Request headers
POST /your/endpoint HTTP/1.1
Content-Type: application/json
webhook-id: 9c1e7a52-4d3b-4f0c-9c2e-6a1b0f7d3e88
webhook-timestamp: 1786520467
webhook-signature: v1,K9v2Yr8mQ0pS7dL1xU4hT6nB3zC5wA8eF2gH1jK0lM=
X-Instacruit-Event: interview_completed

4. Verifying a signature

The signed content is the delivery id, the timestamp and the raw request body joined by full stops: `${id}.${timestamp}.${body}`. Compute HMAC SHA-256 over that string using your signing secret as the key, base64 encode the result, prefix it with v1, and compare it to the header in constant time.

Use the secret exactly as it was shown to you, including the whsec_ prefix. It is the raw HMAC key. Do not strip the prefix and do not base64 decode it first.

Node.js
const crypto = require('crypto');

// The signing secret exactly as it was shown to you, whsec_ prefix included.
const SECRET = process.env.INSTACRUIT_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 5 * 60;

function verify(headers, rawBody) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const received = headers['webhook-signature'];
  if (!id || !timestamp || !received) return false;

  // Check the replay window before you check the signature.
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

  const signedContent = id + '.' + timestamp + '.' + rawBody;
  const mac = crypto.createHmac('sha256', SECRET).update(signedContent).digest('base64');
  const expected = 'v1,' + mac;

  // The header may carry several space separated versions during a rotation.
  return received.split(' ').some((candidate) => {
    const a = Buffer.from(candidate.trim());
    const b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
}

The single most common cause of a verification failure is signing a re-serialised body. Frameworks that parse JSON for you will reorder keys and change whitespace, and the signature is over the exact bytes we sent. Capture the raw body.

Express
// Express: capture the raw bytes, do not verify against re-serialised JSON.
app.post(
  '/instacruit/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody = req.body.toString('utf8');
    if (!verify(req.headers, rawBody)) return res.sendStatus(401);

    const event = JSON.parse(rawBody);
    // Acknowledge first, then do the slow work out of band.
    res.sendStatus(200);
    queue.add(event);
  }
);

Compare with a constant time function such as crypto.timingSafeEqual, and check the lengths first. Passing buffers of different lengths to it raises an error rather than reporting a mismatch.

5. Replay window

The timestamp is inside the signed content on purpose. An attacker who captures a valid request cannot change the timestamp without invalidating the signature, so a stale request stays stale. That only protects you if you actually check it.

Reject any delivery whose webhook-timestamp is more than five minutes away from your own clock, in either direction, and do that check before you spend time on the HMAC. Five minutes is the window our own verifier uses. If your server clock drifts, the window is what absorbs it, so keep NTP running rather than widening the tolerance.

Store webhook-id for at least the length of your retry exposure and ignore ids you have already processed. Retries reuse the id, which is what makes this work.

6. Retries and failures

Deliveries are queued rather than sent inside the request that caused the event, so a slow endpoint never slows down the product and a retry survives a deploy.

  • Retried. Connection errors, timeouts, 429 and any 5xx. Up to three retries with exponential backoff.
  • Not retried. Any other 4xx. A rejected payload will be rejected identically on the next attempt, so retrying only delays every other delivery in the queue. Return 429 if you want us to back off instead.
  • Timeout. Ten seconds. A delivery that has not been acknowledged by then is treated as a failure and retried.
  • Recovery. A recurring sweep re-queues any delivery that was recorded but never handed to a worker, so a queue outage delays events rather than losing them.
  • Abandoned. After five failed attempts a delivery is marked permanently failed and is not tried again. Disabling an endpoint stops delivery immediately.

Retries mean you will occasionally see the same event twice. Deduplicate on webhook-id and treat every handler as replayable.

7. Responding correctly

  • Verify the signature before you read the body for anything other than verification.
  • Return a 2xx as soon as you have persisted the event. Do the real work asynchronously.
  • Return 401 on a signature failure so the problem shows up in your delivery log.
  • Serve the endpoint over HTTPS. Plain HTTP is accepted for local testing only.

The Test button in Settings sends a signed sample delivery to your endpoint immediately. It is signed the same way as a real event, so it is a genuine end to end check of your verification code. Test deliveries are not recorded in your delivery history.

8. What we never send

A webhook payload leaves our systems and lands on infrastructure we do not control, so it carries the minimum needed to act on the event.

  • No resume files and no text extracted from a resume
  • No interview transcripts and no recording URLs
  • No AI written summaries, strengths, weaknesses or knockout reasoning
  • No interview access links, because those tokens let the bearer take the interview
  • No candidate identifiers beyond name, email address and phone number

Scores and statuses are included because they are the point of the integration. Everything else stays in the dashboard, where access is controlled. If you need the written assessment in another system, fetch it deliberately rather than having it pushed to an endpoint.

Remember that these payloads contain personal data about job applicants. Wherever you send them next is your own processing decision under your agreement with the candidate. See our privacy policy for how we handle it on our side.

Something not covered here? Write to [email protected] and include a webhook-id if you are chasing a specific delivery.