Saltar al contenido

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 en Tu cuenta › Preferencias — esto revela la píldora “Desarrolladores” en la esquina inferior derecha, visible en toda la app. Ábrela con un clic o con la tecla W. 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. Elige los eventos que quieres recibir: vienen agrupados por familia, con buscador y una casilla por familia. Todos los eventos (o no marcar ninguno) te manda todos, incluidos los tipos de evento que Cord agregue en el futuro. Ver el catálogo completo abajo.
  5. Ingresa tu URL — debe ser https://; Cord rechaza destinos http://, localhost, IPs privadas/internas y hosts .internal/.local desde la creación.
  6. Guarda una copia de tu secret (whsec_…) — se muestra en claro una sola vez. Después solo se ve enmascarado. Desde esa misma pantalla puedes Enviar evento de prueba.

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",
  "mode": "live",
  "api_version": "2026-10-01",
  "data": {
    "id": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
    "folio": "COT-0148",
    "status": "paid",
    "moneda": "MXN",
    "total": 45200.00,
    "cliente": "Distribuidora El Zarco",
    "cliente_id": "9a1c3e5f-7b2d-4f6a-8c0e-1d3b5f7a9c2e",
    "link_publico": "https://cordhq.app/q/6f2a9c4e1b7d3f8a0c5e2b9d4f1a6c3e"
  }
}

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.

mode es live, o test cuando el evento sale de tu entorno de prueba. api_version es la versión de la API con la que se armó data: la que fijó el endpoint (ver Versiones de la API). Un evento simulado (el botón Probar, el simulador del modo prueba o cord trigger) trae además "test": true; uno real no trae ese campo.

Firmas y seguridad

Cord firma cada entrega con dos headers. Usa constructEvent de @flouviahq/node (ver el SDK de Node) para no tener que implementar la verificación a mano. Exige X-Cord-Signature-V1 con timestamp, compara en tiempo constante y rechaza una entrega de hace más de 5 minutos:

import { constructEvent } from '@flouviahq/node';

const event = await constructEvent(
  await req.text(),
  req.headers,
  process.env.CORD_WEBHOOK_SECRET!,
);

Cada entrega lleva estos headers:

Header Contenido Notas
Content-Type application/json
User-Agent Cord-Webhooks/1.0
X-Cord-Event quote.paid, ping… El tipo de evento, igual al campo event del body. Útil para enrutar sin parsear.
X-Cord-Timestamp segundos unix El mismo t de X-Cord-Signature-V1: el momento en que se firmó este intento.
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.

Los webhooks de la API se firman solo con los headers de Cord. Los de Standard Webhooks (webhook-id, webhook-timestamp y webhook-signature) los manda únicamente la acción Mandar los datos a una URL de Cord Workflows, así que una biblioteca de Standard Webhooks no verifica estas entregas: usa X-Cord-Signature-V1.

Recibir webhooks en tu framework

Casi toda firma que falla en producción falla por lo mismo: el framework ya leyó y parseó el cuerpo antes que tu código, y el JSON vuelto a serializar ya no es el que Cord firmó (cambian los espacios, el orden de las claves o los acentos escapados). Pide el cuerpo crudo solo en la ruta del webhook, verifica la firma y solo entonces parsea el JSON. Así se obtiene en cada framework:

Framework Cuerpo crudo
Next.js (App Router) await request.text() en el route handler
Express express.raw({ type: 'application/json' }) en esa ruta, antes de app.use(express.json())
Fastify addContentTypeParser con parseAs: 'buffer' dentro de un plugin propio
Hono await c.req.text()
Cloudflare Workers await request.text()
Django request.body, con la vista en @csrf_exempt
FastAPI await request.body()
Flask request.get_data()
Laravel $request->getContent(), con la ruta fuera de la verificación CSRF
Ruby on Rails request.raw_post
Go (net/http) io.ReadAll(r.Body)
ASP.NET Core Request.Body copiado a un MemoryStream, sin [FromBody]

Cada receta responde 400 a una firma inválida y 200 a una entrega válida, también cuando se repite: Cord reintenta y reenvía el mismo evento con el mismo id, así que el efecto se aplica una sola vez por event.id (ver identidad e idempotencia). En los ejemplos esa memoria es un conjunto en el proceso, la caché del framework o una tabla; en producción guarda el id en tu base con un índice único, en la misma transacción que el efecto.

