Pular para o conteúdo

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:

EventoQuandodata
payment.confirmedPagamento liquidado — é este que libera acessopaymentId, invoiceId, amount, currency, customerId?, payerId?
payment.failedTentativa falhoupaymentId, invoiceId
payment.refundedReembolso registradopaymentId, invoiceId, amount, currency, accessRevoked
payment.chargebackChargeback registradopaymentId, invoiceId, amount, currency, accessRevoked
invoice.finalizedFatura fechada e cobrávelinvoiceId, total, currency
invoice.sentFatura enviada por emailinvoiceId, total, currency
invoice.paidFatura liquidada (sem paymentId: pode fechar em mais de um pagamento)invoiceId, amount, currency, customerId?, payerId?
invoice.voidedFatura canceladainvoiceId
invoice.uncollectibleDesistiu de cobrarinvoiceId
invoice.auto_collection_failedFatura saiu da cobrança automáticainvoiceId
checkout.session.created / .completed / .expiredSessão do checkout por linksessionId, linkId
usage.threshold_reachedAlerta de uso disparousubscriptionId, meterId?, thresholdAmount
customer.createdCliente 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.