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 paid state.
  • Send an internal Slack message the first time a client views a quote.
  • Trigger your ERP’s billing process once a CFDI is stamped.

Setting up an endpoint

  1. 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.
  2. Open the Webhooks tab of the panel.
  3. Click Add endpoint — opens a full-screen wizard.
  4. Enter your URL — it must be https://; Cord rejects http:// destinations, localhost, private/internal IPs, and .internal/.local hosts at creation time.
  5. Pick the subset of events you want (leaving all unchecked = you receive all of them, including event types Cord adds in the future). See the full catalog below.
  6. Save your secret (whsec_…) — it’s shown in clear text once. After that it’s masked.

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": "b3f1...",
    "folio": "COT-0148",
    "status": "paid",
    "moneda": "MXN",
    "total": 45200.00,
    "cliente": "Distribuidora El Zarco",
    "cliente_id": "9a1c...",
    "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, id included.

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 (comes from a quote, see note below).
invoice.issued A commercial invoice 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 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.
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 include cotizacion_id, folio, and cliente.

Note: payment.partial carries extra fields on data that other quote events don’t have: tipo (anticipo|saldo|cuota), monto, numero_cuota, saldo_pendiente, and payment_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_...",
  "event": "invoice.paid",
  "created_at": "2026-08-20T14:00:00.000Z",
  "data": {
    "id": "d4a1...",
    "object": "invoice",
    "numero": "FAC-0032",
    "folio_fiscal": "3f2a...-uuid-cfdi",
    "estado": "paid",
    "estado_fiscal": "stamped",
    "pais": "MX",
    "tipo": "factura",
    "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/9c1f..."
  }
}

Note the public link: /i/{token} for a Cord Invoicing invoice, versus /q/{token} for a quote. If you use the Server SDK in TypeScript, CordWebhookEvent today only types the discriminated union of quote.*/payment.* events — for the seven Cord Invoicing invoice.* events, type data by hand against the shape above until the SDK covers it.

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 (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
promise id, cotizacion_id, fecha_promesa, monto, 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": "6b0e...",
      "type": "quote.approved",
      "object": "quote",
      "object_id": "b3f1...",
      "data": { "id": "b3f1...", "folio": "COT-0148", "status": "approved", "moneda": "MXN", "total": 45200 },
      "actor": "client",
      "created_at": "2026-09-14T18:32:04.123Z"
    }
  ],
  "meta": { "next_cursor": "MjAyNi0wOS0x..." }
}
  • Optional filters: type (an event from the catalog) and object_id.
  • next_cursor is an opaque token: repeat the request with ?cursor=<next_cursor>. It is null when there are no more pages.
  • actor tells you who caused the event: user:<id>, api:<key>, mcp:<key>, client (the client on the public link), or system.
  • Unlike the webhook, data does not include link_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 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.