Las recetas de Node, Python, Go y ASP.NET Core se levantan en cada cambio y reciben entregas firmadas por el servidor de Cord: válidas, alteradas, fuera de tiempo, con dos firmas durante una rotación y con un cuerpo que no es UTF-8. En Laravel se prueba la clase de verificación con las mismas entregas.

Node.js y edge

Usan constructEvent de @flouviahq/node, que verifica la firma y devuelve el evento tipado. El App Router de Next.js no parsea el cuerpo; en el Pages Router desactiva el parser con export const config = { api: { bodyParser: false } } y lee el cuerpo del stream de req. Hono lee el secreto con env(c), que funciona igual en Node, Bun, Deno y Workers. En Workers, la receta deduplica con D1: crea la tabla con CREATE TABLE cord_events (id TEXT PRIMARY KEY) y guarda el secreto con wrangler secret put CORD_WEBHOOK_SECRET.

app/api/webhooks/cord/route.js

import { constructEvent, CordWebhookSignatureError } from '@flouviahq/node';

const processed = new Set();

export async function POST(request) {
  let event;
  try {
    event = await constructEvent(await request.text(), request.headers, process.env.CORD_WEBHOOK_SECRET);
  } catch (err) {
    if (err instanceof CordWebhookSignatureError) {
      return new Response(err.code, { status: 400 });
    }
    throw err;
  }
  if (!processed.has(event.id)) {
    console.log(event.event, event.id);
    processed.add(event.id);
  }
  return new Response('ok');
}

Python

Verifican a mano con verify_cord_signature, de Verificar la firma a mano, guardada como cord_signature.py junto a tu aplicación. Django lee el secreto de settings.CORD_WEBHOOK_SECRET, deduplica con la caché y su ruta va en urls.py: path("webhooks/cord", views.cord_webhook). FastAPI y Flask leen la variable de entorno CORD_WEBHOOK_SECRET.

views.py

import json

from django.conf import settings
from django.core.cache import cache
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

from cord_signature import verify_cord_signature


@csrf_exempt
@require_POST
def cord_webhook(request):
    if not verify_cord_signature(request.body, request.headers.get("X-Cord-Signature-V1"), settings.CORD_WEBHOOK_SECRET):
        return HttpResponseBadRequest("invalid signature")
    try:
        event = json.loads(request.body)
    except ValueError:
        return HttpResponseBadRequest("invalid payload")
    if not isinstance(event, dict) or not isinstance(event.get("id"), str):
        return HttpResponseBadRequest("invalid payload")
    if cache.add(f"cord-event:{event['id']}", True, timeout=7 * 24 * 3600):
        print(event["event"], event["id"])
    return HttpResponse("ok")

Laravel

La ruta va en routes/web.php y se saca de la verificación CSRF en bootstrap/app.php (Laravel 11 o superior; antes, en $except de app/Http/Middleware/VerifyCsrfToken.php). También puede ir en routes/api.php, que no pasa por CSRF. El secreto se lee con config('services.cord.webhook_secret'), nunca con env() fuera de config/: después de php artisan config:cache, env() devuelve null. La deduplicación usa Cache::add, que solo escribe si la clave no existe; la clase CordSignature está en Verificar la firma a mano.

app/Http/Controllers/CordWebhookController.php

<?php

namespace App\Http\Controllers;

use App\Support\CordSignature;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

class CordWebhookController extends Controller
{
    public function __invoke(Request $request): Response
    {
        $payload = $request->getContent();
        $valid = CordSignature::verify(
            $payload,
            $request->header('X-Cord-Signature-V1'),
            (string) config('services.cord.webhook_secret'),
        );
        if (!$valid) {
            return response('invalid signature', 400);
        }
        $event = json_decode($payload, true);
        if (!is_array($event) || !is_string($event['id'] ?? null)) {
            return response('invalid payload', 400);
        }
        if (Cache::add('cord-event:' . $event['id'], true, now()->addDays(7))) {
            Log::info('cord webhook', ['event' => $event['event'] ?? null, 'id' => $event['id']]);
        }
        return response('ok', 200);
    }
}

Go y ASP.NET Core

