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 viewed por primera vez por el cliente.
  • Disparar el proceso de facturación en tu ERP cuando se timbra el CFDI.

Configurar un endpoint

  1. 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.
  2. Abre la pestaña Webhooks del panel.
  3. Haz clic en Agregar endpoint — abre un asistente de pantalla completa.
  4. Ingresa tu URL — debe ser https://; Cord rechaza destinos http://, localhost, IPs privadas/internas y hosts .internal/.local desde la creación.
  5. 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.
  6. 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 traen cotizacion_id, folio y cliente.

Nota: payment.partial trae campos extra en data que los demás eventos de cotización no tienen: tipo (anticipo|saldo|cuota), monto, numero_cuota, saldo_pendiente y payment_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) y object_id.
  • next_cursor es un token opaco: repite la petición con ?cursor=<next_cursor>. Viene null cuando no hay más.
  • actor indica quién originó el evento: user:<id>, api:<llave>, mcp:<llave>, client (el cliente en el link público) o system.
  • A diferencia del webhook, data no incluye link_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.