Quickstart
From zero to a verified webhook in five minutes, all in the test environment.
In this guide you create a test key, confirm which account it belongs to, add a client, build a quote for them, send it, and receive Cord’s notifications on your localhost with the signature verified. Nothing you do here touches your real data: an sk_test_ key works on your account’s test environment.
The test email does go out
Sending a test quote emails the client for real, with the subject marked as a test. Use your own email address in step 4.
-
Create a test key. In the app, turn on Developer mode in Your account › Preferences and open the Developers pill in the bottom-right corner. In the API keys tab, create a read and write Secret Key in test mode (if the app is already in test mode, the key is created as a test key). It starts with
sk_test_and is shown only once: copy it.The app in the test environment with the Workbench open on the API keys tab: the newly created read and write secret key is shown in full only once, sk_test_ followed by 48 characters, with its Copy button; behind it, the new row already shows in the table as Test and Secret. Store it in a terminal variable. The Cord CLI reads it from there too:
export CORD_API_KEY="sk_test_..." -
Check the key.
GET /api/v1/mereturns the key’s organization, permission and mode:curl https://cordhq.app/api/v1/me \ -H "Authorization: Bearer $CORD_API_KEY"{ "data": { "org": { "id": "5b1f7d9e-3c2a-4e8b-9f61-0a7c4d2e8b13", "nombre": "Aceros del Norte", "plan": "pro" }, "scope": "write", "mode": "test" } }If you see
"mode": "test", everything that follows happens in your test environment. A401withcode: "invalid_key"means the key was copied wrong or was revoked. -
Listen for webhooks on your machine. Cord can’t call your
localhost, so the CLI opens a listening session and forwards each event to you with the real signature headers. First, the server that receives them. Install the SDK in an empty folder:npm init -y && npm install @flouviahq/nodeSave this as
server.mjs:import { createServer } from 'node:http'; import { constructEvent, CordWebhookSignatureError } from '@flouviahq/node'; createServer(async (req, res) => { if (req.method !== 'POST' || req.url !== '/webhooks/cord') { res.writeHead(404).end(); return; } // The signature is computed over the RAW body: don't parse it before verifying. const chunks = []; for await (const chunk of req) chunks.push(chunk); try { const event = await constructEvent(Buffer.concat(chunks), req.headers, process.env.CORD_WEBHOOK_SECRET); console.log(event.event, event.id, event.data.id); res.writeHead(200).end('ok'); } catch (err) { if (err instanceof CordWebhookSignatureError) { console.error('Signature rejected:', err.code); res.writeHead(400).end(err.code); return; } throw err; } }).listen(3000, () => console.log('Listening on http://localhost:3000/webhooks/cord'));In a second terminal (with the same
CORD_API_KEY), open the listening session:npx @flouviahq/cli listen --forward-to http://localhost:3000/webhooks/cordThe CLI prints the session secret (
whsec_…). Start your server with it in the first terminal:CORD_WEBHOOK_SECRET="whsec_..." node server.mjsLeave both running. The session expires in 24 hours and Ctrl+C closes it.
-
Create a client. With an email address of yours:
curl https://cordhq.app/api/v1/clientes \ -H "Authorization: Bearer $CORD_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "empresa": "Ferretería La Prueba", "contacto": "Ana Torres", "email": "your-email@example.com" }'{ "data": { "id": "0e6f2b8a-91c4-4d7e-b3a5-6c2d8f1e4a90" } }Your server prints the first event:
client.created evt_4c1e9a7b2d5f8e0a3b6c9d2e5f8a1b4c 0e6f2b8a-91c4-4d7e-b3a5-6c2d8f1e4a90 -
Create the quote. Use the client’s
id. Withouttax_rate, each line takes the default rate from your account’s tax catalog; withoutbase_currency, the quote uses your account’s currency.curl https://cordhq.app/api/v1/cotizaciones \ -H "Authorization: Bearer $CORD_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 6a1d3f5e-8b2c-4e7a-9d0f-1c3e5a7b9d2f" \ -d '{ "cliente_id": "0e6f2b8a-91c4-4d7e-b3a5-6c2d8f1e4a90", "items": [ { "descripcion": "Window frame installation", "cantidad": 1, "precio_unitario": 12000 } ], "vigencia_dias": 15 }'{ "data": { "id": "8b0c6f0e-2d55-4c1e-9a43-6a2f1c7d9e10", "token": "3f9a1c2e7b4d6f8a0b1c2d3e4f5a6b7c", "folio": "COT-0001", "link_publico": "https://cordhq.app/q/3f9a1c2e7b4d6f8a0b1c2d3e4f5a6b7c", "needs_approval": false, "motivo": null } }The quote starts as a draft and your server receives
quote.created. TheIdempotency-Keyheader makes retrying safe: with the same key and the same body, Cord returns this same response instead of creating a second quote. Generate a new key (for example withuuidgen) for each different quote. -
Send it. Sending generates the link and emails the client:
curl https://cordhq.app/api/v1/cotizaciones/8b0c6f0e-2d55-4c1e-9a43-6a2f1c7d9e10 \ -H "Authorization: Bearer $CORD_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "action": "send" }'{ "data": { "ok": true, "status": "sent", "email": { "sent": true } } }If the client had no email,
emailwould come back as{ "sent": false, "skipped": "el cliente no tiene correo" }and the quote would still be sent. Your server receives:quote.sent evt_9d2a6c4e1f8b3a5d7c0e2f4a6b8d1c3e 8b0c6f0e-2d55-4c1e-9a43-6a2f1c7d9e10 -
Simulate the client. Open the link from the email or trigger the view with a simulator. In another terminal:
npx @flouviahq/cli simulate quote 8b0c6f0e-2d55-4c1e-9a43-6a2f1c7d9e10 vistaquote.viewedarrives. To close the loop, approve it through the API ({ "action": "approve" }) andquote.approvedarrives.
What each notification contains
Every delivery is a POST with this body. id is the event’s identity (the same on every retry: deduplicate with it) and data is the quote summary:
{
"id": "evt_9d2a6c4e1f8b3a5d7c0e2f4a6b8d1c3e",
"event": "quote.sent",
"created_at": "2026-10-07T17:04:12.318Z",
"data": {
"id": "8b0c6f0e-2d55-4c1e-9a43-6a2f1c7d9e10",
"folio": "COT-0001",
"status": "sent",
"moneda": "MXN",
"total": 13920,
"cliente": "Ferretería La Prueba",
"cliente_id": "0e6f2b8a-91c4-4d7e-b3a5-6c2d8f1e4a90",
"link_publico": "https://cordhq.app/q/3f9a1c2e7b4d6f8a0b1c2d3e4f5a6b7c"
}
}
The total in this example includes the 16% VAT of a Mexican account; yours will use your rates. Every amount travels with its moneda (currency).
The same with the Node SDK
import { Cord } from '@flouviahq/node';
const cord = new Cord(process.env.CORD_API_KEY);
const me = await cord.me();
const cliente = await cord.clients.create({ empresa: 'Ferretería La Prueba', email: 'your-email@example.com' });
const cotizacion = await cord.quotes.create({
cliente_id: cliente.id,
items: [{ descripcion: 'Window frame installation', cantidad: 1, precio_unitario: 12000 }],
});
await cord.quotes.send(cotizacion.id);
console.log(me.mode, cotizacion.folio, cotizacion.link_publico);
The SDK sends an Idempotency-Key on every write and repeats it on its retries. See Node SDK.
Next steps
- Webhooks: register a real endpoint, retries, secret rotation and manual signature verification.
- Events: the 41 event types and the history API.
- Quotes API: approve, reject, mark as paid and delete.
- Error handling: every code and when to retry.
- Before going live, create an
sk_live_key and register your endpoint in the Workbench: thecord listensecret only works for its session.