Go usa VerifySignature del mismo paquete (en Verificar la firma a mano); registra el handler con http.Handle("/webhooks/cord", cord.WebhookHandler(os.Getenv("CORD_WEBHOOK_SECRET"))). En ASP.NET Core, app.MapCordWebhook("/webhooks/cord", builder.Configuration["Cord:WebhookSecret"]!) registra la ruta y usa CordSignature de la misma sección.

handler.go

package cord

import (
	"encoding/json"
	"io"
	"log"
	"net/http"
	"sync"
	"time"
)

var now = time.Now

func WebhookHandler(secret string) http.Handler {
	var mu sync.Mutex
	processed := map[string]bool{}
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
			return
		}
		body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
		if err != nil {
			http.Error(w, "invalid body", http.StatusBadRequest)
			return
		}
		if !VerifySignature(body, r.Header.Get("X-Cord-Signature-V1"), secret, 5*time.Minute, now()) {
			http.Error(w, "invalid signature", http.StatusBadRequest)
			return
		}
		var event struct {
			ID    string `json:"id"`
			Event string `json:"event"`
		}
		if err := json.Unmarshal(body, &event); err != nil || event.ID == "" {
			http.Error(w, "invalid payload", http.StatusBadRequest)
			return
		}
		mu.Lock()
		seen := processed[event.ID]
		processed[event.ID] = true
		mu.Unlock()
		if !seen {
			log.Println(event.Event, event.ID)
		}
		w.Write([]byte("ok"))
	})
}

Verificar la firma a mano

Si no usas el SDK, este es el algoritmo completo. Es lo mismo que hace constructEvent:

  1. Lee el cuerpo crudo, exactamente como llegó. Un JSON parseado y vuelto a serializar no cuadra.
  2. Del header X-Cord-Signature-V1 toma t y todos los valores v1 (durante una rotación llegan dos).
  3. Rechaza la entrega si t se aleja más de 300 segundos de tu reloj: es un reenvío.
  4. Calcula HMAC-SHA256(secreto, "<t>.<cuerpo crudo>") en hexadecimal.
  5. Acepta si coincide con cualquiera de los v1, comparando en tiempo constante.

Un header ausente o malformado es una firma inválida: cada función devuelve false y nunca lanza. El momento actual también es un parámetro, para probar con entregas grabadas. La clase de PHP lleva el namespace de Laravel; fuera de Laravel, usa el tuyo y lee el cuerpo con file_get_contents('php://input'). Las seis se prueban en cada cambio con los mismos vectores que firma el servidor de Cord.

verify-cord.mjs

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyCordSignature(rawBody, header, secret, { tolerance = 300, now = Math.floor(Date.now() / 1000) } = {}) {
  if (!header || !secret) return false;
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(',')) {
    const eq = part.indexOf('=');
    if (eq < 0) continue;
    const key = part.slice(0, eq).trim();
    const value = part.slice(eq + 1).trim();
    if (key === 't' && /^[0-9]{1,12}$/.test(value)) timestamp = Number(value);
    else if (key === 'v1' && /^[0-9a-f]{64}$/i.test(value)) signatures.push(Buffer.from(value, 'hex'));
  }
  if (timestamp === null || signatures.length === 0) return false;
  if (Math.abs(now - timestamp) > tolerance) return false;
  const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
  return signatures.some((signature) => timingSafeEqual(signature, expected));
}

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.

El evento de prueba ping

El botón Probar de un endpoint en el Workbench manda, con ping elegido (la opción por defecto), una entrega con event: "ping" por el mismo camino que un evento real (firma, headers y log). Usa datos de ejemplo con la forma de una cotización y un campo mensaje. Toda prueba lleva "test": true en el primer nivel del payload:

{
  "id": "evt_0b7e3d9a1c5f2e8b4a6d0c9f3e1b7a5d",
  "event": "ping",
  "created_at": "2026-10-07T17:12:09.441Z",
  "mode": "live",
  "api_version": "2026-10-01",
  "test": true,
  "data": {
    "id": "00000000-0000-0000-0000-000000000000",
    "folio": "COT-PRUEBA",
    "status": "sent",
    "moneda": "MXN",
    "total": 12500,
    "cliente": "Cliente de prueba S.A. de C.V.",
    "cliente_id": null,
    "link_publico": "https://cordhq.app/q/demo",
    "mensaje": "Esta es una entrega de prueba enviada desde el Workbench de Cord."
  }
}

