Cowdidocs

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:

FieldWhat it is
NameOperator-facing name, unique within your organization. At most 100 characters.
DescriptionOptional. What this endpoint is for, at most 255 characters.
URLWhere deliveries are POSTed. HTTPS, and a public address — Cowdi refuses plaintext HTTP and anything that resolves into a private network.
EventsThe events this endpoint receives. Pick at least one from the catalogue.
TimeoutPer-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_id is 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_id is the facility itself. To get the new cycle, read replacement_cycle_id from GET /v1/accounts/credit/{accountId}/refinance/offers/{refinancingId}.
  • For a Flex Credit facility, the open purchases are settled, and one replacement purchase with kind REFINANCING finances their payoff on the quoted terms. The facility stays open, so replacement_account_id is the facility itself. To get the new purchase, read replacement_cycle_id from GET /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:

  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.

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.

On this page