Integração
Webhooks
Como saber que você foi pago: webhook assinado em produção, polling em sandbox.
Cobrança é assíncrona: você cria a fatura, a pessoa paga minutos (ou dias) depois, em outra aba, no app do banco. Existem dois jeitos de descobrir isso — e em sandbox só um deles funciona.
Em sandbox: polling
Registrar webhook com chave sk_test_ responde:
503 secret_store_unavailable
"Webhook secrets cannot be stored right now. Please try again later."
Não é bug seu e não é intermitente: o cofre de segredos não está disponível pra tenant de sandbox, então não existe segredo pra assinar entrega. Em sandbox, o caminho é perguntar:
// browser ou servidor — endpoint público, sem secret key
const pago = await infi.pay.waitForPaid({ slug, invoiceId });
// true = pagou, false = timeout (default: 3s de intervalo, 10min de teto)
// servidor, leitura pontual
const inv = await infi.invoices.get(invoiceId);
inv.status; // "open" | "paid" | …waitForPaid aceita intervalMs, timeoutMs, onTick (pra atualizar contador na
tela) e signal. É o que você quer rodando enquanto o QR do Pix está na tela.
O que funciona em sandbox
webhooks.list() e webhooks.listDeliveries() respondem 200 (com lista vazia).
Só o create — que precisa gravar segredo — é que para no 503.
Em produção: registrar o endpoint
const endpoint = await infi.webhooks.create({
url: "https://seu-app.com/api/webhooks/infi",
events: ["payment.confirmed", "invoice.finalized"],
});
endpoint.secret; // ⚠️ só aparece aqui. Guarde no seu secret manager.O secret vem uma vez, na criação. Perdeu? infi.webhooks.rotateSecret(id)
emite outro (e invalida o anterior). Também existem list, get,
patch(id, { isActive, events }), delete e listDeliveries() pra auditar o que
saiu.
Se você usa company as code, o mesmo
endpoint pode ser declarado em webhooks[] no infi.company.ts — em sandbox o
sync vai bater no mesmo 503.
Os eventos
Nomes vêm do header X-Webhook-Event-Type, não do corpo:
| Evento | Quando | data |
|---|---|---|
payment.confirmed | Pagamento liquidado — é este que libera acesso | paymentId, invoiceId, amount, currency, customerId?, payerId? |
payment.failed | Tentativa falhou | paymentId, invoiceId |
payment.refunded | Reembolso registrado | paymentId, invoiceId, amount, currency, accessRevoked |
payment.chargeback | Chargeback registrado | paymentId, invoiceId, amount, currency, accessRevoked |
invoice.finalized | Fatura fechada e cobrável | invoiceId, total, currency |
invoice.sent | Fatura enviada por email | invoiceId, total, currency |
invoice.paid | Fatura liquidada (sem paymentId: pode fechar em mais de um pagamento) | invoiceId, amount, currency, customerId?, payerId? |
invoice.voided | Fatura cancelada | invoiceId |
invoice.uncollectible | Desistiu de cobrar | invoiceId |
invoice.auto_collection_failed | Fatura saiu da cobrança automática | invoiceId |
checkout.session.created / .completed / .expired | Sessão do checkout por link | sessionId, linkId |
usage.threshold_reached | Alerta de uso disparou | subscriptionId, meterId?, thresholdAmount |
customer.created | Cliente criado (inclui quem pagou por link) | customerId, externalId, createdAt, name?, email?, taxId?, country? |
customerId é a inscrição (ProductCustomer.id) e vem em fatura de
assinatura; payerId é o cliente do tenant e vem em fatura avulsa. Um dos dois
está ausente conforme o caso — por isso os dois são opcionais.
O despacho casa por nome, sem allowlist: qualquer evento acima pode ser assinado, e a lista cresce.
Corpo é JSON plano, decimais e uuids como string, campo opcional ausente (não
null).
Verificar a assinatura
// app/api/webhooks/infi/route.ts
import { verifyWebhook, InfiError } from "@beinfi/sdk";
import type { PaymentConfirmedData } from "@beinfi/sdk";
export async function POST(req: Request) {
const body = await req.text(); // texto cru: JSON.parse quebra a assinatura
try {
const event = verifyWebhook<PaymentConfirmedData>(
{
id: req.headers.get("x-webhook-id")!,
timestamp: req.headers.get("x-webhook-timestamp")!,
signature: req.headers.get("x-webhook-signature")!,
eventType: req.headers.get("x-webhook-event-type")!,
body,
},
process.env.INFI_WEBHOOK_SECRET!,
);
if (event.type === "payment.confirmed") {
// event.data.invoiceId → libere o acesso (idempotente!)
}
return new Response("ok");
} catch (err) {
if (err instanceof InfiError) return new Response(err.code, { status: 400 });
throw err;
}
}verifyWebhook joga InfiError com code:
invalid_webhook_signature— assinatura não bate (segredo errado, ou o corpo foi reserializado no caminho).webhook_expired— timestamp fora da janela de 5min (proteção de replay). Dá pra afrouxar passando o terceiro argumento em segundos.invalid_webhook— timestamp ou JSON inválido.
Não parseie antes de verificar
A assinatura é sobre os bytes exatos do corpo. Qualquer framework que faça
JSON.parse e reserialize antes de você verificar invalida tudo. Leia raw.
Fora de Node/TS, o esquema é simples: HMAC-SHA256(secret, "{id}.{timestamp}.{body}")
em hex, comparado em tempo constante com o header X-Webhook-Signature (que pode
vir prefixado, v1=abc…, e pode carregar mais de uma assinatura separada por
vírgula durante rotação).
Entrega é "pelo menos uma vez"
Trate o handler como idempotente — e a chave óbvia é a errada. Deduplicar
por event.id deixa passar duas entregas distintas para a mesma fatura, que
continuam sendo uma compra só. Chaveie pela fatura:
const chave = `invoice:${event.data.invoiceId}`;
if (await jaProcessado(chave)) return ok();
await marcarProcessado(chave); // marque ANTES do efeito
await liberarAcesso(...);Marque antes do efeito, não depois: uma falha no meio custa um efeito perdido, que se recupera. A ordem inversa custa um efeito duplicado — num fluxo de crédito, saldo de graça.
Reentrega depois de um 5xx seu é
comportamento esperado, não anomalia.