En lugar de ping puedes elegir cualquiera de los eventos a los que está suscrito el endpoint: llega con su tipo real, datos de ejemplo en la versión de la API del endpoint y también "test": true. Una prueba se intenta una sola vez: si falla, no entra al calendario de reintentos. Las pruebas no quedan en el historial de eventos ni disparan Workflows. Responde 2xx y descártalas. Para probar con cualquiera de los 44 eventos reales y los datos de un objeto de prueba, usa el simulador de webhooks o cord trigger del CLI.

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 error de red o no responder en 5 segundos cuenta como fallo y programa el siguiente intento. Responde rápido y procesa después: guarda el evento y contesta 200 antes de hacer trabajo pesado. 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.

Los tiempos de la tabla son mínimos

El primer intento sale en el momento del evento. Los reintentos los ejecuta un barrido del motor de entrega que corre cada 30 minutos, así que cada reintento sale en el primer barrido después del tiempo que marca la tabla. Si tu endpoint estuvo caído, no esperes el siguiente reintento: recupera lo que te perdiste con el historial de eventos.

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.

Los dos correos llegan al dueño de la cuenta, en el idioma de tu cuenta (Ajustes › General).

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.

También por API, con la llave que creó el endpoint: POST /api/v1/webhooks/{id}/rotate hace lo mismo y devuelve el secreto nuevo 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>

El orden es fijo: el v1= del secreto nuevo va primero y el del secreto viejo al final. Así puedes actualizar tu CORD_WEBHOOK_SECRET en cualquier momento dentro de la ventana sin que ninguna entrega se rechace, siempre que tu verificación compare todos los v1= del header. El SDK ya acepta cualquiera de los dos que cuadre. Si verificas a mano, no te quedes con un solo valor: leer solo el primero exige que ya tengas el secreto nuevo, y un parser que guarda el último v1= (por ejemplo, uno que arma un mapa clave-valor) durante la ventana solo valida con el secreto viejo, así que falla en cuanto cambias al nuevo antes de que la ventana cierre.

La firma legacy X-Cord-Signature solo admite un valor, así que durante la ventana se calcula con el secreto viejo; al cerrarse pasa a firmarse con el nuevo. Al cerrarse la ventana, Cord deja de firmar con el secreto viejo en los dos headers.

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 o hitos).
payment.partial Se recibe UN cobro parcial (anticipo, saldo, una cuota o un pago de un plan por hitos) sin que la cotización quede totalmente pagada todavía.
quote.milestone_completed El negocio marcó cumplido un pago de un plan por hitos que se cobra «al cumplirse»: desde ese momento el cliente lo puede pagar. Trae cobro_id, numero, etiqueta, monto y vence.
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 fuera de México una factura que no se presenta ante la autoridad (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 (desde la app, la API, el MCP, un workflow o Cord por un contracargo o reembolso SPEI) 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, y en facturas las facturas que pagó ese cobro y que dejaron de estar cobradas.
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.
workflow_run.succeeded / workflow_run.failed Una ejecución de Cord Workflows termina sin errores, o falla o se detiene en Requiere atención. Trae workflow_id, workflow (su nombre), documento (el folio o nombre de lo que la disparó) y enlace (sus ejecuciones en Cord); workflow_run.failed trae además estado (failed | attention) y motivo.
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|hito), monto, numero_cuota, etiqueta (el nombre del pago en un plan por hitos), 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_7c3e9a1f5b2d8e4a0c6f3b9d1e7a5c2f",
  "event": "invoice.paid",
  "created_at": "2026-08-20T14:00:00.000Z",
  "mode": "live",
  "api_version": "2026-10-01",
  "data": {
    "id": "d4a1b3c5-7e9f-4a2b-8c4d-6e8f0a2b4c6d",
    "object": "invoice",
    "numero": "FAC-0032",
    "folio_fiscal": "3F2A9C1E-7B4D-4E6A-8C2F-1D5B9E3A7C40",
    "estado": "paid",
    "estado_fiscal": "issued",
    "pais": "MX",
    "tipo": "cfdi_40",
    "documento": "factura",
    "moneda": "MXN",
    "total": 12500.00,
    "pagado": 12500.00,
    "saldo": 0,
    "vence": "2026-08-30",
    "cliente": "Distribuidora El Zarco",
    "cliente_id": "9a1c3e5f-7b2d-4f6a-8c0e-1d3b5f7a9c2e",
    "cotizacion_id": null,
    "link_publico": "https://cordhq.app/i/9c1f4e7a2b5d8c0f3a6e9b2d5f8a1c4e"
  }
}

