ES EN

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). See the full catalog below.
  6. 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:

HeaderContentNotes
X-Cord-Signature-V1t=<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-Signaturesha256=<hmac_sha256(secret, body)>Legacy, no timestamp. Still sent on every delivery for compatibility; use V1 if you can.
X-Cord-Event-Idevt_…Same value as the body’s id field.
X-Cord-Delivery-IduuidThis attempt’s id — changes on every retry/replay, unlike X-Cord-Event-Id.
X-Cord-Attemptnumber1-indexed.
Idempotency-Keyevt_…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:

AttemptWait since the previous one
1immediate
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

EventFires when…
quote.sentThe seller sends the quote to the client.
quote.viewedThe client opens the public link for the first time.
quote.approvedThe client approves (fully or partially) the quote.
quote.rejectedThe client rejects it.
quote.updatedThe seller edits and resends it (“Modify and resend” from the detail view).
quote.expiredIts expiration date passed without a client decision (it was sent/viewed).
quote.deletedA draft is deleted (only applies to quotes that were never sent).
quote.paidFull payment is received (a simple charge, or the last pending charge of a deposit/installment plan).
payment.partialA SINGLE partial charge is received (deposit, balance, or one installment) without the quote being fully paid yet.
payment.failedA recurring retainer/subscription charge fails.
invoice.stampedThe CFDI is stamped.
pingOnly shows up when using “Test” from the Workbench — never fired by a real event.

Note: payment.partial carries extra fields on data that other 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). If you use the Server SDK in TypeScript, CordWebhookEvent is a discriminated union on event — comparing event === 'payment.partial' narrows the type of data for 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.