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 in Your account › Preferences — this reveals the
“Developers” pill in the bottom-right corner, visible across the whole app. Click
it or press
Wto open. See the full tour at Cord Workbench. - Open the Webhooks tab of the panel.
- Click Add endpoint — opens a full-screen wizard.
- Pick the events you want to receive: they’re grouped by family, with search and a checkbox per family. All events (or checking none) sends you all of them, including event types Cord adds in the future. See the full catalog below.
- Enter your URL — it must be
https://; Cord rejectshttp://destinations,localhost, private/internal IPs, and.internal/.localhosts at creation time. - Save your secret (
whsec_…) — it’s shown in clear text once. After that it’s masked. From that same screen you can Send test event.
Your team can configure 16 endpoints on Free, Starter, and Professional, 32 on Scale, and 100 on Developer. Subscriptions created by integrations through the API have a separate allowance of 100 per organization on every plan. Webhooks are NOT gated behind a paid plan.
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": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"folio": "COT-0148",
"status": "paid",
"moneda": "MXN",
"total": 45200.00,
"cliente": "Distribuidora El Zarco",
"cliente_id": "9a1c3e5f-7b2d-4f6a-8c0e-1d3b5f7a9c2e",
"link_publico": "https://cordhq.app/q/6f2a9c4e1b7d3f8a0c5e2b9d4f1a6c3e"
}
}
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 constructEvent from @flouviahq/node (see the
Node SDK) so you don’t have to
implement verification by hand. It requires X-Cord-Signature-V1 with a timestamp, compares in
constant time and rejects a delivery older than 5 minutes:
import { constructEvent } from '@flouviahq/node';
const event = await constructEvent(
await req.text(),
req.headers,
process.env.CORD_WEBHOOK_SECRET!,
);
Every delivery carries these headers:
| Header | Content | Notes |
|---|---|---|
Content-Type |
application/json |
|
User-Agent |
Cord-Webhooks/1.0 |
|
X-Cord-Event |
quote.paid, ping… |
The event type, same as the body’s event field. Handy for routing without parsing. |
X-Cord-Timestamp |
unix seconds | The same t as in X-Cord-Signature-V1: the moment this attempt was signed. |
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. |
Verify the signature by hand
If you don’t use the SDK, this is the complete algorithm. It’s what constructEvent does:
- Read the raw body, exactly as it arrived. Parsed and re-serialized JSON won’t match.
- From the
X-Cord-Signature-V1header taketand everyv1value (two arrive during a rotation). - Reject the delivery if
tis more than 300 seconds away from your clock: it’s a replay. - Compute
HMAC-SHA256(secret, "<t>.<raw body>")in hex. - Accept if it matches any of the
v1values, comparing in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyCordSignature(rawBody, header, secret, toleranceSec = 300) {
if (!header) return false;
let t = null;
const signatures = [];
for (const part of header.split(',')) {
const i = part.indexOf('=');
if (i < 0) continue;
const k = part.slice(0, i).trim();
const v = part.slice(i + 1).trim();
if (k === 't' && /^\d+$/.test(v)) t = Number(v);
else if (k === 'v1' && /^[0-9a-f]{64}$/i.test(v)) signatures.push(Buffer.from(v, 'hex'));
}
if (t === null || signatures.length === 0) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
return signatures.some((s) => s.length === expected.length && timingSafeEqual(s, expected));
}
With Express, ask for the raw body on that route only:
import express from 'express';
import { verifyCordSignature } from './verify-cord.js';
const app = express();
app.post('/webhooks/cord', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyCordSignature(req.body, req.get('X-Cord-Signature-V1'), process.env.CORD_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
// Deduplicate by event.id before applying side effects.
res.sendStatus(200);
});
app.listen(3000);
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.
The ping test event
With ping selected (the default option), an endpoint’s Test button in the Workbench sends a delivery with event: "ping" through the same path as a real event (signature, headers and log). It uses sample data shaped like a quote plus a mensaje field. Every test carries "test": true at the top level of the payload:
{
"id": "evt_0b7e3d9a1c5f2e8b4a6d0c9f3e1b7a5d",
"event": "ping",
"created_at": "2026-10-07T17:12:09.441Z",
"test": true,
"data": {
"id": "00000000-0000-0000-0000-000000000000",
"folio": "COT-PRUEBA",
"status": "sent",
"moneda": "MXN",
"total": 12500,
"cliente": "Cliente de prueba S.A. de C.V.",
"cliente_id": null,
"link_publico": "https://cordhq.app/q/demo",
"mensaje": "Esta es una entrega de prueba enviada desde el Workbench de Cord."
}
}
Instead of ping you can pick any event the endpoint is subscribed to: it arrives with its real type, sample data in the endpoint’s API version and "test": true too. A test is attempted only once: if it fails, it doesn’t enter the retry schedule. Tests don’t land in the event history or trigger Workflows. Respond 2xx and discard them. To test with any of the 42 real events and the data of a test object, use the webhook simulator or cord trigger from the CLI.
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 network error or not answering within 5 seconds counts as a failure and schedules the next attempt. Respond fast and process later: store the event and answer 200 before doing heavy work. 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.
The times in the table are minimums
The first attempt goes out at the moment of the event. Retries are run by a sweep of the delivery engine that runs every 5 minutes, so each retry goes out in the first sweep after the time the table shows. If your endpoint was down, don’t wait for the next retry: recover what you missed with the event history.
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.
Both emails go to the account owner, in your account’s language (Settings › General).
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>
The order is fixed: the new secret’s v1= comes first and the old secret’s comes last. That lets you update your CORD_WEBHOOK_SECRET at any point within the window without a single delivery getting rejected, as long as your verification compares every v1= in the header. The SDK already accepts whichever one matches. If you verify by hand, don’t keep a single value: reading only the first one requires that you already have the new secret, and a parser that keeps the last v1= (for example, one that builds a key-value map) only validates with the old secret during the window, so it fails as soon as you switch to the new one before the window closes.
The legacy X-Cord-Signature signature holds a single value, so during the window it’s computed with the old secret; once the window closes it’s signed with the new one. When the window closes, Cord stops signing with the old secret in both headers.
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 or milestone plan). |
payment.partial |
A SINGLE partial charge is received (deposit, balance, one installment, or a payment of a milestone plan) without the quote being fully paid yet. |
quote.milestone_completed |
The business marked as completed a payment of a milestone plan that is due “on completion”: from then on the client can pay it. Carries cobro_id, numero, etiqueta, monto and vence. |
payment.failed |
A recurring retainer/subscription charge fails. |
invoice.stamped |
The CFDI is stamped (comes from a quote, see note below). |
invoice.issued |
A standard invoice, not filed with a tax authority, is issued outside Mexico (comes from a quote). |
invoice.finalized |
A Cord Invoicing invoice is finalized — the standalone invoice object, not tied to a quote. |
invoice.sent |
The seller sends that invoice to the client. |
invoice.paid |
The invoice is paid in full. |
invoice.payment_failed |
A charge against that invoice fails (declined card, failed direct debit). |
invoice.voided |
The invoice is voided. |
invoice.marked_uncollectible |
The invoice is marked uncollectible. |
invoice.overdue |
Its due date passed without being settled (fired by the reminders cron). |
quote.created |
A quote is created, from the app, the API, MCP, or Cord Elements. |
quote.approval_requested |
A new quote is waiting for internal approval (discount, amount, or margin outside the threshold). Includes motivo. |
quote.approval_decided |
Management approves or rejects that request. Includes decision (approved | rejected). |
quote.comment_added |
The client or the seller writes in the quote conversation. Includes autor (cliente | vendedor), mensaje and, for a line comment, item_id; a counteroffer includes tipo: "contraoferta" and propuesta. |
client.created / client.updated / client.deleted |
A client is created, edited, or deleted in the directory. |
product.created / product.updated / product.deleted |
A product is created, edited, or deleted in the catalog. |
task.created / task.completed |
A task is created (from the app, the API, MCP, a workflow, or by Cord for a chargeback or SPEI refund) or marked as done. |
promise.created / promise.kept / promise.broken |
A payment promise is recorded, or marked as kept or broken. |
dispute.created / dispute.closed |
A client opens a dispute on a Cord Payments charge, or it closes with its outcome in estado (won | lost | warning_closed). Includes monto, moneda, motivo, and fecha_limite to respond, and in facturas the invoices that charge paid, which are no longer settled. |
refund.succeeded / refund.failed |
A refund succeeds or fails. Includes monto, moneda, motivo, and motivo_falla when it fails. |
payout.paid / payout.failed |
A payout to your bank account is paid or fails. Includes monto, moneda, llegada, and motivo_falla when it fails. |
account.updated |
What your payments account can do changes: puede_cobrar, puede_depositar, pendientes (items to verify), and motivo_bloqueo. Only sent when one of them changes. |
ping |
Only shows up when using “Test” from the Workbench — never fired by a real event. |
Dispute, refund, and payout events include
referencia, the transaction identifier at the payment processor, and are sent once per transaction and outcome. When the charge comes from a quote, they also includecotizacion_id,folio, andcliente.
Note:
payment.partialcarries extra fields ondatathat other quote events don’t have:tipo(anticipo|saldo|cuota|hito),monto,numero_cuota,etiqueta(the payment’s name in a milestone plan),saldo_pendiente, andpayment_method, in addition to the normal quote summary (folio, client, total, link).
Two event families, two shapes of data. The quote.* and payment.* events (including
invoice.stamped/invoice.issued, which fire from the quote) share the data shape from the
example above (folio, status, total, cliente, link_publico…). The seven events
invoice.finalized/sent/paid/payment_failed/voided/marked_uncollectible/overdue
belong to Cord Invoicing — the invoice as its own object, with its own lifecycle and its
own public link, not something derived from a quote — and carry a DIFFERENT data shape:
{
"id": "evt_7c3e9a1f5b2d8e4a0c6f3b9d1e7a5c2f",
"event": "invoice.paid",
"created_at": "2026-08-20T14:00:00.000Z",
"data": {
"id": "d4a1b3c5-7e9f-4a2b-8c4d-6e8f0a2b4c6d",
"object": "invoice",
"numero": "FAC-0032",
"folio_fiscal": "3F2A9C1E-7B4D-4E6A-8C2F-1D5B9E3A7C40",
"estado": "paid",
"estado_fiscal": "issued",
"pais": "MX",
"tipo": "cfdi_40",
"moneda": "MXN",
"total": 12500.00,
"pagado": 12500.00,
"saldo": 0,
"vence": "2026-08-30",
"cliente": "Distribuidora El Zarco",
"cotizacion_id": null,
"link_publico": "https://cordhq.app/i/9c1f4e7a2b5d8c0f3a6e9b2d5f8a1c4e"
}
}estado is the invoice lifecycle (draft, open, paid, void, or uncollectible).
estado_fiscal is the registration with the tax provider: pending, issued, cancelled, or
error. tipo is the document type: cfdi_40, cfdi_egreso, proforma,
commercial_invoice, commercial_credit_note, verifactu_invoice, or verifactu_credit_note.
Note the public link: /i/{token} for a Cord Invoicing invoice, versus /q/{token} for a
quote. If you use @flouviahq/node in TypeScript, constructEvent returns CordWebhookEvent,
a union discriminated by event that already types all 42 events in the catalog, including
the seven Cord Invoicing invoice.* events with the shape above.
Shape of data for clients, products, tasks, and promises
Each object includes object so you can tell them apart. The product event never includes cost or margin.
object |
Fields |
|---|---|
client |
id, empresa, contacto, email, telefono, rfc, terminos, country_code, language (their document language; null = the organization’s) (on client.deleted: only id and empresa) |
product |
id, sku, nombre, unidad, precio_lista, activo (on product.deleted: only id and nombre) |
task |
id, titulo, due_date, done, cotizacion_id (no assignee, priority, or notes) |
promise |
id, cotizacion_id, fecha_promesa, monto, moneda, estado |
Managing endpoints through the API
Platforms like Zapier or Make create and remove their own subscriptions. These routes exist for that:
| Method and route | Scope | What it does |
|---|---|---|
GET /api/v1/webhooks |
read |
Lists the endpoints created by this same key, without secrets. |
POST /api/v1/webhooks |
write |
Creates an endpoint. Body: { "url": "https://…", "eventos": ["quote.approved"] }. Returns id, url, eventos, and secret (in clear text once). |
DELETE /api/v1/webhooks/{id} |
write |
Deletes an endpoint created by this same key. |
The same URL rules as in the Workbench apply: only https:// to public hosts. These subscriptions don’t count against your team’s endpoints: they have their own allowance of 100 per organization (integration_limit_reached).
A key cannot see or delete endpoints created from the Workbench or by another key. When you revoke a key, its endpoints are deactivated: if you disconnect an integration, it stops receiving data.
Event history through the API
If your system was down longer than retries cover, or you prefer polling over receiving, GET /api/v1/events (scope read) returns your organization events from newest to oldest, whether or not any endpoint is subscribed:
curl "https://cordhq.app/api/v1/events?type=quote.approved&limit=50" \
-H "Authorization: Bearer sk_live_tU..."
{
"data": [
{
"id": "6b0e2f4a-8c1d-4e3b-9a5f-7d2c0e8b1a46",
"type": "quote.approved",
"object": "quote",
"object_id": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"data": { "id": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "folio": "COT-0148", "status": "approved", "moneda": "MXN", "total": 45200 },
"actor": "client",
"created_at": "2026-09-14T18:32:04.123Z"
}
],
"meta": { "next_cursor": "MjAyNi0wOS0xNFQxODozMjowNC4xMjMwMDBafDZiMGUyZjRhLThjMWQtNGUzYi05YTVmLTdkMmMwZThiMWE0Ng" }
}
- Optional filters:
type(an event from the catalog) andobject_id. next_cursoris an opaque token: repeat the request with?cursor=<next_cursor>. It isnullwhen there are no more pages.actortells you who caused the event:user:<id>,api:<key>,mcp:<key>,client(the client on the public link),workflow:<id>,systemand others. See the full list.- The history’s
idis the record’s UUID, different from the webhook’sevt_…. - Unlike the webhook,
datadoes not includelink_publico: the link is a credential and is not stored in the history.
Delivery log and retention
In the Workbench’s Webhooks tab, each endpoint has an expandable log with its last 50 deliveries (event, attempt, status, duration and date). Each one opens to show what Cord sent (the exact payload), what your server answered and the Retry button. The Workbench’s Events tab shows the same thing from another angle: one logical event with its delivery to each endpoint, instead of a per-endpoint log, and the Resend button. 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.
Viewing the delivery log, Retry on a delivery, Resend on an event and Reactivate & retry on an endpoint require the Professional plan or higher. Creating endpoints, receiving events, Test, Rotate secret and Reactivate are available on every plan.