Recruit100x API v1
Webhooks
Every event Recruit100x can send, its schema and sample, how deliveries are signed and retried, and how to test them.
Subscribe with POST /api/v1/hooks (scope manage:hooks) or in Settings › Integrations › Webhooks. Choose the payload version per endpoint with api_version:
2026-09-01— the original payloads, frozen: fields are only ever added.2026-11-01— adds your own ids (external_ref), the invite, band counts,livemode, and therole.*events.
Events
| Event | When | Scope | Since |
|---|---|---|---|
invite.sent | An interview invitation was sent (or, with return_link, created for you to deliver). | read:roles | 2026-09-01 |
invite.opened | The candidate opened their invitation link for the first time. | read:roles | 2026-09-01 |
screen.completed | The candidate finished the interview; the report is being written. | read:reports | 2026-09-01 |
report.ready | The report is ready — the same payload as GET /api/v1/reports/{reportId}. | read:reports | 2026-09-01 |
report.failed | The report is delayed; we keep trying and send report.ready when it is done. | read:reports | 2026-09-01 |
decision.recorded | A person on your team recorded a decision (advance, reject or hold). | read:reports | 2026-09-01 |
client_feedback.received | Your client left feedback on a shared shortlist. | read:reports | 2026-09-01 |
candidate.sms_opted_out | The candidate opted out of text messages. | read:roles | 2026-09-01 |
shortlist.created | A shortlist link was created for your client. | read:reports | 2026-09-01 |
candidate.handoff | An advanced candidate was sent on to your ATS. | read:reports | 2026-09-01 |
role.ready | The role's interview guide was approved: you can invite candidates. | read:roles | 2026-11-01 |
role.closed | The role was closed. | read:roles | 2026-11-01 |
role.guide_failed | The role's interview guide could not be drafted. | read:roles | 2026-11-01 |
Each event's JSON Schema and a sample are published: webhooks.2026-11-01.schema.json, webhooks.2026-09-01.schema.json, and /developers/samples/{event}.json. There is no per-candidate candidate-care event; use the monthly stats (GET /api/v1/stats/candidate-care).
Verifying signatures
Each delivery is signed per Standard Webhooks with your endpoint's secret: headers webhook-id, webhook-timestamp and webhook-signature, plus x-recruit100x-event. Verify the raw body before you trust it:
Node
const crypto = require("node:crypto");
// secret: the whsec_… value shown once when you created the endpoint.
// headers: the request headers (lower-case names). rawBody: the body exactly as received.
function verifyRecruit100xWebhook(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"] || "";
if (!id || !timestamp) return false;
// Refuse messages older or newer than 5 minutes (replay protection).
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = "v1," + crypto
.createHmac("sha256", key)
.update(id + "." + timestamp + "." + rawBody)
.digest("base64");
// During a secret rotation the header carries two values; either may match.
return signatures.split(" ").some((candidate) =>
candidate.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(expected)));
}
module.exports = { verifyRecruit100xWebhook };Python
import base64, hashlib, hmac, time
# secret: the whsec_... value shown once when you created the endpoint.
# headers: a dict of request headers (lower-case names). raw_body: the body bytes as received.
def verify_recruit100x_webhook(secret, headers, raw_body):
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature", "")
if not msg_id or not timestamp:
return False
# Refuse messages older or newer than 5 minutes (replay protection).
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
# During a secret rotation the header carries two values; either may match.
return any(hmac.compare_digest(candidate, expected) for candidate in signatures.split(" "))PHP
<?php
// $secret: the whsec_… value shown once when you created the endpoint.
// $headers: request headers with lower-case names. $rawBody: the body exactly as received.
function verifyRecruit100xWebhook(string $secret, array $headers, string $rawBody): bool
{
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signatures = $headers['webhook-signature'] ?? '';
if ($id === '' || $timestamp === '') {
return false;
}
// Refuse messages older or newer than 5 minutes (replay protection).
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$key = base64_decode(preg_replace('/^whsec_/', '', $secret));
$expected = 'v1,' . base64_encode(hash_hmac('sha256', $id . '.' . $timestamp . '.' . $rawBody, $key, true));
// During a secret rotation the header carries two values; either may match.
foreach (explode(' ', $signatures) as $candidate) {
if (hash_equals($expected, $candidate)) {
return true;
}
}
return false;
}Retries and replay
A delivery that doesn't get a 2xx answer is retried after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours. Use webhook-id to ignore a delivery you already handled. Failed deliveries can be replayed from Settings › Integrations.
Test events
POST /api/v1/hooks/{hookId}/test sends a signed test.ping to one endpoint now. In test mode the test page sends the interview events to your test endpoints.