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.