Cowdidocs

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"
200 OK
[
  {
    "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"
200 OK
{
  "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"
  }'
ParameterTypeDescription
namestringOperator-facing name, unique within your organization. At most 100 characters.
descriptionstringOptional. What this endpoint is for, at most 255 characters.
urlstringWhere deliveries are POSTed. HTTPS, and a public address — Cowdi refuses plaintext HTTP and anything that resolves into a private network.
eventsarrayThe events this endpoint receives. See the catalog above.
timeoutstringOptional. Per-attempt timeout as an ISO-8601 duration, PT1S to PT30S. Defaults to PT5S.
201 Created
{
  "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"}'
ParameterTypeDescription
descriptionstringOptional. What this endpoint is for, at most 255 characters.
urlstringOptional. A new URL passes the same checks as registration.
statusstringOptional. ACTIVE resumes deliveries, SUSPENDED stops them.
eventsarrayOptional. The complete set of events this endpoint should receive.
timeoutstringOptional. 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:

  1. Split the header on ,. Read t (Unix seconds) and every v1 entry.
  2. Reject the delivery if t is 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.
  3. Build the signed payload: the value of t, a literal ., then the raw request body. Do not parse or re-serialize the body first.
  4. Compute HMAC-SHA256 of the signed payload with your whsec_... secret as the key. Accept when the hex result equals any v1 entry, 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.
Node.js
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)),
  );
}
Python
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"
202 Accepted
{
  "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"
201 Created — the secret is shown once, here
{
  "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.

On this page