Overview
Webhooks are the third family of the CazVid Developer API, alongside Job Builder and Candidate Search. You register a webhook endpoint, choose the events it should receive, and CazVid POSTs each event to your URL. Deliveries are signed and only sent to endpoints you have verified.
- At-least-once delivery. The same event can arrive more than once - dedupe on the envelope
id. - No ordering guarantee. Use
occurredAtif you need to order events. - Additive-only within
v1. New fields can appear without notice, so ignore unknown fields. - Verified endpoints only. Production access requires a Platinum plan or higher.
Authentication and plans
Webhooks reuse your existing CazVid Developer API keys - one key works across Job Builder, Candidate Search, and Webhooks. Send it with the x-api-key header or as an Authorization: Bearer token. Treat keys as secrets. Org owners and admins can also manage endpoints from the Webhooks tab in CazVid.
x-api-key: cazvid_jb_...
Authorization: Bearer cazvid_jb_...
Content-Type: application/jsonTwo scopes gate webhook access. New keys get both by default.
webhooks:read- list endpoints, read an endpoint and its delivery log, read the event catalog and usage.webhooks:manage- create, update, delete, rotate the secret, verify, test, and redeliver.
Management endpoints
All routes are rooted at /api/v3.0/webhooks/public. The management API is key-authenticated and scope-gated. An endpoint created with a key is limited to that key's workspaces, and an organization can register up to 10 endpoints. A new endpoint returns its signing secret once and starts in pending_verification.
| Method | Path | Scope | Purpose |
|---|---|---|---|
| POST | /public/webhook-endpoints | webhooks:manage | Create an endpoint. The signing secret is returned once. |
| GET | /public/webhook-endpoints | webhooks:read | List your endpoints. Secrets are never returned. |
| GET | /public/webhook-endpoints/:id | webhooks:read | Endpoint detail plus delivery stats. |
| PATCH | /public/webhook-endpoints/:id | webhooks:manage | Update url, events, description, or status (active or disabled). |
| DELETE | /public/webhook-endpoints/:id | webhooks:manage | Soft-delete an endpoint. |
| POST | /public/webhook-endpoints/:id/rotate-secret | webhooks:manage | Mint a new secret. The old one keeps signing for 24 hours. |
| POST | /public/webhook-endpoints/:id/verify | webhooks:manage | Resend the verification challenge. |
| POST | /public/webhook-endpoints/:id/test | webhooks:manage | Send a webhook.ping test delivery. |
| GET | /public/webhook-endpoints/:id/deliveries | webhooks:read | Delivery log. Metadata only, never payloads. |
| POST | /public/webhook-endpoints/:id/deliveries/:deliveryId/redeliver | webhooks:manage | Re-enqueue a past delivery within 30 days. |
| GET | /public/events | webhooks:read | The event-type catalog. |
| GET | /public/usage | webhooks:read | Endpoint count and deliveries used today against the cap. |
| GET | /public/assets/:token | webhooks:read | Redeem an asset link. 302-redirects to a short-lived presigned URL. |
Create an endpoint
curl -X POST https://aio-backend-prod.cazvid.app/api/v3.0/webhooks/public/webhook-endpoints \
-H "x-api-key: cazvid_jb_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/cazvid",
"description": "ATS application sync",
"events": ["application.received"],
"workspaceIds": ["WORKSPACE_ID"]
}'Response (secret returned once)
{
"id": "66a1f0c9e4b0a1d2c3e4f5a6",
"url": "https://hooks.example.com/cazvid",
"description": "ATS application sync",
"events": ["application.received"],
"workspaceIds": ["66936c2f8a1b4e0011aa2b3c"],
"status": "pending_verification",
"apiVersion": "v1",
"secret": "whsec_9tJ0k3mX2pQ7rL5nV8wZ1yB4cD6eF0g",
"createdAt": "2026-07-09T14:20:11.004Z",
"updatedAt": "2026-07-09T14:20:11.004Z"
}Send a test ping
curl -X POST https://aio-backend-prod.cazvid.app/api/v3.0/webhooks/public/webhook-endpoints/ENDPOINT_ID/test \
-H "x-api-key: cazvid_jb_..."Event envelope
Every delivery, for every event type, is the same JSON envelope. The type-specific body lives in data.
{
"id": "evt_7f3a9c2e1b6d4f80a5c3e9d1f2b4a6c8",
"type": "application.received",
"apiVersion": "v1",
"createdAt": "2026-07-09T14:32:07.812Z",
"occurredAt": "2026-07-09T14:32:06.104Z",
"organizationId": "6462916b41d57a6091c3d18a",
"data": { }
}| Field | Type | Description |
|---|---|---|
| id | string | Unique event id, stable across every retry. Use it as your dedup key. |
| type | string | The event type, such as application.received. |
| apiVersion | "v1" | Payload contract version. Always v1 for now. |
| createdAt | string (ISO 8601) | When CazVid emitted the event. Stable across retries. |
| occurredAt | string (ISO 8601) | Business time the underlying event happened (the true appliedAt). |
| organizationId | string | Your organization id. |
| data | object | The type-specific body, documented per event below. |
Events
Three event types ship in v1.
| Event | When |
|---|---|
| application.received | A candidate completes an application to one of your jobs. |
| webhook.verify | Sent on create and re-verify. Echo the challenge to activate the endpoint. |
| webhook.ping | Sent by the test action. data.test is true; never affects suspension. |
application.received
Sent when a candidate completes an application to one of your jobs, on both the desktop and mobile apply flows. candidate.email is the stable person key and application.id identifies the application - see IDs and assets for how to treat each field. screeningAnswers is empty when the job has no screening questions.
{
"id": "evt_7f3a9c2e1b6d4f80a5c3e9d1f2b4a6c8",
"type": "application.received",
"apiVersion": "v1",
"createdAt": "2026-07-09T14:32:07.812Z",
"occurredAt": "2026-07-09T14:32:06.104Z",
"organizationId": "6462916b41d57a6091c3d18a",
"data": {
"application": {
"id": "66a1f0c9e4b0a1d2c3e4f5a6",
"listItemId": "66a1f0c9e4b0a1d2c3e4f5b7",
"url": "https://desktop.cazvid.app/employer/applicants/66a1f0c9e4b0a1d2c3e4f5a6",
"appliedAt": "2026-07-09T14:32:06.104Z",
"source": "one_tap",
"qualificationStatus": "qualified",
"screeningAnswers": [
{
"questionId": "6698c1a2f0b7d90012ab34ce",
"question": "Do you have at least 2 years of customer support experience?",
"answer": "Yes",
"questionType": "boolean",
"answerKind": "boolean",
"required": true,
"disqualifying": true
}
]
},
"job": {
"postId": "6698c1a2f0b7d90012ab34cd",
"url": "https://cazvid.com/job/senior-customer-support-specialist",
"title": "Senior Customer Support Specialist",
"locationName": "Mexico City, Mexico",
"countryCode": "MX",
"isRemote": false,
"workspaceId": "66936c2f8a1b4e0011aa2b3c"
},
"candidate": {
"publicId": "pc1.ZXhhbXBsZS1vcGFxdWUtaGFuZGxlLXRva2Vu",
"name": "Maria Gonzalez",
"email": "maria.gonzalez@example.com",
"phoneNumber": "+52 55 1234 5678",
"locationName": "Mexico City, Mexico",
"countryCode": "MX",
"resumeUrl": "https://aio-backend-prod.cazvid.app/api/v3.0/webhooks/public/assets/eyJhbGciOi...",
"videoUrl": "https://ik.imagekit.io/cazvid/candidate-videos/66a1f0c9.mp4"
}
}
}webhook.verify
Sent when you create or re-verify an endpoint. Echo the exact data.challenge string in your 2xx response body to activate the endpoint. Until then, no real event is delivered.
{
"id": "evt_2b9d4f8a1c6e0d3b5f7a9c1e2d4b6a8c",
"type": "webhook.verify",
"apiVersion": "v1",
"createdAt": "2026-07-09T14:20:12.507Z",
"occurredAt": "2026-07-09T14:20:12.507Z",
"organizationId": "6462916b41d57a6091c3d18a",
"data": {
"challenge": "whc_c1f4a8e2b6d0937f5a1c9e3d7b2408af"
}
}webhook.ping
Sent by the test action so you can confirm your receiver and signature checks end to end. data.test is always true and pings never change an endpoint's suspension state.
{
"id": "evt_5c1e9a2d7f4b0836a9c3e1d5f7b2408a",
"type": "webhook.ping",
"apiVersion": "v1",
"createdAt": "2026-07-09T14:25:00.113Z",
"occurredAt": "2026-07-09T14:25:00.113Z",
"organizationId": "6462916b41d57a6091c3d18a",
"data": {
"test": true
}
}Endpoint verification
An endpoint cannot receive real events until you prove you control its URL.
- 1.On create (and on each verify call) the endpoint is
pending_verificationand CazVid POSTs awebhook.verifyevent to the URL. - 2.Your endpoint returns a 2xx response whose body echoes the exact
data.challengestring. - 3.On a correct echo the endpoint becomes
activeand starts receiving the events it subscribed to. - 4.Re-verification is limited to 5 attempts per hour. Use the verify endpoint or the verify button in CazVid.
Verifying signatures
Every delivery is signed with the Standard Webhooks scheme. Verify each request before trusting it. Three headers accompany the POST:
webhook-id: evt_7f3a9c2e1b6d4f80a5c3e9d1f2b4a6c8
webhook-timestamp: 1783002727
webhook-signature: v1,g0X4Hq7m2p9sB1vZ8wYc3dE5fG6hJ0kL2mN4oP6qR8=The signed content is {webhook-id}.{webhook-timestamp}.{body}, HMAC-SHA256 with your endpoint secret, base64-encoded, and prefixed with v1,. Verify the exact raw bytes you received - do not re-serialize the parsed JSON, or whitespace and key order will break the signature. During a rotation the header carries two space-delimited signatures; accept the request if any one matches. Reject a webhook-timestamp more than 5 minutes from your current time to bound replays.
whsec_<base64>. Strip the whsec_ prefix and base64-decode the rest to get the raw signing key. The Standard Webhooks libraries do this for you when you pass the full whsec_... value.Install the Standard Webhooks library with npm i standardwebhooks (Node) or pip install standardwebhooks (Python). The svix libraries are wire-compatible. standardwebhooks.com
Node
import { Webhook } from "standardwebhooks";
import express from "express";
// The secret is the whsec_... value returned at create / rotate-secret. The
// library strips the prefix and base64-decodes it to the raw key for you.
const wh = new Webhook(process.env.CAZVID_WEBHOOK_SECRET);
const app = express();
app.post(
"/cazvid-webhooks",
express.raw({ type: "application/json" }), // verify the RAW body
(req, res) => {
try {
const event = wh.verify(req.body, {
"webhook-id": req.header("webhook-id"),
"webhook-timestamp": req.header("webhook-timestamp"),
"webhook-signature": req.header("webhook-signature"),
});
// event is the verified envelope. Dedupe on event.id, then ACK fast.
res.sendStatus(200);
} catch {
res.sendStatus(400);
}
},
);Python
from standardwebhooks import Webhook
# The secret is the whsec_... value from create / rotate-secret.
wh = Webhook(secret)
@app.post("/cazvid-webhooks")
async def receive(request):
raw = await request.body() # verify the RAW bytes
headers = {
"webhook-id": request.headers["webhook-id"],
"webhook-timestamp": request.headers["webhook-timestamp"],
"webhook-signature": request.headers["webhook-signature"],
}
event = wh.verify(raw, headers) # raises on an invalid signature
return Response(status_code=200)Manual verification (no dependency)
import { createHmac, timingSafeEqual } from "node:crypto";
// Fallback if you cannot add the standardwebhooks dependency.
function verify(rawBody, headers, whsecret, toleranceSec = 300) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false;
// Strip "whsec_" and base64-decode to the raw signing key.
const key = Buffer.from(whsecret.replace(/^whsec_/, ""), "base64");
const signed = id + "." + ts + "." + rawBody;
const expected = createHmac("sha256", key).update(signed).digest("base64");
// The header can carry several space-delimited signatures during rotation.
return headers["webhook-signature"]
.split(" ")
.map((part) => part.split(",")[1])
.some((sig) => {
const a = Buffer.from(sig);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
});
}IDs and assets
The payload mixes several kinds of identifier. Treat them differently.
| Field | Kind | Notes |
|---|---|---|
| application.id | Raw, stable | The application id (qatId). Stable across retries and identical on desktop and mobile. Safe to store as your application key. |
| job.postId | Raw | The platform post id, the same id the Job Builder API returns. |
| organizationId | Raw | Your organization id. |
| job.workspaceId | Raw | The workspace the job belongs to. |
| candidate.publicId | Opaque token | A pc1. handle, redeemable at the authenticated contact flow. NOT correlatable across events (random per payload), so never use it as a person key. |
| candidate.email | Stable person key | Use this to match or merge a person across applications. |
| candidate.resumeUrl | Short-lived link | A GET /public/assets/<token> redemption link. The token lasts about 30 days and 302-redirects to a fresh, roughly 1-hour presigned URL. Present only when the candidate supplied a resume. |
| candidate.videoUrl | Public CDN | An ImageKit URL, publicly fetchable. Present only when the candidate uploaded a video. |
candidate.resumeUrl is an authenticated redemption endpoint, not a direct file link. Call GET /public/assets/<token> with a key that has webhooks:read and it returns a 302 to a fresh, roughly 1-hour presigned URL; the token is also bound to your organization. Fetch the redirect target promptly and do not store it; re-redeem the token (valid about 30 days) when you need the file again.Delivery, retries, and suspension
A delivery succeeds when your endpoint returns any 2xx within a 10-second wall-clock bound (DNS, connect, TLS, and response). Process asynchronously and ACK fast. Response bodies are ignored except the verification challenge echo. Redirects count as a failure. Deliveries are HTTPS only, on port 443 or 8443, and CazVid will not deliver to private or internal addresses.
Failed attempts are retried on this schedule - 8 attempts over roughly 1.8 days, then the delivery is marked failed:
attempt 1 immediate
attempt 2 +1 minute
attempt 3 +5 minutes
attempt 4 +30 minutes
attempt 5 +2 hours
attempt 6 +5 hours
attempt 7 +10 hours
attempt 8 +24 hoursid, which your consumer dedupes.Limits and behavior
- Up to 10 endpoints per organization.
- Up to 5,000 deliveries per organization per day. Excess is parked as failed and can be redelivered the next day.
- Test pings count toward the daily cap and are limited to 10 per day per endpoint.
- Re-verification is limited to 5 attempts per hour per endpoint.
- Delivery records are retained for 30 days.
Building more on CazVid? Browse the Developer API index for the Job Builder and Candidate Search APIs.