Webhooks
Reacciona instantáneamente a cambios de estado en tus cotizaciones sin tener que hacer polling a la API.
¿Por qué usar Webhooks?
En lugar de que tu sistema (un ERP, un flujo de Zapier/Make, o tu propio backend) le pregunte a Cord cada 5 minutos “¿ya pagó el cliente?”, los webhooks de Cord le avisan a tu sistema en el momento en que ocurre el evento, con un POST HTTP firmado a tu servidor.
Casos de uso comunes:
- Provisionar una licencia de software automáticamente cuando una cotización entra en estado
paid. - Enviar un mensaje interno a Slack cuando una cotización es
viewedpor primera vez por el cliente. - Disparar el proceso de facturación en tu ERP cuando se timbra el CFDI.
Configurar un endpoint
- Activa el Modo desarrollador al final del índice de Ajustes — esto revela la barra “Desarrolladores” al fondo de la pantalla, visible en toda la app. Ábrela con un clic. Ver el tour completo en Cord Workbench.
- Abre la pestaña Webhooks del panel.
- Haz clic en Agregar endpoint — abre un asistente de pantalla completa.
- Ingresa tu URL — debe ser
https://; Cord rechaza destinoshttp://,localhost, IPs privadas/internas y hosts.internal/.localdesde la creación. - Elige el subconjunto de eventos que quieres recibir (dejar todos sin marcar = recibes todos, incluidos los tipos de evento que Cord agregue en el futuro). Ver el catálogo completo abajo.
- Guarda una copia de tu secret (
whsec_…) — se muestra en claro una sola vez. Después solo se ve enmascarado.
Tu equipo puede configurar 16 endpoints en Gratis, Starter y Profesional, 32 en Scale y 100 en Developer. Las suscripciones que crean las integraciones por API tienen un cupo aparte de 100 por organización en todos los planes. Los webhooks NO están gateados a un plan de pago.
El payload
Cada entrega es un POST con Content-Type: application/json y este cuerpo:
{
"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": "Solo presente en el evento de prueba ping"
}
}
id es la identidad del evento, no de la cotización — es el mismo valor a través de reintentos y de un replay manual (ver identidad e idempotencia). data.id es el id interno de la cotización.
Firmas y seguridad
Cord firma cada entrega con dos headers. Usa el SDK (@flouviahq/elements/server, ver la
guía del Server SDK) para no tener que
implementar la verificación a mano:
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!,
);
Si prefieres verificar a mano:
| Header | Contenido | Notas |
|---|---|---|
X-Cord-Signature-V1 |
t=<unix>,v1=<hmac_sha256(secret, "<t>.<body>")> |
Recomendado. Incluye timestamp — protección real contra replay. Puede traer dos v1= durante una rotación de secreto (ver abajo); acepta si cualquiera cuadra. |
X-Cord-Signature |
sha256=<hmac_sha256(secret, body)> |
Legacy, sin timestamp. Se sigue enviando en cada entrega por compatibilidad; usa V1 si puedes. |
X-Cord-Event-Id |
evt_… |
Igual al campo id del body. |
X-Cord-Delivery-Id |
uuid | Id de este intento — cambia en cada reintento/replay, a diferencia de X-Cord-Event-Id. |
X-Cord-Attempt |
número | 1-indexado. |
Idempotency-Key |
evt_… |
Repite X-Cord-Event-Id — varios frameworks de servidor lo leen de ahí directo. |
Nunca compares las firmas con === a mano en producción — usa una comparación de tiempo
constante (el SDK ya lo hace). Un !== normal puede filtrar información por timing attack.
Identidad del evento e idempotencia
Cada evento lógico tiene un id estable (evt_…), igual en el body y en X-Cord-Event-Id/Idempotency-Key. Es el mismo valor:
- En cada reintento automático de un mismo evento (ver la siguiente sección).
- En un replay manual desde el Workbench (“Reintentar” en el log de entregas) — el payload se reenvía byte-idéntico, incluido el
id.
Como los reintentos y los replays pueden hacer que tu endpoint reciba el mismo evento más de una vez, tu handler debe ser idempotente: guarda los id ya procesados (aunque sea en una tabla simple con unique) y descarta silenciosamente los repetidos antes de aplicar efectos secundarios (cobrar, mandar un correo, etc.).
Reintentos y entrega garantizada
Cord encola cada evento antes de intentar entregarlo — si tu endpoint está caído, el evento no se pierde: sigue un calendario de reintentos con backoff exponencial hasta por 11 intentos en ~3.6 días:
| Intento | Espera desde el anterior |
|---|---|
| 1 | inmediato |
| 2 | ~10 s |
| 3 | ~1 min |
| 4 | ~5 min |
| 5 | ~30 min |
| 6 | ~2 h |
| 7 | ~5 h |
| 8 | ~10 h |
| 9–11 | ~1 día |
(Con jitter de ±20% para que muchos endpoints caídos al mismo tiempo no reciban el reintento exactamente en el mismo segundo.) Cualquier respuesta que no sea 2xx, un timeout, o un error de red cuenta como fallo y programa el siguiente intento. Un 3xx (redirect) nunca se sigue — se trata como fallo; si tu endpoint cambió de URL, actualízala en el Workbench en vez de dejar un redirect.
La entrega es al menos una vez (at-least-once), no exactamente una vez — de ahí la importancia de deduplicar por id (ver arriba).
Salud del endpoint
Cord vigila la racha de fallos de cada endpoint (solo cuenta eventos que agotaron su calendario de reintentos, o fallos de una prueba/replay manual — nunca un intento que todavía tiene reintentos por delante):
- 3 fallos seguidos → recibes un correo de aviso (como máximo uno cada 24 h).
- 5 fallos seguidos → el endpoint se desactiva automáticamente (deja de recibir eventos nuevos) y recibes un correo explicando por qué. Los eventos que seguían pendientes de reintento para ese endpoint se cancelan — no se acumulan indefinidamente contra un destino caído.
Cuando arregles tu endpoint, desde la pestaña Webhooks del Workbench puedes:
- Reactivar — vuelve a recibir eventos nuevos desde ahora.
- Reactivar y reintentar — además, reencola lo que falló en las últimas 24 horas (nunca el backlog completo, para no bombardearte con eventos viejos de golpe).
Rotación de secreto
Puedes rotar el secreto de un endpoint sin perder ni una entrega. En el Workbench, botón “Rotar secreto”, elige una ventana de solape (1h / 24h / 72h) y confirma — verás el secreto nuevo en claro una sola vez.
Durante la ventana, Cord firma cada entrega con ambos secretos a la vez:
X-Cord-Signature-V1: t=1732650000,v1=<hmac con el secreto NUEVO>,v1=<hmac con el secreto VIEJO>
Esto significa que puedes actualizar tu CORD_WEBHOOK_SECRET en cualquier momento dentro de la ventana — antes o después de rotar en Cord — sin que ninguna entrega se rechace. El SDK ya acepta cualquiera de los dos v1= que cuadre. Si verificas a mano y solo lees el primer v1= que encuentres, sigues funcionando igual (es el más nuevo). Al cerrarse la ventana, Cord deja de mandar el secreto viejo.
Catálogo de eventos
| Evento | Se dispara cuando… |
|---|---|
quote.sent |
El vendedor envía la cotización al cliente. |
quote.viewed |
El cliente abre el link público por primera vez. |
quote.approved |
El cliente aprueba (total o parcialmente) la cotización. |
quote.rejected |
El cliente la rechaza. |
quote.updated |
El vendedor la modifica y la reenvía (“Modificar y reenviar” en el detalle). |
quote.expired |
Pasó su fecha de vigencia sin que el cliente decidiera (estaba sent/viewed). |
quote.deleted |
Se elimina un borrador (solo aplica a cotizaciones que nunca se enviaron). |
quote.paid |
Se recibe el pago total (cobro simple o el último cobro pendiente de un plan por anticipo/cuotas). |
payment.partial |
Se recibe UN cobro parcial (anticipo, saldo o una cuota) sin que la cotización quede totalmente pagada todavía. |
payment.failed |
Falla un cobro recurrente de una iguala/retainer. |
invoice.stamped |
Se timbra el CFDI (viene de una cotización, ver nota abajo). |
invoice.issued |
Se emite una factura comercial fuera de México (viene de una cotización). |
invoice.finalized |
Se emite una factura de Cord Invoicing — el objeto de factura standalone, no atado a una cotización. |
invoice.sent |
El vendedor envía esa factura al cliente. |
invoice.paid |
La factura queda totalmente pagada. |
invoice.payment_failed |
Falla un cobro contra esa factura (tarjeta rechazada, domiciliación fallida). |
invoice.voided |
Se anula la factura. |
invoice.marked_uncollectible |
Se marca la factura como incobrable. |
invoice.overdue |
Pasó su fecha de vencimiento sin liquidarse (lo dispara el cron de recordatorios). |
quote.created |
Se crea una cotización, desde la app, la API, el MCP o Cord Elements. |
quote.approval_requested |
Una cotización nueva queda pendiente de aprobación interna (descuento, monto o margen fuera de umbral). Trae motivo. |
quote.approval_decided |
Gerencia aprueba o rechaza esa solicitud. Trae decision (approved | rejected). |
quote.comment_added |
El cliente o el vendedor escribe en la conversación de la cotización. Trae autor (cliente | vendedor), mensaje y, si es sobre una línea, item_id; una contraoferta trae tipo: "contraoferta" y propuesta. |
client.created / client.updated / client.deleted |
Se crea, edita o elimina un cliente del directorio. |
product.created / product.updated / product.deleted |
Se crea, edita o elimina un producto del catálogo. |
task.created / task.completed |
Se crea una tarea o se marca como hecha. |
promise.created / promise.kept / promise.broken |
Se registra una promesa de pago, o se marca como cumplida o incumplida. |
dispute.created / dispute.closed |
Un cliente abre un contracargo sobre un cobro de Cord Payments, o se cierra con su resultado en estado (won | lost | warning_closed). Trae monto, moneda, motivo y fecha_limite para responder. |
refund.succeeded / refund.failed |
Un reembolso se completa o falla. Trae monto, moneda, motivo y, si falla, motivo_falla. |
payout.paid / payout.failed |
Un depósito a tu cuenta bancaria se paga o falla. Trae monto, moneda, llegada y, si falla, motivo_falla. |
account.updated |
Cambia lo que tu cuenta de cobros puede hacer: puede_cobrar, puede_depositar, pendientes (datos por verificar) y motivo_bloqueo. Solo se emite cuando alguno cambia. |
ping |
Solo aparece al usar “Probar” desde el Workbench — nunca lo dispara un evento real. |
Los eventos de contracargos, reembolsos y depósitos traen
referencia, el identificador del movimiento en el procesador de pagos, y se emiten una sola vez por movimiento y resultado. Cuando el cobro viene de una cotización, también traencotizacion_id,folioycliente.
Nota:
payment.partialtrae campos extra endataque los demás eventos de cotización no tienen:tipo(anticipo|saldo|cuota),monto,numero_cuota,saldo_pendienteypayment_method, además del resumen normal de la cotización (folio, cliente, total, link).
Dos familias de eventos, dos formas de data. Los eventos quote.* y payment.*
(incluidos invoice.stamped/invoice.issued, que se disparan desde la cotización) comparten
la forma de data del ejemplo de arriba (folio, status, total, cliente,
link_publico…). Los siete eventos invoice.finalized/sent/paid/payment_failed/
voided/marked_uncollectible/overdue pertenecen a Cord Invoicing — la factura como
objeto propio, con su propio ciclo de vida y su propio link público, no un derivado de la
cotización — y traen una forma de data DISTINTA:
{
"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..."
}
}Nota el link público: /i/{token} para una factura de Cord Invoicing, contra /q/{token}
para una cotización. Si usas el Server SDK en TypeScript, hoy CordWebhookEvent solo tipa la
unión discriminada de los eventos quote.*/payment.* — para los siete eventos invoice.*
de Cord Invoicing, tipa el data manualmente contra la forma de arriba hasta que el SDK lo
cubra.
Forma de data en clientes, productos, tareas y promesas
Cada objeto trae object para distinguirlo. El evento de producto nunca incluye el costo ni el margen.
object |
Campos |
|---|---|
client |
id, empresa, contacto, email, telefono, rfc, terminos, country_code (en client.deleted: solo id y empresa) |
product |
id, sku, nombre, unidad, precio_lista, activo (en product.deleted: solo id y nombre) |
task |
id, titulo, due_date, done, cotizacion_id |
promise |
id, cotizacion_id, fecha_promesa, monto, estado |
Administrar endpoints por API
Plataformas como Zapier o Make crean y borran sus suscripciones solas. Para eso existen:
| Método y ruta | Scope | Qué hace |
|---|---|---|
GET /api/v1/webhooks |
read |
Lista los endpoints que creó esta misma llave, sin secretos. |
POST /api/v1/webhooks |
write |
Crea un endpoint. Cuerpo: { "url": "https://…", "eventos": ["quote.approved"] }. Devuelve id, url, eventos y secret (en claro una sola vez). |
DELETE /api/v1/webhooks/{id} |
write |
Borra un endpoint creado por esta misma llave. |
Aplican las mismas reglas de URL que en el Workbench: solo https:// hacia hosts públicos. Estas suscripciones no cuentan contra los endpoints de tu equipo: tienen su propio cupo de 100 por organización (integration_limit_reached).
Una llave no ve ni puede borrar los endpoints creados desde el Workbench ni los de otra llave. Al revocar una llave, sus endpoints se desactivan: si desconectas una integración, deja de recibir datos.
Historial de eventos por API
Si tu sistema estuvo caído más tiempo del que cubren los reintentos, o prefieres consultar en vez de recibir, GET /api/v1/events (scope read) devuelve los eventos de tu organización del más nuevo al más viejo, haya o no endpoints suscritos:
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..." }
}
- Filtros opcionales:
type(un evento del catálogo) yobject_id. next_cursores un token opaco: repite la petición con?cursor=<next_cursor>. Vienenullcuando no hay más.actorindica quién originó el evento:user:<id>,api:<llave>,mcp:<llave>,client(el cliente en el link público) osystem.- A diferencia del webhook,
datano incluyelink_publico: el link es una credencial y no se guarda en el historial.
Log de entregas y retención
En la pestaña Webhooks del Workbench, cada endpoint tiene un log expandible con el resultado de cada intento (status, duración, respuesta de tu servidor) y un botón “Reintentar” por fila. La pestaña Eventos del Workbench muestra lo mismo desde otro ángulo: un evento lógico con todas sus entregas agrupadas, en vez de un log por endpoint. El log se conserva 30 días; los eventos que llegaron a fallar del todo se conservan 90 días (para que “Reintentar” siga funcionando más tiempo en ese caso). No uses el log de Cord como almacenamiento de largo plazo de lo que se envió — guarda tú lo que necesites conservar en tu propio sistema al procesarlo.