Skip to content
On this page How delivery works

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 POST requests 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
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

EventWhen it fires
parse.acceptedThe job was accepted.
parse.completedThe job completed. A batch can still contain failed or duplicate files.
parse.failedThe job failed, including exhausted worker delivery attempts.
parse.timed_outThe job timed out or expired; delivery exhaustion can instead emit parse.failed.
parse.cancelledThe 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.

Headers
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: …
Lifecycle envelope (illustrative subset)
{
  "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.
Verify the original bytes

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

SymptomCauseAction
No deliveries after parsingSubscription inactive or wrong eventsCheck the project/environment, active subscription and event_types; confirm the job reached the expected transition.
Signature never matchesBody was parsed/re-serialized, or wrong secretVerify 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 deliveriesRetries after slow/timeout responsesRespond 2xx fast and deduplicate on the delivery ID.
422 webhook_https_requiredNon-HTTPS targetUse HTTPS (tunnel URLs are HTTPS).