Skip to content

Webhooks

Registering an endpoint, verifying a signature, and what happens when your receiver is down.

A webhook endpoint belongs to one integration and one mode, filters the event types it wants, and signs with its own secret. Everything under /v1/webhook_endpoints needs webhooks:manage.

Registering one

curl https://api.cuvo.co/v1/webhook_endpoints \
  -H "Authorization: Bearer cuvo_sk_test_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: endpoint-hooks-example-1" \
  -d '{
    "url": "https://hooks.example.com/cuvo",
    "event_types": ["case.approved.v1", "case.declined.v1", "order.shipped.v1"],
    "include_resource": true,
    "description": "Production receiver"
  }'

The URL must be https and must not carry credentials. It is also checked against private, loopback and link-local address ranges, both when you register it and again immediately before every single attempt, because a hostname that resolved publicly yesterday can resolve to a metadata address today. Redirects are not followed.

event_types is a filter and must name at least one type. include_resource embeds the resource DTO in the delivery as data, which saves you a read; leave it off and refetch by id.

The response is one of exactly two places the signing secret appears. It starts with whsec_, it is sealed at rest, and it is never readable again. The other place is the rotation below.

What arrives

A delivery is a POST of the event body with four headers.

Cuvo-Signature:   t=1757340131,v1=6f8a…
Cuvo-Event-Id:    evt_2b7c9d10
Cuvo-Event-Type:  case.approved.v1
Cuvo-Delivery-Id: dlv_44f1c0a8

Verifying the signature

The digest is HMAC-SHA256 of `${t}.${rawBody}`, hex encoded. It covers the timestamp, so a captured delivery cannot be replayed under a fresh one, and it covers the raw body, so re-serializing on either side breaks it. Verify before you parse.

const [t, ...sigs] = header.split(",").map((part) => part.split("=")[1]);
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
const ok = fresh && sigs.some((sig) => crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));

Both SDKs ship this as verifySignature. See SDKs.

Rotating the secret

POST /v1/webhook_endpoints/{id}/rotate_secret returns a new secret and keeps the previous one valid for 24 hours. During the grace the header carries two v1= values, the new secret first. A receiver that accepts a delivery when any v1= matches can move to the new secret whenever it likes inside that window, which is the point of the grace.

Retries and failure

Delivery is at-least-once, so dedupe on Cuvo-Event-Id. A worker that dies after sending and before recording sends again.

An attempt succeeds on any 2xx. Anything else, including a timeout at 15 seconds, is a failure and is retried on this ladder:

immediately, then +1m, +5m, +30m, +2h, +6h, +12h, +24h, +24h

That is up to nine attempts across about 2.9 days, after which the delivery is failed for good.

Twenty consecutive failed events in a row disable the endpoint. Nothing is delivered to a disabled endpoint, and the disabling itself emits webhook_endpoint.disabled.v1, so subscribe to that on a second endpoint if you want to be told.

Debugging a receiver

GET /v1/webhook_endpoints/{id}/deliveries lists every attempt, filterable by status of pending, delivered or failed. Each row carries the attempt number, the HTTP status, the latency, next_attempt_at, and the first kilobyte of what your endpoint answered, which is usually enough to see the stack trace it returned.

To replay one, POST /v1/events/{id}/redeliver, optionally naming a single endpoint. To prove a brand new receiver before any case exists, POST /v1/test/webhooks/trigger emits a real signed delivery of any event type you choose.