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 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. - Abre la pestaña Webhooks del panel.
- Haz clic en Agregar endpoint — abre un asistente de pantalla completa.
- 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.
- Ingresa tu URL — debe ser
https://; Cord rechaza destinoshttp://,localhost, IPs privadas/internas y hosts.internal/.localdesde la creación. - 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');
}server.mjs
import express from 'express';
import { constructEvent, CordWebhookSignatureError } from '@flouviahq/node';
const app = express();
const processed = new Set();
app.post('/webhooks/cord', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = await constructEvent(req.body ?? '', req.headers, process.env.CORD_WEBHOOK_SECRET);
} catch (err) {
if (err instanceof CordWebhookSignatureError) return res.status(400).send(err.code);
throw err;
}
if (!processed.has(event.id)) {
console.log(event.event, event.id);
processed.add(event.id);
}
res.sendStatus(200);
});
app.use(express.json());
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`Listening on http://localhost:${port}/webhooks/cord`));server.mjs
import Fastify from 'fastify';
import { constructEvent, CordWebhookSignatureError } from '@flouviahq/node';
const app = Fastify();
const processed = new Set();
app.register(async (webhooks) => {
webhooks.addContentTypeParser('application/json', { parseAs: 'buffer' }, (request, body, done) => done(null, body));
webhooks.post('/webhooks/cord', async (request, reply) => {
let event;
try {
event = await constructEvent(request.body, request.headers, process.env.CORD_WEBHOOK_SECRET);
} catch (err) {
if (err instanceof CordWebhookSignatureError) return reply.code(400).send(err.code);
throw err;
}
if (!processed.has(event.id)) {
console.log(event.event, event.id);
processed.add(event.id);
}
return reply.send('ok');
});
});
const port = Number(process.env.PORT ?? 3000);
await app.listen({ port });
console.log(`Listening on http://localhost:${port}/webhooks/cord`);src/index.mjs
import { Hono } from 'hono';
import { env } from 'hono/adapter';
import { constructEvent, CordWebhookSignatureError } from '@flouviahq/node';
const app = new Hono();
const processed = new Set();
app.post('/webhooks/cord', async (c) => {
const { CORD_WEBHOOK_SECRET } = env(c);
let event;
try {
event = await constructEvent(await c.req.text(), c.req.raw.headers, CORD_WEBHOOK_SECRET);
} catch (err) {
if (err instanceof CordWebhookSignatureError) return c.text(err.code, 400);
throw err;
}
if (!processed.has(event.id)) {
console.log(event.event, event.id);
processed.add(event.id);
}
return c.text('ok');
});
export default app;src/index.js
import { constructEvent, CordWebhookSignatureError } from '@flouviahq/node';
export default {
async fetch(request, env) {
const { pathname } = new URL(request.url);
if (request.method !== 'POST' || pathname !== '/webhooks/cord') {
return new Response('Not found', { status: 404 });
}
let event;
try {
event = await constructEvent(await request.text(), request.headers, env.CORD_WEBHOOK_SECRET);
} catch (err) {
if (err instanceof CordWebhookSignatureError) {
return new Response(err.code, { status: 400 });
}
throw err;
}
const { meta } = await env.DB.prepare('INSERT OR IGNORE INTO cord_events (id) VALUES (?)').bind(event.id).run();
if (meta.changes > 0) {
console.log(event.event, 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")main.py
import json
import os
from fastapi import FastAPI, Request, Response
from cord_signature import verify_cord_signature
app = FastAPI()
processed = set()
@app.post("/webhooks/cord")
async def cord_webhook(request: Request) -> Response:
body = await request.body()
if not verify_cord_signature(body, request.headers.get("X-Cord-Signature-V1"), os.environ["CORD_WEBHOOK_SECRET"]):
return Response("invalid signature", status_code=400)
try:
event = json.loads(body)
except ValueError:
return Response("invalid payload", status_code=400)
if not isinstance(event, dict) or not isinstance(event.get("id"), str):
return Response("invalid payload", status_code=400)
if event["id"] not in processed:
print(event["event"], event["id"])
processed.add(event["id"])
return Response("ok")app.py
import json
import os
from flask import Flask, request
from cord_signature import verify_cord_signature
app = Flask(__name__)
processed = set()
@app.post("/webhooks/cord")
def cord_webhook():
body = request.get_data()
if not verify_cord_signature(body, request.headers.get("X-Cord-Signature-V1"), os.environ["CORD_WEBHOOK_SECRET"]):
return "invalid signature", 400
try:
event = json.loads(body)
except ValueError:
return "invalid payload", 400
if not isinstance(event, dict) or not isinstance(event.get("id"), str):
return "invalid payload", 400
if event["id"] not in processed:
print(event["event"], event["id"])
processed.add(event["id"])
return "ok", 200Laravel
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);
}
}routes/web.php
<?php
use App\Http\Controllers\CordWebhookController;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/cord', CordWebhookController::class);bootstrap/app.php
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__ . '/../routes/web.php',
commands: __DIR__ . '/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: ['webhooks/cord']);
})
->withExceptions(function (Exceptions $exceptions): void {})
->create();config/services.php
<?php
return [
'cord' => [
'webhook_secret' => env('CORD_WEBHOOK_SECRET'),
],
];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"))
})
}CordWebhookEndpoint.cs
using System.Collections.Concurrent;
using System.IO;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
namespace Cord.Webhooks;
public static class CordWebhookEndpoint
{
private static readonly ConcurrentDictionary<string, bool> Processed = new();
public static RouteHandlerBuilder MapCordWebhook(this IEndpointRouteBuilder app, string pattern, string secret)
{
return app.MapPost(pattern, async (HttpRequest request, ILoggerFactory loggers) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer);
var payload = buffer.ToArray();
if (!CordSignature.Verify(payload, request.Headers["X-Cord-Signature-V1"], secret))
{
return Results.Text("invalid signature", statusCode: 400);
}
string? id = null;
string? type = null;
try
{
using var json = JsonDocument.Parse(payload);
if (json.RootElement.ValueKind == JsonValueKind.Object)
{
if (json.RootElement.TryGetProperty("id", out var idValue) && idValue.ValueKind == JsonValueKind.String)
{
id = idValue.GetString();
}
if (json.RootElement.TryGetProperty("event", out var typeValue))
{
type = typeValue.ToString();
}
}
}
catch (JsonException)
{
return Results.Text("invalid payload", statusCode: 400);
}
if (id is null)
{
return Results.Text("invalid payload", statusCode: 400);
}
if (Processed.TryAdd(id, true))
{
loggers.CreateLogger("Cord").LogInformation("{Type} {Id}", type, id);
}
return Results.Text("ok");
});
}
}Verificar la firma a mano
Si no usas el SDK, este es el algoritmo completo. Es lo mismo que hace constructEvent:
- Lee el cuerpo crudo, exactamente como llegó. Un JSON parseado y vuelto a serializar no cuadra.
- Del header
X-Cord-Signature-V1tomaty todos los valoresv1(durante una rotación llegan dos). - Rechaza la entrega si
tse aleja más de 300 segundos de tu reloj: es un reenvío. - Calcula
HMAC-SHA256(secreto, "<t>.<cuerpo crudo>")en hexadecimal. - 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));
}cord_signature.py
import hashlib
import hmac
import re
import time
_TIMESTAMP = re.compile(r"[0-9]{1,12}")
_SIGNATURE = re.compile(r"[0-9a-fA-F]{64}")
def verify_cord_signature(body: bytes, header: str | None, secret: str, tolerance: int = 300, now: int | None = None) -> bool:
if not header or not secret:
return False
timestamp = None
signatures = []
for part in header.split(","):
key, sep, value = part.partition("=")
if not sep:
continue
key, value = key.strip(), value.strip()
if key == "t" and _TIMESTAMP.fullmatch(value):
timestamp = int(value)
elif key == "v1" and _SIGNATURE.fullmatch(value):
signatures.append(value.lower())
if timestamp is None or not signatures:
return False
current = int(time.time()) if now is None else now
if abs(current - timestamp) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)app/Support/CordSignature.php
<?php
declare(strict_types=1);
namespace App\Support;
final class CordSignature
{
public static function verify(string $payload, ?string $header, string $secret, int $tolerance = 300, ?int $now = null): bool
{
if ($header === null || $header === '' || $secret === '') {
return false;
}
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
if (!str_contains($part, '=')) {
continue;
}
[$key, $value] = array_map('trim', explode('=', $part, 2));
if ($key === 't' && preg_match('/^[0-9]{1,12}$/D', $value)) {
$timestamp = (int) $value;
} elseif ($key === 'v1' && preg_match('/^[0-9a-f]{64}$/Di', $value)) {
$signatures[] = strtolower($value);
}
}
if ($timestamp === null || $signatures === []) {
return false;
}
if (abs(($now ?? time()) - $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
}cord_signature.rb
require 'openssl'
module CordSignature
def self.verify(body, header, secret, tolerance: 300, now: Time.now.to_i)
return false if header.nil? || header.empty? || secret.nil? || secret.empty?
timestamp = nil
signatures = []
header.split(',').each do |part|
key, value = part.split('=', 2)
next if value.nil?
key = key.strip
value = value.strip
if key == 't' && value.match?(/\A[0-9]{1,12}\z/)
timestamp = value.to_i
elsif key == 'v1' && value.match?(/\A[0-9a-fA-F]{64}\z/)
signatures << value.downcase
end
end
return false if timestamp.nil? || signatures.empty?
return false if (now - timestamp).abs > tolerance
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}.".b + body.b)
signatures.any? { |signature| secure_compare(expected, signature) }
end
def self.secure_compare(a, b)
return false unless a.bytesize == b.bytesize
a.bytes.zip(b.bytes).reduce(0) { |diff, (x, y)| diff | (x ^ y) }.zero?
end
endverify.go
package cord
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"regexp"
"strconv"
"strings"
"time"
)
var (
timestampPattern = regexp.MustCompile(`^[0-9]{1,12}$`)
signaturePattern = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
)
func VerifySignature(body []byte, header, secret string, tolerance time.Duration, at time.Time) bool {
if header == "" || secret == "" {
return false
}
timestamp := int64(-1)
var signatures [][]byte
for _, part := range strings.Split(header, ",") {
key, value, found := strings.Cut(part, "=")
if !found {
continue
}
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
switch {
case key == "t" && timestampPattern.MatchString(value):
timestamp, _ = strconv.ParseInt(value, 10, 64)
case key == "v1" && signaturePattern.MatchString(value):
signature, _ := hex.DecodeString(value)
signatures = append(signatures, signature)
}
}
if timestamp < 0 || len(signatures) == 0 {
return false
}
if age := at.Unix() - timestamp; age > int64(tolerance.Seconds()) || -age > int64(tolerance.Seconds()) {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(timestamp, 10) + "."))
mac.Write(body)
expected := mac.Sum(nil)
for _, signature := range signatures {
if hmac.Equal(signature, expected) {
return true
}
}
return false
}CordSignature.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace Cord.Webhooks;
public static class CordSignature
{
private static readonly Regex TimestampPattern = new(@"^[0-9]{1,12}\z");
private static readonly Regex SignaturePattern = new(@"^[0-9a-fA-F]{64}\z");
public static bool Verify(byte[] payload, string? header, string secret, int toleranceSeconds = 300, long? now = null)
{
if (string.IsNullOrEmpty(header) || string.IsNullOrEmpty(secret))
{
return false;
}
long? timestamp = null;
var signatures = new List<byte[]>();
foreach (var part in header.Split(','))
{
var eq = part.IndexOf('=');
if (eq < 0)
{
continue;
}
var key = part[..eq].Trim();
var value = part[(eq + 1)..].Trim();
if (key == "t" && TimestampPattern.IsMatch(value))
{
timestamp = long.Parse(value, CultureInfo.InvariantCulture);
}
else if (key == "v1" && SignaturePattern.IsMatch(value))
{
signatures.Add(Convert.FromHexString(value));
}
}
if (timestamp is null || signatures.Count == 0)
{
return false;
}
var current = now ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(current - timestamp.Value) > toleranceSeconds)
{
return false;
}
var prefix = Encoding.UTF8.GetBytes(timestamp.Value.ToString(CultureInfo.InvariantCulture) + ".");
var message = new byte[prefix.Length + payload.Length];
prefix.CopyTo(message, 0);
payload.CopyTo(message, prefix.Length);
var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), message);
foreach (var signature in signatures)
{
if (CryptographicOperations.FixedTimeEquals(signature, expected))
{
return true;
}
}
return false;
}
}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 traencotizacion_id,folioycliente.
Nota:
payment.partialtrae campos extra endataque 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_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_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) 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),workflow:<id>,systemy otros. Ver la lista completa.- El
iddel historial es un UUID del registro, distinto delevt_…del webhook. - 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 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.