Para testar webhook localmente, o seu servidor precisa de uma URL pública. A xFlow só aceita endpoints https:// e públicos: http://localhost:3000 ou um IP interno são recusados com 400 invalid_url. A solução é um túnel HTTPS, que publica uma URL na internet e encaminha cada requisição para a porta da sua máquina. Com o túnel aberto, você cadastra o endpoint com a chave de teste, simula o pagamento e vê o evento chegar no seu handler. Os detalhes de cada evento estão no guia de webhook Pix.
O que você precisa
- Chave de teste.
pk_test_esk_test_, criadas em Chaves de API > Criar chave, no ambiente "Teste". Ela pode ser criada antes da aprovação do cadastro. - Handler rodando na sua máquina, por exemplo na porta 3000.
- Uma ferramenta de túnel HTTPS, como ngrok ou Cloudflare Tunnel. Qualquer uma que publique uma URL
https://apontando para a sua porta serve.
Passo 1: suba o handler local
O handler abaixo, em Node.js com Express, valida a assinatura e grava o corpo cru de cada entrega em arquivo. Esse arquivo vai servir para repetir o evento depois, sem depender da API.
import express from "express";
import { writeFileSync } from "node:fs";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
const SEGREDO = process.env.XFLOW_WEBHOOK_SECRET;
function assinaturaValida(raw, header) {
const esperado = "sha256=" + createHmac("sha256", SEGREDO).update(raw).digest("hex");
const a = Buffer.from(esperado), b = Buffer.from(header ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
app.post("/webhooks/xflow", express.raw({ type: "application/json" }), (req, res) => {
const evento = req.get("x-webhook-event");
const nome = String(evento).replace(/[^a-z._]/g, "");
writeFileSync(`ultimo-${nome}.json`, req.body); // corpo cru, byte a byte
if (!assinaturaValida(req.body, req.get("x-webhook-signature"))) {
console.log("assinatura inválida", evento);
return res.sendStatus(401);
}
const { data } = JSON.parse(req.body.toString("utf8"));
console.log(evento, data.id, "livemode:", data.livemode);
res.sendStatus(200);
});
app.listen(3000);Duas regras valem desde o primeiro teste: calcule o HMAC sobre o corpo cru, antes de qualquer JSON.parse, e compare em tempo constante.
Passo 2: abra o túnel
# exemplos; use a ferramenta que preferir
ngrok http 3000
# ou
cloudflared tunnel --url http://localhost:3000A ferramenta mostra uma URL https://. Ela é o endereço público do seu handler enquanto o túnel estiver aberto. Em muitos planos gratuitos, essa URL muda a cada execução, o que afeta o passo seguinte.
Passo 3: cadastre o endpoint com a chave de teste
curl -X POST https://app.xflowpayments.com/api/v1/webhooks \
-u "pk_test_...:sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://SUA-URL-DO-TUNEL/webhooks/xflow",
"events": ["transaction.pending", "transaction.paid", "transaction.refunded", "transaction.refund_failed"],
"description": "teste local"
}'A resposta 201 traz o campo secret, o segredo de assinatura. Ele aparece uma única vez: copie para a variável XFLOW_WEBHOOK_SECRET e reinicie o handler.
Importante: a documentação não separa endpoints de teste e de produção. Por isso, o handler deve sempre conferir
livemode, e o endpoint do túnel deve ser apagado quando o teste terminar.
Passo 4: gere o evento
Crie uma cobrança com a chave de teste e simule o pagamento:
curl -X POST https://app.xflowpayments.com/api/v1/charges \
-u "pk_test_...:sk_test_..." \
-H "Content-Type: application/json" \
-d '{"amountCents":1990,"customer":{"name":"Cliente","email":"cliente@exemplo.com","document":"00000000000"},"external_reference":"teste-local-1"}'
curl -X POST https://app.xflowpayments.com/api/v1/test/charges/{id}/pay \
-u "pk_test_...:sk_test_..."O evento transaction.paid chega no handler com livemode: false, payer: null e a taxa calculada com as taxas reais da sua conta. O roteiro completo do ambiente de teste está em API Pix sandbox.
Dois limites do sandbox afetam o teste de webhook. O estorno com chave de teste conclui na hora, mas a documentação não diz se ele dispara transaction.refunded: confira o resultado com GET /api/v1/charges/{id}/refund. E os saques retornam 403 test_mode_unsupported, então não há eventos withdrawal.* para receber.
Passo 5: teste o reenvio
A xFlow reenvia cada evento até 3 vezes quando a sua URL não responde 2xx. Para ver isso acontecer, troque temporariamente o 200 do handler por 500 e simule outro pagamento. A documentação não informa o intervalo entre as tentativas. O que importa é o comportamento do seu código: o mesmo evento pode chegar mais de uma vez, e a segunda entrega não pode dar baixa duas vezes. Use data.id junto com o nome do evento como chave para descartar repetições.
Repetir um evento sem chamar a API
Com o corpo gravado no passo 1, você repete a entrega quantas vezes quiser. Assine o arquivo com o segredo e envie com curl:
CORPO=ultimo-transaction.paid.json
ASSINATURA="sha256=$(openssl dgst -sha256 -hmac "$XFLOW_WEBHOOK_SECRET" -hex < "$CORPO" | sed 's/^.* //')"
curl -X POST http://localhost:3000/webhooks/xflow \
-H "Content-Type: application/json" \
-H "x-webhook-event: transaction.paid" \
-H "x-webhook-signature: $ASSINATURA" \
--data-binary @"$CORPO"Use --data-binary, e não -d: o -d remove quebras de linha do arquivo, os bytes mudam e a assinatura deixa de bater. Para conferir uma assinatura à mão, cole o segredo, o corpo e o header no validador de assinatura de webhook.
O mesmo truque cobre os eventos que o sandbox não gera, como withdrawal.completed. Monte um corpo no formato { event, data, createdAt }, assine e envie. Como a documentação não detalha o data dos eventos de saque, o handler deve confirmar o estado com GET /api/v1/transfers/{id} antes de agir.
Problemas comuns
| Sintoma | Causa provável | O que fazer |
|---|---|---|
400 invalid_url ao cadastrar |
URL http:// ou endereço interno |
Use a URL https:// do túnel |
| Assinatura não confere | Corpo alterado antes do HMAC, curl -d ou segredo de outro endpoint |
Use o corpo cru, --data-binary e o segredo do endpoint certo |
| Eventos pararam de chegar | O túnel fechou ou a URL mudou | Apague o endpoint e crie outro com a URL nova |
| O mesmo evento chega duas vezes | O handler não respondeu 2xx e houve reenvio |
Responda rápido e torne o processamento idempotente |
400 endpoint_limit |
A conta chegou a 20 endpoints | Apague os endpoints de testes antigos |
Quando terminar
Liste os endpoints com GET /api/v1/webhooks e apague o do túnel com DELETE /api/v1/webhooks/{id}. Se for voltar a testar logo, pause com PATCH /api/v1/webhooks/{id} e o corpo { "active": false }. Um endpoint esquecido continua recebendo eventos, com dados de quem pagou, numa URL de túnel que pode deixar de ser sua. O conceito de webhook, com assinatura e reenvio, está resumido no glossário, e a referência completa fica na documentação da API.
Perguntas frequentes
Por que não posso cadastrar http://localhost como URL de webhook?
Porque a URL precisa ser https:// e pública. Um endereço interno é recusado com 400 invalid_url. Para receber eventos na sua máquina, use um túnel que publique uma URL HTTPS apontando para a porta local.
Preciso de chave de produção para testar o webhook?
Não. Com a chave de teste você cadastra o endpoint, cria a cobrança e simula o pagamento com POST /api/v1/test/charges/{id}/pay. O evento transaction.paid chega com livemode: false.
A URL do túnel mudou. Como atualizo o endpoint?
A API documenta o PATCH de webhook só para pausar ou reativar. Para trocar a URL, apague o endpoint antigo com DELETE /api/v1/webhooks/{id} e crie outro. O novo endpoint tem um segredo novo, que aparece uma única vez.
Como testo eventos de saque se o sandbox não faz saque?
Com chave de teste, os saques retornam 403 test_mode_unsupported, então o sandbox não gera eventos withdrawal.*. Monte um corpo de exemplo, assine com o segredo do endpoint e envie ao seu handler com curl.