Outbound Webhooks
Webhooks push recruiting events to your endpoint as they happen, so you don’t have to poll. When a candidate, job, application, or placement changes, Vitae.ai POSTs a signed JSON payload to every active subscription in the organization that listens for that event type.
Delivery is durable: each event is persisted before it is sent, retried with exponential backoff on failure, and moved to a dead-letter state you can inspect if it never succeeds.
Subscribing
Webhook subscriptions are managed from the dashboard, or by API-key-authenticated automation apps such as Zapier and Make.
Dashboard management endpoints are authenticated by a logged-in organization user (a session bearer token), not by an API key:
POST /v1/webhook-subscriptions Create a subscription (secret returned once)
GET /v1/webhook-subscriptions List subscriptions
GET /v1/webhook-subscriptions/:id Fetch one subscription
PATCH /v1/webhook-subscriptions/:id Update url / events / active
POST /v1/webhook-subscriptions/:id/rotate-secret Rotate signing secret (returned once)
GET /v1/webhook-subscriptions/:id/deliveries Recent delivery attempts
DELETE /v1/webhook-subscriptions/:id Deactivate (soft; preserves history)A subscription has an HTTPS url, a set of eventTypes to deliver, an optional
description, and an isActive flag. The destination URL is validated for SSRF
safety before it is saved — it must be a public host, not a private, loopback, or
link-local address.
curl -X POST https://api.vitae.ai/v1/webhook-subscriptions \
-H "Authorization: Bearer <sessionToken>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/vitae",
"eventTypes": ["candidate_created", "application_updated"]
}'The response includes a signing secret (whsec_...) exactly once. Store it
securely — it is never retrievable again, only rotatable.
Automation platforms can manage the same lifecycle with a scoped API key:
POST /v1/public-api/webhook-subscriptions webhooks_write
GET /v1/public-api/webhook-subscriptions webhooks_read
GET /v1/public-api/webhook-subscriptions/:id webhooks_read
PATCH /v1/public-api/webhook-subscriptions/:id webhooks_write
DELETE /v1/public-api/webhook-subscriptions/:id webhooks_write
GET /v1/public-api/webhook-subscriptions/:id/deliveries webhooks_readEvent types
Subscribe to any subset of:
| Event type | Fires when |
|---|---|
candidate_created | A candidate is created |
candidate_updated | A candidate is updated |
job_created | A job is created |
job_updated | A job is updated |
application_created | An application is created |
application_updated | An application’s stage or notes change |
placement_created | A placement is created |
placement_updated | A placement is updated |
A subscription with no eventTypes receives nothing.
Payload
Each delivery is a POST with a JSON envelope. The data field is the same curated
public projection returned by the REST API — never raw
database rows — so no internal scoring or un-curated PII is leaked.
{
"id": "f1d2c3b4-...",
"type": "candidate_created",
"createdAt": "2026-06-06T12:00:00.000Z",
"apiVersion": "v1",
"data": {
"id": "cand_123",
"fullName": "Ada Lovelace",
"email": "ada@example.com",
"status": "active"
}
}| Field | Meaning |
|---|---|
id | Unique id for this event occurrence — safe to dedupe on |
type | The event type (matches the subscribed values) |
createdAt | ISO-8601 instant the event was emitted |
apiVersion | Payload contract version (v1); pin your parser to it |
data | Curated public attributes of the affected resource |
Headers
Every delivery carries:
| Header | Value |
|---|---|
x-vitae-signature | Timestamped HMAC, e.g. t=1700000000,v1=<hex> |
x-vitae-event | Event type, e.g. candidate_created |
x-vitae-event-id | The envelope id (dedupe key) |
x-vitae-delivery-id | Id of this specific delivery attempt |
content-type | application/json |
user-agent | Vitae-Webhooks/1.0 |
Verifying signatures
The x-vitae-signature header is t=<unix-seconds>,v1=<hex>, where the hex is an
HMAC-SHA256 of "<t>.<rawBody>" keyed by your signing secret — the same recipe
Stripe uses. Always verify against the raw request body, before any JSON
parsing or re-serialization.
import { createHmac, timingSafeEqual } from 'node:crypto';
function isValidVitaeSignature(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
const timestamp = parts.t;
const signature = parts.v1;
if (!timestamp || !signature) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}Reject the request if the signature does not match. Because the timestamp is signed
into the payload, you can also reject deliveries whose t is outside a freshness
window (e.g. older than five minutes) to defend against replay.
Responding
Return any 2xx status to acknowledge receipt. Any non-2xx response, a connection
error, or a timeout is treated as a failed attempt and retried.
- Respond quickly (under ten seconds) — slow handlers are aborted and retried.
- Acknowledge first, then process asynchronously, so a slow downstream never causes duplicate deliveries.
- Be idempotent: dedupe on
x-vitae-event-id, since at-least-once delivery means an event can arrive more than once. - Redirects are not followed; point the subscription at the final URL.
Retries and dead-letters
A delivery is attempted up to 5 times with exponential backoff (base 10s: ~10s, 20s, 40s, 80s). The endpoint URL is re-validated for SSRF safety on every attempt, not just at subscribe time, to defend against DNS rebinding.
If all attempts fail, the delivery is dead-lettered — it stops retrying and is recorded with its last response status, body (truncated), and error. Inspect recent attempts, including dead-letters, via:
GET /v1/webhook-subscriptions/:id/deliveriesEach delivery row exposes status (pending, delivering, succeeded, failed,
dead_lettered), attempts, responseStatus, error, and timing fields, so you
can diagnose a misbehaving endpoint and, once fixed, rotate the secret or re-create
the subscription.
Security
- Always verify the
x-vitae-signaturebefore trusting a payload. - Serve your endpoint over HTTPS on a public host.
- Treat the signing secret like a password; rotate it if it may be exposed.
- Enforce a timestamp freshness window to reject replays.