Webhooks
Register endpoints, receive Cowdi events, and verify that each delivery came from Cowdi.
Cowdi calls your HTTPS endpoint when something your organization cares about happens. Each call is one delivery: a signed JSON body, plus headers that identify the event and the delivery.
All webhook management endpoints live under /v1/webhooks and take a
bearer token (see Authentication) whose role holds
org:webhooks:read or org:webhooks:write.
See which events you can subscribe to
curl https://api.cowdi.co/v1/webhooks/events/types \
-H "Authorization: Bearer $TOKEN"[
{
"event": "approval.outcome",
"category": "APPROVAL",
"description": "An approval request was approved or rejected"
}
]category is the resource the event happens to. Group the list by it when you
show these to a person; the set is closed, and the API reference lists every
value.
See what your organization produced
Cowdi records every event it raises for you, whether or not an endpoint asked for it. Read this feed to learn what the platform produces before you write a receiver, and to see the events an endpoint missed while it was suspended.
curl "https://api.cowdi.co/v1/webhooks/events?type=approval.outcome&size=20" \
-H "Authorization: Bearer $TOKEN"{
"data": [
{
"id": "0198c1c2-7d41-7b63-ae05-4c2f7b4c9e51",
"event_type": "approval.outcome",
"category": "APPROVAL",
"event_key": "approval.outcome:0198c1c2-4a1e-7f30-b7a2-1f9f4f1f6b2e",
"payload_version": 1,
"data": {
"approval_request_id": "0198c1c2-4a1e-7f30-b7a2-1f9f4f1f6b2e",
"approval_type": "BLOCK_CHANGE",
"entity_type": "USER",
"entity_id": "7f9b1c2e-...",
"decision": "APPROVED"
},
"created_at": "2026-08-27T09:15:04Z"
}
],
"next": "/v1/webhooks/events?after=eyJldmVudElkIjoi…",
"prev": null
}data is exactly what a delivery carries under data, so a row here and a
delivery of the same event read the same. event_key names the business event,
which is how you find the event for one approval request.
Narrow the feed with type and a created_from/created_until window
(RFC 3339 timestamps). Pages are keyset cursors: follow next and prev.
GET /v1/webhooks/events/{event_id} opens one event.
An event is in the feed once, however many endpoints received it. What was sent to each endpoint, and what each answered, is the delivery log below.
Register an endpoint
Create a webhook with a name, a public HTTPS URL, and the events it should receive. A description is optional, and says what the endpoint is for.
curl -X POST https://api.cowdi.co/v1/webhooks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "sales-app",
"description": "Approval outcomes for the sales app",
"url": "https://sales.example.com/hooks/cowdi",
"events": ["approval.outcome"],
"timeout": "PT5S"
}'| Parameter | Type | Description |
|---|---|---|
name | string | Operator-facing name, unique within your organization. At most 100 characters. |
description | string | Optional. What this endpoint is for, at most 255 characters. |
url | string | Where deliveries are POSTed. HTTPS, and a public address — Cowdi refuses plaintext HTTP and anything that resolves into a private network. |
events | array | The events this endpoint receives. See the catalog above. |
timeout | string | Optional. Per-attempt timeout as an ISO-8601 duration, PT1S to PT30S. Defaults to PT5S. |
{
"id": "0198c1c2-4a1e-7f30-b7a2-1f9f4f1f6b2e",
"name": "sales-app",
"description": "Approval outcomes for the sales app",
"url": "https://sales.example.com/hooks/cowdi",
"status": "ACTIVE",
"events": ["approval.outcome"],
"timeout": "PT5S",
"signing_keys": [
{
"id": "0198c1c2-5b2f-7e41-9c83-2a0e5f2f7c3f",
"secret": "whsec_9f2c1e8b…7f6e5d4c3",
"masked_secret": "whsec_...a1b2",
"created_at": "2026-08-25T09:15:04Z"
}
],
"created_at": "2026-08-25T09:15:04Z",
"updated_at": "2026-08-25T09:15:04Z"
}The secret is shown once
signing_keys[0].secret appears only in this response. Store it now — every
later read returns only the masked form. If you lose it, mint a new secret and
revoke this one.
Manage endpoints
List your endpoints, read one, or change one. PATCH takes any subset of
fields; events is always the complete set, not a delta.
curl https://api.cowdi.co/v1/webhooks \
-H "Authorization: Bearer $TOKEN"curl -X PATCH https://api.cowdi.co/v1/webhooks/{webhook_id} \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"events": ["approval.outcome"], "status": "ACTIVE"}'| Parameter | Type | Description |
|---|---|---|
description | string | Optional. What this endpoint is for, at most 255 characters. |
url | string | Optional. A new URL passes the same checks as registration. |
status | string | Optional. ACTIVE resumes deliveries, SUSPENDED stops them. |
events | array | Optional. The complete set of events this endpoint should receive. |
timeout | string | Optional. PT1S to PT30S. |
What a delivery looks like
POST /your/endpoint HTTP/1.1
X-Cowdi-Signature: t=1787649304,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
X-Cowdi-Event-Type: approval.outcome
X-Cowdi-Delivery-Id: 01a0347d-7e2c-7002-9cdb-55ec8ff70c31
Content-Type: application/json
{
"delivery_id": "01a0347d-7e2c-7002-9cdb-55ec8ff70c31",
"event_type": "approval.outcome",
"payload_version": 1,
"sent_at": "2026-08-25T09:15:04Z",
"organization_id": "acme",
"data": {
"approval_request_id": "0198c1c2-4a1e-7f30-b7a2-1f9f4f1f6b2e",
"approval_type": "BLOCK_CHANGE",
"entity_type": "USER",
"entity_id": "7f9b1c2e-...",
"decision": "APPROVED"
}
}data is the only part an event decides. The envelope around it is the same
for every event.
Verify the signature
Every delivery is signed with HMAC-SHA256. The X-Cowdi-Signature header
carries a timestamp and a signature:
X-Cowdi-Signature: t=1787649304,v1=<newest key>,v1=<older key, mid-rotation>The header carries one v1 entry per active signing key. Outside a rotation
that is one entry; during a rotation it is two. Accept the delivery when any
entry matches the one secret you hold.
To verify:
- Split the header on
,. Readt(Unix seconds) and everyv1entry. - Reject the delivery if
tis missing, not a number, or older than your tolerance. Five minutes is a good default. The timestamp is inside the signature, so a captured delivery cannot be replayed later. - Build the signed payload: the value of
t, a literal., then the raw request body. Do not parse or re-serialize the body first. - Compute HMAC-SHA256 of the signed payload with your
whsec_...secret as the key. Accept when the hex result equals anyv1entry, compared in constant time. Reject everything else — malformed headers included. This is a public endpoint, so bad input must return a refusal, not an exception.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, header, rawBody, toleranceSeconds = 300) {
if (typeof header !== "string") return false;
const parts = header.split(",").map((part) => part.split("="));
const t = parts.find(([k]) => k === "t")?.[1];
const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => v ?? "");
if (!/^\d+$/.test(t ?? "") || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return signatures.some(
(signature) =>
signature.length === expected.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(signature)),
);
}import hashlib, hmac, time
def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
parts = [part.split("=", 1) for part in (header or "").split(",")]
t = next((v for k, v in parts if len([k, v]) == 2 and k == "t"), None)
signatures = [v for k, v in parts if k == "v1"]
if t is None or not t.isdigit() or not signatures:
return False
if abs(time.time() - int(t)) > tolerance:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)Respond, retries, and idempotency
Answer with any 2xx status within your endpoint's timeout. Anything else —
an error status, a timeout, no answer — counts as a failed attempt.
Cowdi retries failed deliveries with an exponential backoff: six attempts
reaching roughly half an hour. Every attempt re-sends the exact same body,
so the same delivery_id always describes the same action. The signature is
fresh on each attempt, because it covers a new timestamp.
Delivery is at-least-once. Treat X-Cowdi-Delivery-Id as your idempotency
key: if you have processed a delivery id before, acknowledge it with a 2xx
and do nothing else.
Only the first kilobyte of your response body is stored for diagnostics.
Inspect and retry deliveries
Every delivery is recorded: the exact body that was sent, when, and what your endpoint answered — attempt by attempt.
curl "https://api.cowdi.co/v1/webhooks/deliveries?status=FAILED&size=20" \
-H "Authorization: Bearer $TOKEN"Narrow the list with webhook_id, event_type, status, and a
created_from/created_until window (RFC 3339 timestamps). Pages are keyset
cursors: follow next and prev. GET /v1/webhooks/deliveries/{id}
opens one delivery with every attempt and the response each attempt received.
A delivery that this platform has finished with can be sent again — one that
ended FAILED, and one that was DELIVERED but that you lost:
curl -X POST https://api.cowdi.co/v1/webhooks/deliveries/{delivery_id}/retry \
-H "Authorization: Bearer $TOKEN"{
"id": "01a0347d-7e2c-7002-9cdb-55ec8ff70c31",
"status": "PENDING",
"attempts": 6,
"next_attempt_at": "2026-08-25T09:20:04Z",
"...": "..."
}The retry re-sends the recorded bytes, so the delivery id is unchanged and your
idempotency check sees the same action. Two cases refuse with 409: a delivery
that is still running, and a webhook that is SUSPENDED. One manual retry per
delivery per minute — a retry asked for sooner answers 429 with Retry-After.
Suspension
An endpoint that fails ten deliveries in a row is suspended, and Cowdi stops calling it. An endpoint whose name starts resolving into a private network is suspended immediately. A suspended endpoint keeps its registration, events, and secrets. Fix the receiver, then resume deliveries:
curl -X PATCH https://api.cowdi.co/v1/webhooks/{webhook_id} \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "ACTIVE"}'Rotate a secret
Rotate without an outage. From the moment a second secret is minted, every
delivery carries one v1 entry per key — so the secret your receiver already
holds keeps verifying while you deploy the new one, at your own pace. There is
no window in which a delivery fails.
curl -X POST https://api.cowdi.co/v1/webhooks/{webhook_id}/secrets \
-H "Authorization: Bearer $TOKEN"{
"id": "0198c1c2-6c30-7f52-8d94-3b1f6a3a8d40",
"secret": "whsec_4d3c2b1a…8170f6e5",
"masked_secret": "whsec_...9x8y",
"created_at": "2026-08-25T10:00:00Z"
}Deploy the new secret to your receiver, then revoke the old one:
curl -X DELETE https://api.cowdi.co/v1/webhooks/{webhook_id}/secrets/{signing_key_id} \
-H "Authorization: Bearer $TOKEN"After the revocation, deliveries carry a single v1 entry again, signed with
the remaining secret. The last remaining secret cannot be revoked — rotate
first, or suspend the endpoint.