Guide
Full webhook guide
Receive parse lifecycle events at your own HTTPS endpoint instead of polling. This guide covers subscriptions, event types, signed payloads, replay-safe verification, retries, and local testing.
How delivery works
- Subscriptions are project-scoped and target an HTTPS URL you control.
- Events are delivered as JSON
POSTrequests signed with HMAC SHA-256. - Each payload uses schema
cvault.developer.webhook.event.v1. - Network failures and retryable responses use backoff. Ordinary 4xx and redirects are not retryable; handlers must be idempotent.
- Lifecycle events describe job transitions. Fetch every results page and inspect per-file outcomes after a terminal event.
1. Create a subscription
Create a project/environment subscription with POST /v1/webhook-subscriptions and webhook.manage. The HTTP 201 response returns data.secret once; store it securely. The console's organization webhook endpoints are a separate scope and their controls do not establish list, test, update or rotation routes for developer project subscriptions.
curl -sS -X POST "$CVAULT_API_URL/v1/webhook-subscriptions" \
-H "x-api-key: $CVAULT_API_KEY" \
-H "content-type: application/json" \
-d '{"target_url":"https://hooks.example.com/cvault","event_types":["parse.completed","parse.failed"]}'Targets must be HTTPS. Per-request callback destinations are not supported, so one subscription can serve many parse jobs. Set webhook_subscription_id on a submission to select an active subscription in the same project/environment. Without it, matching active project subscriptions are used.
2. Choose events
| Event | When it fires |
|---|---|
parse.accepted | The job was accepted. |
parse.completed | The job completed. A batch can still contain failed or duplicate files. |
parse.failed | The job failed, including exhausted worker delivery attempts. |
parse.timed_out | The job timed out or expired; delivery exhaustion can instead emit parse.failed. |
parse.cancelled | The job was cancelled. |
Subscribe to the terminal outcomes your integration handles, including failures, timeouts and cancellation. parse.completed describes the job transition, not a per-document delivery, and a completed batch may contain failed or duplicate files. Poll status as a fallback; do not assume event arrival order.
3. Read the delivery
Every delivery carries identity and signing headers, then a JSON body with the event and payload.
X-CVault-Signature: …
X-CVault-Signature-Version: …
X-CVault-Timestamp: …
X-CVault-Nonce: …
X-CVault-Event: …
X-CVault-Event-Id: …
X-CVault-Delivery-Id: …
X-CVault-Idempotency-Key: …{
"schema_version": "cvault.developer.webhook.event.v1",
"event_type": "parse.completed",
"event_id": "evt_...",
"delivery_id": "dlv_...",
"event_version": 1,
"project_id": "project-uuid",
"environment": "test",
"occurred_at": "2026-09-14T10:24:12+00:00",
"data": {
"job_id": "b7f3c0f0-...",
"status": "done"
}
}4. Verify signatures (required)
These examples authenticate the signature and timestamp. They do not provide the durable delivery store: implement the acceptance/deduplication flow below. Recompute the signature over the raw request body and compare it in constant time. The canonical message is timestamp + '.' + nonce + '.' + raw_body_bytes.
import { createHmac, timingSafeEqual } from "node:crypto";
// Lowercase header names; rawBody is the untouched Buffer, before JSON parsing.
function verifyCvaultSignature(rawBody, headers, secret) {
const timestamp = headers["x-cvault-timestamp"] ?? "";
const nonce = headers["x-cvault-nonce"] ?? "";
const signature = headers["x-cvault-signature"] ?? "";
if (!Buffer.isBuffer(rawBody) || !secret ||
headers["x-cvault-signature-version"] !== "v1" ||
!/^[0-9]+$/.test(timestamp) || !/^[A-Za-z0-9._~:-]{1,256}$/.test(nonce) ||
!/^v1=[0-9a-f]{64}$/.test(signature)) return false;
const seconds = Number(timestamp);
if (!Number.isSafeInteger(seconds) || Math.abs(Date.now() / 1000 - seconds) > 900) return false;
const expected = "v1=" + createHmac("sha256", secret)
.update(timestamp + "." + nonce + ".", "utf8").update(rawBody).digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
// After verification, atomically persist delivery_id and enqueue the event.
// Return 2xx for an already accepted delivery; do not repeat its side effects.Verification rules:
- Verify the v1 signature over untouched request bytes before parsing JSON.
- Check timestamp freshness. These examples use the reference verifier default of 900 seconds; it is a receiver tolerance, not a delivery-time guarantee.
- Atomically deduplicate accepted events by delivery_id or X-CVault-Delivery-Id. Nonces identify signed dispatch attempts and are not a durable event-deduplication key.
- Return 2xx after authenticated events are durably accepted. Network failures and retryable HTTP responses use backoff; ordinary 4xx and redirects are not retryable.
- Lifecycle payloads use schema cvault.developer.webhook.event.v1 with event_type, event_id, delivery_id, occurred_at, project_id, environment and data.
Do not parse or re-serialize the body before verifying — even whitespace changes invalidate the signature. Verify against the exact bytes you received.
5. Retries and idempotency
- Verify signature/version and freshness before trusting any event data. Lowercase header names in the examples.
- Atomically persist the stable delivery_id (or X-CVault-Delivery-Id) with your durable queue handoff before responding 2xx. For an already accepted delivery, return 2xx without repeating its effects.
- A nonce belongs to a signed dispatch attempt; do not use it instead of the delivery ID for durable deduplication. Repeated dispatches can sign the same event with another nonce.
- Network errors, timeouts, 408, 425, 429 and 5xx are retryable. Other 4xx and redirects are not; destinations must be public HTTPS without credentials and redirects are not followed.
- Fetch full results through GET /v1/parse-jobs/{job_id}/results after a terminal event, and continue until next_cursor is null. Webhooks are lifecycle notifications, not a complete batch-results export.
- The event data.status may carry the internal done label; polling/results use succeeded. Use event_type for lifecycle dispatch and inspect fetched results for individual success.
6. Test locally
- Expose your local server with a tunnel and use the tunnel URL as the subscription target.
- Submit a synthetic resume with the project subscription selected and observe its lifecycle delivery. The public project API has no test-delivery endpoint; organization console tests are a different surface.
- Test valid, tampered, missing-header, expired-timestamp and duplicate-delivery cases against your receiver. An authenticated duplicate should get 2xx without repeating work.
7. Troubleshooting
| Symptom | Cause | Action |
|---|---|---|
| No deliveries after parsing | Subscription inactive or wrong events | Check the project/environment, active subscription and event_types; confirm the job reached the expected transition. |
| Signature never matches | Body was parsed/re-serialized, or wrong secret | Verify untouched bytes with the secret returned at creation. For a compromised project subscription, arrange revocation through supported administration; /v1 has no secret-rotation route. |
| Duplicate deliveries | Retries after slow/timeout responses | Respond 2xx fast and deduplicate on the delivery ID. |
| 422 webhook_https_required | Non-HTTPS target | Use HTTPS (tunnel URLs are HTTPS). |
