Set up an endpoint
- Expose a public https URL that accepts
POSTwith a JSON body. - Register it in Organisation console → Developers → Webhooks, or with
POST /api/v1/webhooks(scopewebhooks:manage). Choose the events you want; the default is all of them. - Store the
whsec_…signing secret you get back. It is encrypted at rest and shown only once. - Click Send test (or
POST /api/v1/webhooks/{id}/test). A signedpingevent arrives within a minute.
.local/.internal hosts and redirects are refused.What we send
| Header | Value |
|---|---|
OC-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
OC-Event | Event type, e.g. application.stage_changed |
OC-Delivery | Delivery ID (the same across retries of one delivery) |
OC-Attempt | 1 for the first try, then 2, 3, … |
User-Agent | One-Career-Webhooks/1.0 |
{
"id": "9d0e4b7a-1c2f-4e5d-8a6b-7c9d0e1f2a3b",
"type": "application.stage_changed",
"created_at": "2026-10-24T10:15:02.123Z",
"data": {
"application_id": "3f6c2a9e-8b1d-4c7a-9e2f-5d4b1a0c7e91",
"posting_id": "3f6c2a9e-8b1d-4c7a-9e2f-5d4b1a0c7e91",
"from": "screening",
"to": "interview"
}
}Payloads carry IDs and changed fields only, never names, emails, phone numbers or documents. Use event.id as an idempotency key and fetch details with your API key when you need them.
Verify the signature
Compute HMAC-SHA256 over <t>.<raw body> with your secret and compare it in constant time with v1. Reject events whose t is more than 5 minutes from your clock, to stop replays. Always use the raw bytes: re-serialising parsed JSON changes them.
import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@one-career/sdk/webhooks";
const app = express();
// keep the RAW body: the signature covers the exact bytes we sent
app.post("/oc-webhooks", express.raw({ type: "application/json" }), async (req, res) => {
try {
const event = await verifyWebhook({
payload: req.body,
header: req.get("OC-Signature"),
secret: process.env.OC_WEBHOOK_SECRET!, // whsec_…
});
if (await seen(event.id)) return res.sendStatus(200); // retries can repeat an event
await queue.add(event); // do the work later, answer fast
res.sendStatus(200);
} catch (e) {
res.sendStatus(e instanceof WebhookVerificationError ? 400 : 500);
}
});// Any language: compute HMAC-SHA256 over "<t>.<raw body>" with your secret and compare in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(raw: Buffer, header: string, secret: string, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=") as [string, string]));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // replay window
const expected = createHmac("sha256", secret).update(`${t}.`).update(raw).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}import hmac, hashlib, time
def verify(raw: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts.get("t", "0"))
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Retries and failures
- Answer with any 2xx within 10 seconds. Queue slow work and do it later.
- Anything else (non-2xx, timeout, network error, redirect) is retried after 1, 5, 30, 120, 360, 720 and 1440 minutes: 8 attempts over about a day. After that the delivery is marked dead.
- If an endpoint keeps failing for 3 days it is paused and your admins get a notification. Fix it and switch it back on in the console.
- Order isn't guaranteed and duplicates are possible. Use
created_atandid. - The console shows the last 25 deliveries with status code and error, so you can see what happened.
Events
application.created
A candidate applies (or is sourced) to one of your postings.
| data field | type |
|---|---|
application_id | uuid |
posting_id | uuid |
stage | applied | sourced |
match_score | 0–100 or null |
application.stage_changed
An application moves to another stage: by your team, through the API, or by the candidate (withdraw, accept, decline).
| data field | type |
|---|---|
application_id | uuid |
posting_id | uuid |
from | stage |
to | stage |
offer.accepted
The candidate accepts the offer (stage → accepted). Sent along with application.stage_changed.
| data field | type |
|---|---|
application_id | uuid |
posting_id | uuid |
offer.declined
The candidate declines the offer (stage → declined).
| data field | type |
|---|---|
application_id | uuid |
posting_id | uuid |
assessment.completed
An assessment attempt is graded.
| data field | type |
|---|---|
invite_id | uuid |
campaign_id | uuid |
application_id | uuid or null |
score_pct | number |
percentile | number or null |
integrity_band | proctoring integrity band or null |
interview.scheduled
An interview round is scheduled or rescheduled.
| data field | type |
|---|---|
round_id | uuid |
application_id | uuid |
type | round type |
scheduled_at | ISO 8601 |
duration_min | minutes |
meeting_id | uuid or null |
posting.opened
A posting goes live (after review, or when resumed).
| data field | type |
|---|---|
posting_id | uuid |
title | string |
status | open |
posting.closed
A posting is closed.
| data field | type |
|---|---|
posting_id | uuid |
title | string |
status | closed |
offer.approval_requested
An offer is proposed and needs internal approval (Offer approvals).
| data field | type |
|---|---|
application_id | uuid |
posting_id | uuid |
version | offer version |
currency | ISO 4217 |
ctc_total | number |
policy | any | all | sequential |
approvers | number of approvers |
proposed_at | ISO 8601 |
offer.approved
The approval chain is complete. The offer can now be sent for up to approved_ctc.
| data field | type |
|---|---|
application_id | uuid |
posting_id | uuid |
version | offer version |
approved_ctc | number |
currency | ISO 4217 |
approved_at | ISO 8601 |
offer.changes_requested
An approver sends the offer back for changes.
| data field | type |
|---|---|
application_id | uuid |
posting_id | uuid |
version | offer version |
returned_at | ISO 8601 |
offer.sent
The offer letter is sent to the candidate (including a revised version).
| data field | type |
|---|---|
application_id | uuid |
posting_id | uuid |
version | offer version |
sent_at | ISO 8601 |
respond_by | YYYY-MM-DD or null |
sign_mode | esign | upload | click |
sign_by | YYYY-MM-DD or null |
offer.signed
The candidate signs: the offer letter itself (e-sign) or an acceptance letter.
| data field | type |
|---|---|
application_id | uuid |
posting_id | uuid |
version | offer version |
signed_at | ISO 8601 |
kind | offer_letter | acceptance_letter |
reference | OC-SIGN-… |
sha256 | SHA-256 of the signed file |
vault.document_added
A copy lands in your organisation Vault (letter as sent, signed letter, acceptance letter).
| data field | type |
|---|---|
document_id | uuid |
kind | offer_letter | signed_offer | acceptance_letter |
application_id | uuid |
offer_version | number |
sha256 | SHA-256 |
title | string |