Webhooks
Reaja instantaneamente às mudanças de status dos seus orçamentos sem precisar fazer polling na API.
Por que usar webhooks?
Em vez de o seu sistema (um ERP, um fluxo do Zapier/Make ou o seu próprio backend) perguntar ao Cord a cada 5 minutos “o cliente já pagou?”, os webhooks do Cord avisam o seu sistema no momento em que o evento acontece, com um POST HTTP assinado para o seu servidor.
Casos de uso comuns:
- Provisionar automaticamente uma licença de software quando um orçamento entra no status
paid. - Enviar uma mensagem interna no Slack quando o cliente visualiza (
viewed) um orçamento pela primeira vez. - Disparar o processo de faturamento no seu ERP quando o CFDI é timbrado.
Configurar um endpoint
- Ative o Modo desenvolvedor em Sua conta › Preferências — isso revela a pílula
“Desenvolvedores” no canto inferior direito, visível em todo o app. Abra-a com um clique ou com a tecla
W. Veja o tour completo em Cord Workbench. - Abra a aba Webhooks do painel.
- Clique em Adicionar endpoint — abre um assistente em tela cheia.
- Escolha os eventos que você quer receber: eles vêm agrupados por família, com busca e uma caixa de seleção por família. Todos os eventos (ou não marcar nenhum) envia todos para você, incluindo os tipos de evento que o Cord adicionar no futuro. Veja o catálogo completo abaixo.
- Digite a sua URL — ela deve ser
https://; o Cord rejeita na criação destinoshttp://,localhost, IPs privados/internos e hosts.internal/.local. - Guarde uma cópia do seu secret (
whsec_…) — ele é exibido em texto claro uma única vez. Depois disso, aparece mascarado. Nessa mesma tela você pode Enviar evento de teste.
A sua equipe pode configurar 16 endpoints no Grátis, no Starter e no Profissional, 32 no Scale e 100 no Developer. As inscrições que as integrações criam por API têm uma cota separada de 100 por organização em todos os planos. Os webhooks NÃO dependem de um plano pago.
O payload
Cada entrega é um POST com Content-Type: application/json e este corpo:
{
"id": "evt_5f2a1c9e8b3d4a7f6e1c0d9b8a7f6e5c",
"event": "quote.paid",
"created_at": "2026-07-27T18:32:04.000Z",
"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 é a identidade do evento, não do orçamento — é o mesmo valor ao longo das novas tentativas e de um replay manual (veja identidade do evento e idempotência). data.id é o id interno do orçamento.
Assinaturas e segurança
O Cord assina cada entrega com dois headers. Use constructEvent de @flouviahq/node (veja o
SDK de Node) para não precisar
implementar a verificação manualmente. Ele exige X-Cord-Signature-V1 com timestamp, compara em
tempo constante e rejeita uma entrega de mais de 5 minutos atrás:
import { constructEvent } from '@flouviahq/node';
const event = await constructEvent(
await req.text(),
req.headers,
process.env.CORD_WEBHOOK_SECRET!,
);
Cada entrega leva estes headers:
| Header | Conteúdo | Notas |
|---|---|---|
Content-Type |
application/json |
|
User-Agent |
Cord-Webhooks/1.0 |
|
X-Cord-Event |
quote.paid, ping… |
O tipo de evento, igual ao campo event do corpo. Útil para rotear sem fazer parse. |
X-Cord-Timestamp |
segundos unix | O mesmo t de X-Cord-Signature-V1: o momento em que esta tentativa foi assinada. |
X-Cord-Signature-V1 |
t=<unix>,v1=<hmac_sha256(secret, "<t>.<body>")> |
Recomendado. Inclui timestamp — proteção real contra replay. Pode trazer dois v1= durante uma rotação de segredo (veja abaixo); aceite se qualquer um conferir. |
X-Cord-Signature |
sha256=<hmac_sha256(secret, body)> |
Legado, sem timestamp. Continua sendo enviado em cada entrega por compatibilidade; use a V1 se puder. |
X-Cord-Event-Id |
evt_… |
Igual ao campo id do corpo. |
X-Cord-Delivery-Id |
uuid | Id desta tentativa — muda a cada nova tentativa/replay, ao contrário de X-Cord-Event-Id. |
X-Cord-Attempt |
número | Começa em 1. |
Idempotency-Key |
evt_… |
Repete X-Cord-Event-Id — vários frameworks de servidor o leem diretamente daí. |
Verificar a assinatura manualmente
Se você não usa o SDK, este é o algoritmo completo. É o mesmo que constructEvent faz:
- Leia o corpo bruto, exatamente como chegou. Um JSON parseado e serializado de novo não confere.
- Do header
X-Cord-Signature-V1, peguete todos os valoresv1(durante uma rotação chegam dois). - Rejeite a entrega se
tse afastar mais de 300 segundos do seu relógio: é um reenvio malicioso. - Calcule
HMAC-SHA256(segredo, "<t>.<corpo bruto>")em hexadecimal. - Aceite se coincidir com qualquer um dos
v1, comparando em tempo constante.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyCordSignature(rawBody, header, secret, toleranceSec = 300) {
if (!header) return false;
let t = null;
const signatures = [];
for (const part of header.split(',')) {
const i = part.indexOf('=');
if (i < 0) continue;
const k = part.slice(0, i).trim();
const v = part.slice(i + 1).trim();
if (k === 't' && /^\d+$/.test(v)) t = Number(v);
else if (k === 'v1' && /^[0-9a-f]{64}$/i.test(v)) signatures.push(Buffer.from(v, 'hex'));
}
if (t === null || signatures.length === 0) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
return signatures.some((s) => s.length === expected.length && timingSafeEqual(s, expected));
}
Com Express, peça o corpo bruto apenas nessa rota:
import express from 'express';
import { verifyCordSignature } from './verify-cord.js';
const app = express();
app.post('/webhooks/cord', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyCordSignature(req.body, req.get('X-Cord-Signature-V1'), process.env.CORD_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
// Elimine duplicados por event.id antes de aplicar efeitos colaterais.
res.sendStatus(200);
});
app.listen(3000);
Nunca compare as assinaturas com === manualmente em produção — use uma comparação em tempo
constante (o SDK já faz isso). Um !== comum pode vazar informações por timing attack.
O evento de teste ping
Com ping escolhido (a opção padrão), o botão Testar de um endpoint no Workbench envia uma entrega com event: "ping" pelo mesmo caminho de um evento real (assinatura, headers e log). Ela usa dados de exemplo com o formato de um orçamento e um campo mensaje. Todo teste leva "test": true no primeiro nível do payload:
{
"id": "evt_0b7e3d9a1c5f2e8b4a6d0c9f3e1b7a5d",
"event": "ping",
"created_at": "2026-10-07T17:12:09.441Z",
"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."
}
}
Em vez de ping, você pode escolher qualquer um dos eventos em que o endpoint está inscrito: ele chega com o tipo real, dados de exemplo na versão da API do endpoint e também "test": true. Um teste é tentado uma única vez: se falhar, não entra no calendário de novas tentativas. Os testes não ficam no histórico de eventos nem disparam Workflows. Responda 2xx e descarte-os. Para testar com qualquer um dos 41 eventos reais e os dados de um objeto de teste, use o simulador de webhooks ou cord trigger da CLI.
Identidade do evento e idempotência
Cada evento lógico tem um id estável (evt_…), igual no corpo e em X-Cord-Event-Id/Idempotency-Key. É o mesmo valor:
- Em cada nova tentativa automática de um mesmo evento (veja a próxima seção).
- Em um replay manual pelo Workbench (“Tentar novamente” no log de entregas) — o payload é reenviado byte a byte idêntico, incluindo o
id.
Como as novas tentativas e os replays podem fazer o seu endpoint receber o mesmo evento mais de uma vez, o seu handler deve ser idempotente: guarde os id já processados (mesmo que seja em uma tabela simples com unique) e descarte silenciosamente os repetidos antes de aplicar efeitos colaterais (cobrar, enviar um e-mail etc.).
Novas tentativas e entrega garantida
O Cord enfileira cada evento antes de tentar entregá-lo — se o seu endpoint estiver fora do ar, o evento não se perde: ele segue um calendário de novas tentativas com backoff exponencial de até 11 tentativas em ~3,6 dias:
| Tentativa | Espera desde a anterior |
|---|---|
| 1 | imediata |
| 2 | ~10 s |
| 3 | ~1 min |
| 4 | ~5 min |
| 5 | ~30 min |
| 6 | ~2 h |
| 7 | ~5 h |
| 8 | ~10 h |
| 9–11 | ~1 dia |
(Com jitter de ±20% para que muitos endpoints que caíram ao mesmo tempo não recebam a nova tentativa exatamente no mesmo segundo.) Qualquer resposta que não seja 2xx, um erro de rede ou não responder em 5 segundos conta como falha e agenda a tentativa seguinte. Responda rápido e processe depois: guarde o evento e responda 200 antes de fazer trabalho pesado. Um 3xx (redirect) nunca é seguido — é tratado como falha; se a URL do seu endpoint mudou, atualize-a no Workbench em vez de deixar um redirect.
Os tempos da tabela são mínimos
A primeira tentativa sai no momento do evento. As novas tentativas são executadas por uma varredura do motor de entrega que roda a cada 5 minutos, então cada nova tentativa sai na primeira varredura depois do tempo que a tabela indica. Se o seu endpoint ficou fora do ar, não espere a próxima tentativa: recupere o que você perdeu com o histórico de eventos.
A entrega é pelo menos uma vez (at-least-once), não exatamente uma vez — daí a importância de eliminar duplicados por id (veja acima).
Saúde do endpoint
O Cord acompanha a sequência de falhas de cada endpoint (só contam os eventos que esgotaram o calendário de novas tentativas, ou falhas de um teste/replay manual — nunca uma tentativa que ainda tem novas tentativas pela frente):
- 3 falhas seguidas → você recebe um e-mail de aviso (no máximo um a cada 24 h).
- 5 falhas seguidas → o endpoint é desativado automaticamente (deixa de receber eventos novos) e você recebe um e-mail explicando o motivo. Os eventos que ainda estavam pendentes de nova tentativa para esse endpoint são cancelados — eles não se acumulam indefinidamente contra um destino fora do ar.
Os dois e-mails chegam ao proprietário da conta, no idioma da sua conta (Configurações › Geral).
Depois de consertar o seu endpoint, na aba Webhooks do Workbench você pode:
- Reativar — volta a receber eventos novos a partir de agora.
- Reativar e tentar novamente — além disso, coloca de novo na fila o que falhou nas últimas 24 horas (nunca o backlog completo, para não bombardear você com eventos antigos de uma vez).
Rotação de segredo
Você pode rotacionar o segredo de um endpoint sem perder nenhuma entrega. No Workbench, botão “Rotacionar segredo”, escolha uma janela de sobreposição (1h / 24h / 72h) e confirme — você verá o segredo novo em texto claro uma única vez.
Durante a janela, o Cord assina cada entrega com os dois segredos ao mesmo tempo:
X-Cord-Signature-V1: t=1732650000,v1=<hmac com o segredo NOVO>,v1=<hmac com o segredo ANTIGO>
A ordem é fixa: o v1= do segredo novo vem primeiro e o do segredo antigo por último. Assim você pode atualizar o seu CORD_WEBHOOK_SECRET a qualquer momento dentro da janela sem que nenhuma entrega seja rejeitada, desde que a sua verificação compare todos os v1= do header. O SDK já aceita qualquer um dos dois que confira. Se você verifica manualmente, não fique com um único valor: ler só o primeiro exige que você já tenha o segredo novo, e um parser que guarda o último v1= (por exemplo, um que monta um mapa chave-valor) durante a janela só valida com o segredo antigo, então falha assim que você muda para o novo antes de a janela fechar.
A assinatura legada X-Cord-Signature só admite um valor, então durante a janela ela é calculada com o segredo antigo; quando a janela fecha, passa a ser assinada com o novo. Ao fechar a janela, o Cord deixa de assinar com o segredo antigo nos dois headers.
Catálogo de eventos
| Evento | É disparado quando… |
|---|---|
quote.sent |
O vendedor envia o orçamento ao cliente. |
quote.viewed |
O cliente abre o link público pela primeira vez. |
quote.approved |
O cliente aprova (total ou parcialmente) o orçamento. |
quote.rejected |
O cliente o recusa. |
quote.updated |
O vendedor o modifica e o reenvia (“Modificar e reenviar” no detalhe). |
quote.expired |
A data de validade passou sem que o cliente decidisse (estava sent/viewed). |
quote.deleted |
Um rascunho é excluído (só se aplica a orçamentos que nunca foram enviados). |
quote.paid |
O pagamento total é recebido (cobrança simples ou a última cobrança pendente de um plano de entrada/parcelas). |
payment.partial |
UMA cobrança parcial é recebida (entrada, saldo ou uma parcela) sem que o orçamento fique totalmente pago ainda. |
payment.failed |
Falha uma cobrança recorrente de uma mensalidade/retainer. |
invoice.stamped |
O CFDI é timbrado (vem de um orçamento, veja a nota abaixo). |
invoice.issued |
Uma fatura comercial é emitida fora do México (vem de um orçamento). |
invoice.finalized |
Uma fatura do Cord Invoicing é emitida — o objeto de fatura independente, não vinculado a um orçamento. |
invoice.sent |
O vendedor envia essa fatura ao cliente. |
invoice.paid |
A fatura fica totalmente paga. |
invoice.payment_failed |
Falha uma cobrança contra essa fatura (cartão recusado, débito automático que falhou). |
invoice.voided |
A fatura é cancelada. |
invoice.marked_uncollectible |
A fatura é marcada como incobrável. |
invoice.overdue |
A data de vencimento passou sem que ela fosse quitada (disparado pelo cron de lembretes). |
quote.created |
Um orçamento é criado, pelo app, pela API, pelo MCP ou pelo Cord Elements. |
quote.approval_requested |
Um orçamento novo fica pendente de aprovação interna (desconto, valor ou margem fora do limite). Traz motivo. |
quote.approval_decided |
A gerência aprova ou recusa essa solicitação. Traz decision (approved | rejected). |
quote.comment_added |
O cliente ou o vendedor escreve na conversa do orçamento. Traz autor (cliente | vendedor), mensaje e, se for sobre um item, item_id; uma contraproposta traz tipo: "contraoferta" e propuesta. |
client.created / client.updated / client.deleted |
Um cliente do cadastro é criado, editado ou excluído. |
product.created / product.updated / product.deleted |
Um produto do catálogo é criado, editado ou excluído. |
task.created / task.completed |
Uma tarefa é criada (pelo app, pela API, pelo MCP, por um workflow ou pelo Cord devido a uma contestação ou a um reembolso SPEI) ou marcada como concluída. |
promise.created / promise.kept / promise.broken |
Uma promessa de pagamento é registrada, ou marcada como cumprida ou descumprida. |
dispute.created / dispute.closed |
Um cliente abre uma contestação sobre uma cobrança do Cord Payments, ou ela é encerrada com o resultado em estado (won | lost | warning_closed). Traz monto, moneda, motivo e fecha_limite para responder. |
refund.succeeded / refund.failed |
Um reembolso é concluído ou falha. Traz monto, moneda, motivo e, se falhar, motivo_falla. |
payout.paid / payout.failed |
Um repasse para a sua conta bancária é pago ou falha. Traz monto, moneda, llegada e, se falhar, motivo_falla. |
account.updated |
Muda o que a sua conta de pagamentos pode fazer: puede_cobrar, puede_depositar, pendientes (dados a verificar) e motivo_bloqueo. Só é emitido quando algum deles muda. |
ping |
Só aparece ao usar “Testar” no Workbench — nunca é disparado por um evento real. |
Os eventos de contestações, reembolsos e repasses trazem
referencia, o identificador da transação no processador de pagamentos, e são emitidos uma única vez por transação e resultado. Quando a cobrança vem de um orçamento, também trazemcotizacion_id,folioecliente.
Nota:
payment.partialtraz campos extras emdataque os demais eventos de orçamento não têm:tipo(anticipo|saldo|cuota),monto,numero_cuota,saldo_pendienteepayment_method, além do resumo normal do orçamento (número, cliente, total, link).
Duas famílias de eventos, dois formatos de data. Os eventos quote.* e payment.*
(incluindo invoice.stamped/invoice.issued, que são disparados a partir do orçamento) compartilham
o formato de data do exemplo acima (folio, status, total, cliente,
link_publico…). Os sete eventos invoice.finalized/sent/paid/payment_failed/
voided/marked_uncollectible/overdue pertencem ao Cord Invoicing — a fatura como
objeto próprio, com ciclo de vida próprio e link público próprio, não um derivado do
orçamento — e trazem um formato de data DIFERENTE:
{
"id": "evt_7c3e9a1f5b2d8e4a0c6f3b9d1e7a5c2f",
"event": "invoice.paid",
"created_at": "2026-08-20T14:00:00.000Z",
"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",
"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/9c1f4e7a2b5d8c0f3a6e9b2d5f8a1c4e"
}
}estado é o ciclo de vida da fatura (draft, open, paid, void ou
uncollectible). estado_fiscal é o registro perante o provedor fiscal: pending, issued,
cancelled ou error. tipo é o tipo de documento: cfdi_40, cfdi_egreso, proforma,
commercial_invoice, commercial_credit_note, verifactu_invoice ou verifactu_credit_note.
Observe o link público: /i/{token} para uma fatura do Cord Invoicing, contra /q/{token}
para um orçamento. Se você usa @flouviahq/node em TypeScript, constructEvent devolve
CordWebhookEvent, uma união discriminada por event que já tipa os 41 eventos do catálogo,
incluindo os sete invoice.* do Cord Invoicing com o formato acima.
Formato de data em clientes, produtos, tarefas e promessas
Cada objeto traz object para diferenciá-lo. O evento de produto nunca inclui o custo nem a margem.
object |
Campos |
|---|---|
client |
id, empresa, contacto, email, telefono, rfc, terminos, country_code, language (idioma dos documentos dele; null = o da organização) (em client.deleted: só id e empresa) |
product |
id, sku, nombre, unidad, precio_lista, activo (em product.deleted: só id e nombre) |
task |
id, titulo, due_date, done, cotizacion_id (não inclui responsável, prioridade nem notas) |
promise |
id, cotizacion_id, fecha_promesa, monto, moneda, estado |
Administrar endpoints pela API
Plataformas como o Zapier ou o Make criam e excluem as próprias inscrições. Para isso existem:
| Método e rota | Escopo | O que faz |
|---|---|---|
GET /api/v1/webhooks |
read |
Lista os endpoints criados por esta mesma chave, sem segredos. |
POST /api/v1/webhooks |
write |
Cria um endpoint. Corpo: { "url": "https://…", "eventos": ["quote.approved"] }. Devolve id, url, eventos e secret (em texto claro uma única vez). |
DELETE /api/v1/webhooks/{id} |
write |
Exclui um endpoint criado por esta mesma chave. |
Valem as mesmas regras de URL que no Workbench: só https:// para hosts públicos. Essas inscrições não contam contra os endpoints da sua equipe: elas têm a própria cota de 100 por organização (integration_limit_reached).
Uma chave não vê nem pode excluir os endpoints criados pelo Workbench nem os de outra chave. Ao revogar uma chave, os endpoints dela são desativados: se você desconecta uma integração, ela deixa de receber dados.
Histórico de eventos pela API
Se o seu sistema ficou fora do ar por mais tempo do que as novas tentativas cobrem, ou se você prefere consultar em vez de receber, GET /api/v1/events (escopo read) devolve os eventos da sua organização do mais novo ao mais antigo, haja ou não endpoints inscritos:
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 opcionais:
type(um evento do catálogo) eobject_id. next_cursoré um token opaco: repita a requisição com?cursor=<next_cursor>. Vem comonullquando não há mais.actorindica quem originou o evento:user:<id>,api:<chave>,mcp:<chave>,client(o cliente no link público),workflow:<id>,systeme outros. Veja a lista completa.- O
iddo histórico é um UUID do registro, diferente doevt_…do webhook. - Ao contrário do webhook,
datanão incluilink_publico: o link é uma credencial e não é guardado no histórico.
Log de entregas e retenção
Na aba Webhooks do Workbench, cada endpoint tem um log expansível com as últimas 50 entregas (evento, tentativa, status, duração e data). Cada uma se abre para mostrar o que o Cord enviou (o payload exato), o que seu servidor respondeu e o botão Tentar novamente. A aba Eventos do Workbench mostra o mesmo de outro ângulo: um evento lógico com a entrega para cada endpoint, em vez de um log por endpoint, e o botão Reenviar. O log é guardado por 30 dias; os eventos que falharam por completo são guardados por 90 dias (para que “Tentar novamente” continue funcionando por mais tempo nesse caso). Não use o log do Cord como armazenamento de longo prazo do que foi enviado — guarde no seu próprio sistema o que você precisar conservar ao processar cada entrega.
Consultar o log de entregas, Tentar novamente uma entrega, Reenviar um evento e Reativar e tentar novamente um endpoint exigem o plano Profissional ou superior. Criar endpoints, receber eventos, Testar, Rotacionar segredo e Reativar estão disponíveis em todos os planos.