documento dice cómo se titula: factura, factura_proforma (no es una factura ni tiene validez fiscal), nota_credito o nota_debito. cliente_id es el id del cliente, el mismo de /api/v1/clientes (null sin cliente).

estado es el ciclo de vida de la factura (draft, open, paid, void o uncollectible). estado_fiscal es el registro ante el proveedor fiscal: pending, issued, cancelled o error. tipo es el tipo de documento: cfdi_40, cfdi_egreso, proforma, commercial_invoice, commercial_credit_note, verifactu_invoice o verifactu_credit_note.

Nota el link público: /i/{token} para una factura de Cord Invoicing, contra /q/{token} para una cotización. Si usas @flouviahq/node en TypeScript, constructEvent devuelve CordWebhookEvent, una unión discriminada por event que ya tipa los 44 eventos del catálogo, incluidos los siete invoice.* de Cord Invoicing con la forma de arriba.

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, language (idioma de sus documentos; null = el de la organización) (en client.deleted: solo id y empresa)
product id, sku, nombre, unidad, precio_lista, moneda (la de la organización), activo (en product.deleted: solo id y nombre)
task id, titulo, due_date, done, cotizacion_id, factura_id (no incluye responsable, prioridad ni notas)
promise id, cotizacion_id, fecha_promesa, monto, moneda, 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).
PATCH /api/v1/webhooks/{id} write Cambia url, eventos o activo de un endpoint creado por esta misma llave; lo que no mandas se conserva. activo: false lo pausa y activo: true lo reactiva y reinicia su racha de fallos. Devuelve el endpoint.
POST /api/v1/webhooks/{id}/rotate write Rota el secreto. Cuerpo opcional: { "ventana_horas": 24 }, con 1, 24 o 72 horas (24 si no lo mandas). Devuelve el endpoint con el secret nuevo (en claro una sola vez) y secreto_anterior_expira; durante esa ventana cada entrega trae dos v1.
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": "6b0e2f4a-8c1d-4e3b-9a5f-7d2c0e8b1a46",
      "type": "quote.approved",
      "object": "quote",
      "object_id": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      "data": { "id": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "folio": "COT-0148", "status": "approved", "moneda": "MXN", "total": 45200 },
      "actor": "client",
      "created_at": "2026-09-14T18:32:04.123Z"
    }
  ],
  "meta": { "next_cursor": "MjAyNi0wOS0xNFQxODozMjowNC4xMjMwMDBafDZiMGUyZjRhLThjMWQtNGUzYi05YTVmLTdkMmMwZThiMWE0Ng" }
}
  • 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), workflow:<id>, system y otros. Ver la lista completa.
  • El id del historial es un UUID del registro, distinto del evt_… del webhook.
  • 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

La pestaña Webhooks del Workbench en pantalla completa: el endpoint del ERP con 93.1% de entregas correctas en 7 días, expandido en sus entregas; la más reciente, quote.approved en su segundo intento, falló con 500 y muestra el payload exacto que envió Cord, lo que respondió el servidor y los botones Reintentar y Ver el evento.

En la pestaña Webhooks del Workbench, cada endpoint tiene un log expandible con sus últimas 50 entregas (evento, intento, status, duración y fecha). Cada una se abre para ver lo que Cord envió (el payload exacto), lo que respondió tu servidor y el botón Reintentar. La pestaña Eventos del Workbench muestra lo mismo desde otro ángulo: un evento lógico con su entrega a cada endpoint, en vez de un log por endpoint, y el botón Reenviar. 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.

Consultar el log de entregas, Reintentar una entrega, Reenviar un evento y Reactivar y reintentar un endpoint requieren el plan Profesional o superior. Crear endpoints, recibir eventos, Probar, Rotar secreto y Reactivar están en todos los planes.