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.
| Event | Fires when | data contains |
|---|---|---|
candidate_created | A person is added to the talent pool, either by a recruiter or by applying through a job board form. | candidate |
candidate_updated | Reserved. Not emitted yet, so subscribing to it today receives nothing. | candidate |
application_created | A candidate is attached to a specific job. | application, candidate, job |
application_status_changed | The pipeline status moves, whether a recruiter moved it or the platform advanced it after an interview. | application (with previousStatus), candidate, job |
interview_scheduled | An interview or screening link is created for an application. | interview, application, candidate, job |
interview_completed | The 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.
{
"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.
| Header | Signed | What it is |
|---|---|---|
webhook-id | Signed | A 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-timestamp | Signed | Unix seconds at the moment the request was signed. |
webhook-signature | Signed | One or more space separated signatures, each prefixed with a version. Today we send v1, followed by base64. |
X-Instacruit-Event | Not signed | The 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. |
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_completed4. 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.
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: 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,
429and any5xx. 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. Return429if 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.