Webhooks
Register endpoints in Cowdi Connect, 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.
Endpoints are configured in the Webhooks tool in Cowdi Connect — you sign in as yourself and set one up in the UI. The work that remains is on your side: receiving deliveries and proving each one came from Cowdi. That is what this guide is about.
You do not need an API client for this
Your own access in Connect covers webhook configuration, and a receiver never calls the API — so registering an endpoint needs no credentials of its own. See Manage webhooks with the API for the cases that do.
Set an endpoint up in Connect
Open Webhooks from the masthead and add an endpoint. You give it:
| Field | What it is |
|---|---|
| Name | Operator-facing name, unique within your organization. At most 100 characters. |
| Description | Optional. What this endpoint is for, at most 255 characters. |
| URL | Where deliveries are POSTed. HTTPS, and a public address — Cowdi refuses plaintext HTTP and anything that resolves into a private network. |
| Events | The events this endpoint receives. Pick at least one from the catalogue. |
| Timeout | Per-attempt timeout, 1 to 30 seconds. Defaults to 5. |
The event picker is the catalogue of everything you can subscribe to, grouped by category — the resource the event happens to. The set is closed, and the API reference lists every value.
The signing secret is shown once
Connect reveals the whsec_... secret when the endpoint is created, and never
again — every later read shows only a masked form. Store it in your secret
manager before you close the dialog. If you lose it, rotate: mint a new secret
and revoke the old one.
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.
Refinance events
Two events cover a refinance of a term loan, a revolving credit facility, or a Flex Credit facility.
refinancing.approved means a reviewer approved a quote. The borrower can accept
it until expires_at. data carries refinancing_id, account_id, user_id,
payoff, financed_principal, terms, and expires_at.
refinancing.completed means the refinance completed. Without extra cash, that is
when the borrower accepts the quote. With extra cash, the offer first shows status
DISBURSING, and the event follows when the extra cash reaches its destination.
data carries the same quote fields, plus replacement_account_id and completed_at.
To see the extra cash and where it went, read extra_cash and destination from the
offer. To follow the payment of the extra cash, read payout from the offer. Its
status is PENDING, COMPLETED, FAILED, or CANCELLED, and completed_at is
when the extra cash reached destination. A FAILED payout waits for a retry. Until
the payout completes, the offer stays DISBURSING and refinancing.completed does
not fire.
- For a term loan, the refinanced loan is closed, and the replacement loan is
active.
replacement_account_idis the new loan. - For a revolving credit facility, the open cycles are closed, and one
replacement cycle is open on the same facility with the quoted terms. The
facility keeps its limit, so
replacement_account_idis the facility itself. To get the new cycle, readreplacement_cycle_idfromGET /v1/accounts/credit/{accountId}/refinance/offers/{refinancingId}. - For a Flex Credit facility, the open purchases are settled, and one replacement
purchase with
kindREFINANCINGfinances their payoff on the quoted terms. The facility stays open, soreplacement_account_idis the facility itself. To get the new purchase, readreplacement_cycle_idfromGET /v1/accounts/flex/{accountId}/refinance/offers/{refinancingId}.
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.
Test your receiver
Check your receiver before anything depends on it. From the endpoint's page in
Connect, send a test delivery: Cowdi sends a signed webhook.test payload to
the registered URL, through the same client and signing that every real delivery
uses — so a signature that verifies here verifies in production.
Connect reports whether your endpoint answered 2xx, the status it returned,
how long the attempt took, and the start of its response body — or, when no
response arrived at all, why not.
A test is not retried and is not recorded in the delivery log — that report is
the only record of it. webhook.test is not a subscribable event type, so an
endpoint receives it only when you ask for one. There is one test per webhook
per cooldown; asking again inside it is refused. An endpoint that has since
started resolving into a private network is suspended rather than called.
When deliveries fail
Every delivery is recorded: the exact body that was sent, when, and what your endpoint answered — attempt by attempt. The endpoint's page in Connect lists them, filterable by event type and status, so you can see what a broken receiver returned.
A delivery the platform has finished with can be sent again — one that ended
FAILED, and one that was DELIVERED but that you lost. A retry re-sends the
recorded bytes, so the delivery id is unchanged and your idempotency check sees
the same action. Two cases are refused: a delivery that is still running, and a
webhook that is suspended. There is one manual retry per delivery per minute.
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 set it back to active in Connect. Suspending an endpoint yourself is also how you stop deliveries without losing the registration.
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.
Mint the new secret in Connect, store it, deploy it to your receiver, then
revoke the old one. 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.
Manage webhooks with the API
Everything above is also on the API, under /v1/webhooks. Reach for it when
webhook configuration is itself automated — provisioning an endpoint per tenant
from your own control plane, say, or exporting the delivery log into your
observability stack. A single integration that receives events does not need
any of it.
These endpoints take a bearer token (see
Authentication) holding the org:connect:read scope for
the GETs and org:connect:write for every other method. That is a scope over
your organization's Connect configuration, so grant it deliberately and to one
client rather than to every service that happens to receive webhooks.
The API reference has the full set.
PATCH takes any subset of fields, but events is always the complete set,
not a delta. Deleting an endpoint removes it and its signing keys; the
delivery records it already produced stay readable, and an in-flight delivery
finishes first.
There is an event feed as well as a delivery log, and they answer different
questions. GET /v1/webhooks/events is every event the platform raised for
you, whether or not an endpoint subscribed to it — readable before your first
endpoint exists, and how you see what an endpoint missed while it was suspended.
GET /v1/webhooks/deliveries is what was actually sent, and what each attempt
got back. An event is in the feed once, however many endpoints received it. Both
narrow with a created_from/created_until window (RFC 3339 timestamps) and
page with keyset cursors: follow next and prev.
A feed row's data is exactly what a delivery carries under data, so the two
read the same, and event_key names the business event — which is how you find
the event for one approval request.