Webhooks
React instantly to state changes in your quotes without having to poll the API.
Why use Webhooks?
Instead of your system (an ERP, a Zapier/Make flow, or your own backend) asking Cord every 5 minutes “has the client paid yet?”, Cord’s webhooks notify your system the moment the event happens, with a signed POST to your server.
Common use cases:
- Automatically provision a software license when a quote enters the
paidstate. - Send an internal Slack message the first time a client
viewsa quote. - Trigger your ERP’s billing process once a CFDI is stamped.
Setting up an endpoint
- Turn on Developer mode at the bottom of the Settings index — this reveals the “Developers” bar pinned to the bottom of the screen, visible across the whole app. Click it to open. See the full tour at Cord Workbench.
- Open the Webhooks tab of the panel.
- Click Add endpoint — opens a full-screen wizard.
- Enter your URL — it must be
https://; Cord rejectshttp://destinations,localhost, private/internal IPs, and.internal/.localhosts at creation time. - Pick the subset of events you want (leaving all unchecked = you receive all of them). See the full catalog below.
- Save your secret (
whsec_…) — it’s shown in clear text once. After that it’s masked.
Each plan has an endpoint limit (Free: 1 · Starter: 3 · Professional: 10 · Scale: 25 · Developer: 100). Webhooks are NOT gated behind a paid plan — every plan, including free, can use them.
The payload
Every delivery is a POST with Content-Type: application/json and this body:
{
"id": "evt_5f2a1c9e8b3d4a7f6e1c0d9b8a7f6e5c",
"event": "quote.paid",
"created_at": "2026-07-27T18:32:04.000Z",
"data": {
"id": "b3f1...",
"folio": "COT-0148",
"status": "paid",
"total": 45200.00,
"cliente": "Distribuidora El Zarco",
"link_publico": "https://cordhq.app/q/6f2a...",
"mensaje": "Only present on the ping test event"
}
}
id is the event’s identity, not the quote’s — it stays the same across retries and a manual replay (see event identity & idempotency). data.id is the quote’s internal id.
Signing and security
Cord signs every delivery with two headers. Use the SDK (@flouviahq/elements/server, see the
Server SDK guide) so you don’t have to
implement verification by hand:
import { CordAPI } from '@flouviahq/elements/server';
const cord = new CordAPI(process.env.CORD_SECRET_KEY!);
const event = cord.webhooks.constructEvent(
await req.text(),
req.headers,
process.env.CORD_WEBHOOK_SECRET!,
);
If you’d rather verify by hand:
| Header | Content | Notes |
|---|---|---|
X-Cord-Signature-V1 | t=<unix>,v1=<hmac_sha256(secret, "<t>.<body>")> | Recommended. Includes a timestamp — real replay protection. May carry two v1= pairs during a secret rotation (see below); accept if either one matches. |
X-Cord-Signature | sha256=<hmac_sha256(secret, body)> | Legacy, no timestamp. Still sent on every delivery for compatibility; use V1 if you can. |
X-Cord-Event-Id | evt_… | Same value as the body’s id field. |
X-Cord-Delivery-Id | uuid | This attempt’s id — changes on every retry/replay, unlike X-Cord-Event-Id. |
X-Cord-Attempt | number | 1-indexed. |
Idempotency-Key | evt_… | Repeats X-Cord-Event-Id — several server frameworks read it from there for free. |
Never compare signatures with plain === in production — use a constant-time comparison (the
SDK already does). A regular !== can leak information via a timing attack.
Event identity & idempotency
Every logical event has a stable id (evt_…), identical in the body and in X-Cord-Event-Id/Idempotency-Key. It’s the same value:
- Across every automatic retry of the same event (see the next section).
- On a manual replay from the Workbench (“Retry” in the delivery log) — the payload is resent byte-for-byte,
idincluded.
Since retries and replays can make your endpoint receive the same event more than once, your handler needs to be idempotent: store the ids you’ve already processed (even a simple table with a unique constraint works) and silently skip duplicates before applying side effects (charging a card, sending an email, etc.).
Retries and guaranteed delivery
Cord enqueues every event before attempting delivery — if your endpoint is down, the event isn’t lost: it follows an exponential-backoff retry schedule for up to 11 attempts over ~3.6 days:
| Attempt | Wait since the previous one |
|---|---|
| 1 | immediate |
| 2 | ~10 s |
| 3 | ~1 min |
| 4 | ~5 min |
| 5 | ~30 min |
| 6 | ~2 h |
| 7 | ~5 h |
| 8 | ~10 h |
| 9–11 | ~1 day |
(With ±20% jitter so many endpoints that went down at the same time don’t all get retried in the exact same second.) Any non-2xx response, a timeout, or a network error counts as a failure and schedules the next attempt. A 3xx (redirect) is never followed — it’s treated as a failure; if your endpoint moved, update the URL in the Workbench instead of leaving a redirect in place.
Delivery is at-least-once, not exactly-once — which is exactly why deduplicating by id matters (see above).
Endpoint health
Cord tracks each endpoint’s failure streak (only events that exhausted their full retry schedule count, or a failed manual test/replay — never an attempt that still has retries ahead of it):
- 3 failures in a row → you get a warning email (at most one every 24h).
- 5 failures in a row → the endpoint is automatically disabled (stops receiving new events) and you get an email explaining why. Events still waiting to retry against that endpoint get canceled — they don’t pile up indefinitely against a dead destination.
Once you’ve fixed your endpoint, from the Workbench’s Webhooks tab you can:
- Reactivate — start receiving new events again from now on.
- Reactivate & retry — also requeues whatever failed in the last 24 hours (never the full backlog, so you don’t get bombarded with old events all at once).
Secret rotation
You can rotate an endpoint’s secret without missing a single delivery. In the Workbench, click “Rotate secret”, pick an overlap window (1h / 24h / 72h), and confirm — you’ll see the new secret in clear text once.
During the window, Cord signs every delivery with both secrets at once:
X-Cord-Signature-V1: t=1732650000,v1=<hmac with the NEW secret>,v1=<hmac with the OLD secret>
That means you can update your CORD_WEBHOOK_SECRET at any point within the window — before or after rotating on Cord’s side — without a single delivery getting rejected. The SDK already accepts whichever v1= matches. If you verify by hand and only read the first v1= you find, you’re still covered (it’s the newest one). Once the window closes, Cord stops sending the old secret.
Event catalog
| Event | Fires when… |
|---|---|
quote.sent | The seller sends the quote to the client. |
quote.viewed | The client opens the public link for the first time. |
quote.approved | The client approves (fully or partially) the quote. |
quote.rejected | The client rejects it. |
quote.updated | The seller edits and resends it (“Modify and resend” from the detail view). |
quote.expired | Its expiration date passed without a client decision (it was sent/viewed). |
quote.deleted | A draft is deleted (only applies to quotes that were never sent). |
quote.paid | Full payment is received (a simple charge, or the last pending charge of a deposit/installment plan). |
payment.partial | A SINGLE partial charge is received (deposit, balance, or one installment) without the quote being fully paid yet. |
payment.failed | A recurring retainer/subscription charge fails. |
invoice.stamped | The CFDI is stamped. |
ping | Only shows up when using “Test” from the Workbench — never fired by a real event. |
Note:
payment.partialcarries extra fields ondatathat other events don’t have:tipo(anticipo|saldo|cuota),monto,numero_cuota,saldo_pendiente, andpayment_method, in addition to the normal quote summary (folio, client, total, link). If you use the Server SDK in TypeScript,CordWebhookEventis a discriminated union onevent— comparingevent === 'payment.partial'narrows the type ofdatafor you.
Delivery log and retention
In the Workbench’s Webhooks tab, each endpoint has an expandable log with the outcome of every attempt (status, duration, your server’s response) and a “Retry” button per row. The Workbench’s Events tab shows the same thing from another angle: one logical event with all its deliveries grouped, instead of a per-endpoint log. The log is kept for 30 days; events that ultimately failed for good are kept for 90 days (so “Retry” keeps working longer in that case). Don’t use Cord’s log as long-term storage of what was sent — persist whatever you need to keep in your own system as you process each delivery.