Webhooks for External Integrations
Receive signed, real-time HTTP notifications when vacancies, applications, candidates and leads change in Jaicob.
Jaicob delivers events to your endpoint as signed HTTPS POST requests. This page covers the delivery contract, signature verification, and every event you can subscribe to.
Create and manage subscriptions in Jaicob under Settings > Integrations > Webhooks. You choose which events each endpoint receives.
Overview
- Protocol: HTTPS POST with a JSON body. Use an
https://endpoint. - Authentication: HMAC-SHA256 signature using your subscription secret.
- Respond with
2xxwithin 15 seconds. We abort the request at 15 seconds and treat it as a failure. - Retries: 8 attempts over roughly 8 minutes, then the event is dropped permanently.
- Duplicate deliveries are expected. Deduplicate on
Idempotency-Key. - Branch on
typeand ignore event types you do not handle, so new events do not break you. - An endpoint only receives the events selected on its subscription. To start receiving an event that was added after you created the subscription, edit the subscription and tick the new event.
- Give us the final URL. If your endpoint redirects, the redirect is followed as a
GETwithout the body, so you receive nothing while we record the delivery as successful.
HTTP request example
POST /your/webhook/endpoint HTTP/1.1
Host: jaicob.ai
User-Agent: Jaicob/1.0
Content-Type: application/json
Jaicob-Signature: sha256=4cc1c16a... (hex)
X-Webhook-Event: application.status.changed
X-Webhook-Timestamp: 1759830151
Idempotency-Key: b8a3c9a6-3e6c-44a6-b077-6a7c2f0b0c9c{
"id": "b8a3c9a6-3e6c-44a6-b077-6a7c2f0b0c9c",
"type": "application.status.changed",
"occurredAt": "2025-10-07T09:42:31.000Z",
"data": {
"id": "f1bf5b1f-0d86-4f2a-86e7-5c0f2a2f2de1",
"status": "SCREENED",
"changedAt": "2025-10-07T09:42:30.412Z"
}
}Payload envelope
Every delivery shares the same top-level envelope; only data differs per event type.
| Field | Description |
|---|---|
id | Event identifier (string, UUID). Stable across retries and identical to Idempotency-Key. It identifies the event, not one delivery: if several subscriptions listen to the same event, each receives the same id. |
type | The event type. See the event catalog below. |
occurredAt | ISO 8601 timestamp of when we sent this attempt, not when the event happened. It is recalculated on every retry, so the same id can arrive with different occurredAt values. For when the change happened, use data.changedAt. |
data | Event-specific payload (object). |
Headers
| Header | Description |
|---|---|
X-Webhook-Event | The event type for this delivery, for example application.status.changed. |
X-Webhook-Timestamp | Unix epoch seconds when we created the signature. Use it to enforce your own replay window. |
Jaicob-Signature | HMAC-SHA256 of "<timestamp>.<raw-body>", hex-encoded, prefixed with sha256=. |
Idempotency-Key | Same value as the envelope id. One key per event, not per attempt and not per subscription: two subscriptions receiving the same event share this key, so scope your dedupe store per subscription if both point at one handler. |
Content-Type | Always application/json. |
User-Agent | Always Jaicob/1.0. |
Delivery and retries
Each POST is aborted after 15 seconds. A delivery is retried when it times out, when the connection fails, on any 5xx, and on 408 or 429. Every other 4xx (including 400, 401, 403, 404 and 410) is treated as permanent: we do not retry, and the event is dropped for that endpoint.
Retries run at 2s, 6s, 14s, 30s, 62s, 126s and 254s after the first attempt: 8 attempts across roughly 8 minutes and 15 seconds. After that the event is discarded and cannot be redelivered, so an endpoint that is down for longer loses events permanently. There is no replay endpoint.
Retries apply to the whole fan-out, not to one endpoint. If your company has several subscriptions and any one of them fails transiently, every matching endpoint is sent the event again, including endpoints that already returned 2xx. Deduplicating on Idempotency-Key is therefore required, not optional.
Rotating a subscription secret takes effect immediately and there is no grace period. Deliveries already queued or waiting to retry are signed with the new secret, so switch your verifying secret over at the moment you rotate.
Removed fields
Before an event is stored we remove a fixed set of internal keys from the payload, at every nesting depth. These keys are never present in any delivery, even when the REST API returns them for the same entity:
embedding, company, jobBoards, comments, emails, applications, emailSettings, memory, notifications, role, settings, workflowExecution, profile, contacts, matchingBalance
Two consequences worth planning for:
- A vacancy payload never carries
jobBoards, so you cannot tell fromvacancy.createdorvacancy.updatedwhether a vacancy is published anywhere. Subscribe to thevacancy.published.*andvacancy.unpublished.*events for that. - A lead payload never carries
profile, so enrichment data such as location, headline, seniority and skills is not delivered even though the API returns it.
Personal dataPayloads contain personal data beyond the candidate. The owning recruiter is included as a nested
userobject with their email and phone number, and candidate attachments include the full extracted text of the CV. Take this into account in your own record of processing and your retention policy.
Verify the signature
- Read
X-Webhook-TimestampandJaicob-Signature. - Compute
HMAC_SHA256(secret, "<timestamp>.<raw-body>")over the raw request body, hex-encoded. - Compare with the signature (after
sha256=) using a timing-safe compare. - Reject requests whose
X-Webhook-Timestampis older than your chosen tolerance. Five minutes is a reasonable default. This window is enforced entirely by you: we do not reject anything on your behalf.
The signature covers the exact bytes we sent. Re-serializing a parsed body is not guaranteed to reproduce them, so read the raw body.
Node.js (Next.js API route)
import crypto from 'node:crypto';
import type { NextApiRequest, NextApiResponse } from 'next';
export const config = { api: { bodyParser: false } };
const WEBHOOK_TOLERANCE_SECONDS = 300;
async function readRawBody(req: NextApiRequest): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
return Buffer.concat(chunks).toString('utf8');
}
function timingSafeEqual(a: string, b: string) {
const A = Buffer.from(a, 'hex');
const B = Buffer.from(b, 'hex');
return A.length === B.length && crypto.timingSafeEqual(A, B);
}
function verifySignature(bodyRaw: string, timestamp: string, header: string, secret: string) {
const [algo, sig] = header.split('=');
if (algo !== 'sha256' || !sig) return false;
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (age > WEBHOOK_TOLERANCE_SECONDS || age < -WEBHOOK_TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${bodyRaw}`).digest('hex');
return timingSafeEqual(sig, expected);
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end();
const rawBody = await readRawBody(req);
const timestamp = req.headers['x-webhook-timestamp'] as string;
const signature = req.headers['jaicob-signature'] as string;
if (!timestamp || !signature || !verifySignature(rawBody, timestamp, signature, process.env.WEBHOOK_SECRET!)) {
return res.status(400).json({ error: 'Invalid signature' });
}
const idempotencyKey = req.headers['idempotency-key'] as string;
// Skip if you already processed this key, then return 200.
const event = JSON.parse(rawBody);
switch (event.type) {
case 'application.created':
case 'application.status.changed':
break;
case 'applicant.created':
case 'applicant.updated':
case 'applicant.deleted':
case 'applicant.status.changed':
break;
case 'lead.created':
case 'lead.updated':
case 'lead.deleted':
case 'lead.converted':
break;
case 'vacancy.created':
case 'vacancy.updated':
case 'vacancy.deleted':
case 'vacancy.status.changed':
break;
default:
// Publish/unpublish is one event per channel, so match on the prefix rather than
// listing every combination. event.data.channel tells you which channel it was.
if (event.type.startsWith('vacancy.published.') || event.type.startsWith('vacancy.unpublished.')) {
break;
}
// Unknown or new event type: acknowledge and ignore so future additions do not break you.
break;
}
return res.status(200).json({ ok: true });
}Python
import hmac, hashlib, time
TOLERANCE = 300
def verify_signature(raw_body: bytes, timestamp: str, header: str, secret: str) -> bool:
try:
algo, sig = header.split('=')
except ValueError:
return False
if algo != 'sha256':
return False
age = int(time.time()) - int(timestamp)
if abs(age) > TOLERANCE:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(sig, expected)Event catalog
Create and update events carry the entity object, minus the removed fields listed above. Status-change, delete and publish/unpublish events carry a small payload of just the fields listed on each entry.
Vacancy
vacancy.created
vacancy.createdA new vacancy was created. If the vacancy is published to job boards on creation you will also receive vacancy.published.<channel> and a further vacancy.updated within the same second.
data(object): the vacancy object, minus the removed fields.data.description(string): rich-text HTML, not plain text.data.user(object): the owning recruiter, including their email and phone number.data.applicationsCount,data.leadToVacanciesCount(number): always0in webhook payloads. Read the API if you need real counts.
{
"id": "b8a3c9a6-3e6c-44a6-b077-6a7c2f0b0c9c",
"type": "vacancy.created",
"occurredAt": "2025-10-07T09:42:31.000Z",
"data": {
"id": "f1bf5b1f-0d86-4f2a-86e7-5c0f2a2f2de1",
"title": "Software Developer",
"description": "<p>We are looking for a new software developer...</p>",
"status": "OPEN",
"reference": "VAC-1042"
}
}vacancy.updated
vacancy.updatedA vacancy changed. This fires for ordinary edits and every time a vacancy is published to or removed from a job board, so it can arrive without any field you care about having changed. Nothing in the payload tells you which of the two happened: use the publish/unpublish events for that.
data(object): the vacancy object, minus the removed fields.data.description(string): rich-text HTML.data.jobBoards: never included. Subscribe tovacancy.published.<channel>/vacancy.unpublished.<channel>instead.
vacancy.deleted
vacancy.deletedA vacancy was permanently deleted. This is a hard delete. It is emitted just before the row is removed, so the vacancy may still be readable through the API for a moment after you receive it. Vacancies removed indirectly, by deleting the owning user or company, do not emit this event.
data.id(string, UUID): the deleted vacancy ID.
vacancy.status.changed
vacancy.status.changedA vacancy moved to a new status. Statuses are configurable per company: the defaults are DRAFT, OPEN, ON_HOLD, FILLED, CLOSED and CANCELLED, but a company can add its own, so treat this as free text rather than a fixed set. A status change does not emit vacancy.updated.
data.id(string, UUID): the vacancy ID.data.status(string): the new status, uppercased with spaces as underscores.data.changedAt(ISO 8601): the vacancy's last-modified timestamp.
{
"id": "b8a3c9a6-3e6c-44a6-b077-6a7c2f0b0c9c",
"type": "vacancy.status.changed",
"occurredAt": "2025-10-07T09:42:31.000Z",
"data": {
"id": "f1bf5b1f-0d86-4f2a-86e7-5c0f2a2f2de1",
"status": "FILLED",
"changedAt": "2025-10-07T09:42:30.412Z"
}
}Publishing events
Publishing is split per channel so you can subscribe to a single destination. Ten event types:
| Published | Unpublished | Channel |
|---|---|---|
vacancy.published.career | vacancy.unpublished.career | The careers page |
vacancy.published.api | vacancy.unpublished.api | The public vacancies API |
vacancy.published.google-jobs | vacancy.unpublished.google-jobs | Google Jobs |
vacancy.published.indeed | vacancy.unpublished.indeed | Indeed |
vacancy.published.werkzoeken | vacancy.unpublished.werkzoeken | Werkzoeken |
All ten carry the same payload:
data.id(string, UUID): the vacancy ID.data.channel(string): the channel, matching the event name.data.changedAt(ISO 8601): when we recorded the change. For externally hosted boards this is when our side completed the change, which can lag the board itself.
A vacancy.updated event is emitted alongside every publish and unpublish.
Deleting a vacancy that is live on Indeed or Werkzoeken emits the matching vacancy.unpublished event followed by vacancy.deleted. Deleting a vacancy does not emit an unpublished event for career, api or google-jobs: for those, treat vacancy.deleted as removal from the channel.
{
"id": "b8a3c9a6-3e6c-44a6-b077-6a7c2f0b0c9c",
"type": "vacancy.published.indeed",
"occurredAt": "2025-10-07T09:42:31.000Z",
"data": {
"id": "f1bf5b1f-0d86-4f2a-86e7-5c0f2a2f2de1",
"channel": "indeed",
"changedAt": "2025-10-07T09:42:30.412Z"
}
}Application
application.created
application.createdA candidate applied to a vacancy.
data(object): the application object, minus the removed fields.data.applicant(object): the complete candidate object, not an ID stub. Includes contact details, work history and attachments.data.vacancy(object): the complete vacancy object, not an ID stub.data.stage(string): configurable per company. Defaults:APPLIED,SCREENED,EVALUATED,OFFERED,ACCEPTED.data.status(string): a free-text next action generated by AI, for example"Send resume to hiring manager". Not an enum: do not switch on it.
application.status.changed
application.status.changedAn application moved to a new stage. Despite the event name, data.status carries the application's stage (APPLIED, SCREENED, and so on), not its status field. Changes to the AI-generated status field emit nothing. Once an application has been rejected this event stops firing for it entirely.
data.id(string, UUID): the application ID.data.status(string): the new stage.data.changedAt(ISO 8601): the application's last-modified timestamp.
Candidate
applicant.created
applicant.createdA new candidate was created.
data(object): the candidate object, minus the removed fields.data.attachments[].content(string): the full extracted text of the CV. This is the largest and most sensitive field in the delivery.data.status(string): configurable per company. Defaults:AVAILABLE,BLACKLISTED,NOT_LOOKING,SHORTLISTED,UNAVAILABLE.data.applications: never included. Useapplication.createdto learn which vacancies a candidate is on.
applicant.updated
applicant.updatedA candidate was updated. The whole candidate is delivered with no indication of what changed, so diff against your own copy. Editing a candidate through the normal edit flow emits this event even when the status changes, rather than applicant.status.changed.
applicant.deleted
applicant.deletedA candidate was deleted (erasure).
data.id(string, UUID): the deleted candidate ID.
applicant.status.changed
applicant.status.changedA candidate moved to a new status through the dedicated status action. The status is normalized (uppercased, spaces replaced with underscores). This is not the only way a status changes: editing a candidate through the normal edit flow emits applicant.updated instead, so subscribe to both if you track candidate status.
data.id(string, UUID): the candidate ID.data.status(string): the new, normalized status.data.changedAt(ISO 8601): the candidate's last-modified timestamp.
Lead
lead.created
lead.createdA brand new lead was created. Re-sourcing somebody who already exists in the company merges into the existing lead and emits nothing, so absence of this event does not mean the person was not sourced.
data(object): the lead object, minus the removed fields.data.profile: never included. Enrichment data (location, headline, seniority, industry, skills, languages) is not delivered even though the API returns it.data.user(object): the owning recruiter, including their email and phone number.data.matches(array): AI match scores and the full nested vacancy for each match.
lead.updated
lead.updatedA lead was edited through the lead update action. Many other changes to a lead emit nothing: adding or removing tags, removing an attachment, linking the lead to a vacancy, marking it seen, converting it, and the sourcing merge described under lead.created.
lead.deleted
lead.deletedA lead was deleted. Fired for soft-deletes (the standard delete), hard-deletes (erasure), and opt-outs through the public opt-out endpoint. The payload does not distinguish them, so treat it as "stop processing this lead". Hard-deleting a lead that was already soft-deleted emits nothing.
data.id(string, UUID): the deleted lead ID.
lead.converted
lead.convertedA lead was explicitly converted into a candidate, with applicant.created emitted for the new candidate. When a lead instead applies through the public careers flow you receive applicant.created and lead.deleted, not this event.
data.id(string, UUID): the source lead ID.data.applicantId(string, UUID): the ID of the candidate created from the lead.
Testing locally
curl -X POST https://example.com/your/webhook/endpoint \
-H 'Content-Type: application/json' \
-H 'User-Agent: Jaicob/1.0' \
-H 'Jaicob-Signature: sha256=4cc1c16a...' \
-H 'X-Webhook-Event: application.status.changed' \
-H 'X-Webhook-Timestamp: 1759830151' \
-H 'Idempotency-Key: b8a3c9a6-3e6c-44a6-b077-6a7c2f0b0c9c' \
-d '{ "id": "b8a3c9a6-3e6c-44a6-b077-6a7c2f0b0c9c", "type": "application.status.changed", "occurredAt": "2025-10-07T09:42:31.000Z", "data": { "id": "f1bf5b1f-0d86-4f2a-86e7-5c0f2a2f2de1", "status": "SCREENED", "changedAt": "2025-10-07T09:42:30.412Z" } }'Replace the headers and payload values with real ones from your subscription.
Best practices
- Respond with
200as soon as you enqueue processing and do heavy work asynchronously. You have 15 seconds. - Use the
Idempotency-Keyto discard duplicate deliveries. - Validate
Content-Typeand the required headers before parsing. - Branch on
typeand ignore unknown event types so future additions do not break your integration. - Log delivery metadata (status, headers), never secrets.
- Rotate your webhook secret periodically, and switch the verifying secret over at the same moment: rotation takes effect immediately with no grace period.
- Do not treat a
4xxas a way to ask us to retry later. Only408and429are retried; other4xxresponses drop the event.
Updated about 1 month ago
