Um webhook Pix avisa o seu sistema, sem você perguntar, que uma cobrança foi paga, um estorno concluiu ou um saque falhou. Na API Pix da xFlow, você cadastra uma URL https:// pública e, a cada evento, recebe um POST com corpo JSON, o header x-webhook-event com o nome do evento e o header x-webhook-signature com a assinatura HMAC-SHA256 do corpo. Se a sua URL não responder 2xx, a entrega é reenviada até 3 vezes.
Por que usar webhook em vez de polling
Sem webhook, o seu sistema precisa consultar GET /api/v1/charges/{id} repetidas vezes até o status virar paid. Isso gasta requisições, conta no limite por conta (429 rate_limited) e atrasa a baixa do pedido entre uma consulta e outra. Com o webhook, a xFlow avisa quando o pagamento é confirmado, e a confirmação é revalidada na adquirente antes de o valor cair no saldo. A documentação recomenda o webhook e deixa o polling como alternativa.
Como cadastrar o endpoint
Você pode cadastrar pelo painel, em Integrações > Webhooks, ou pela própria API. A API aceita chave de produção e de teste no mesmo endpoint:
curl -X POST https://app.xflowpayments.com/api/v1/webhooks \
-u "pk_live_...:sk_live_..." \
-H "Content-Type: application/json" \
-d '{"url":"https://seusistema.com.br/webhooks","events":["transaction.paid","transaction.refunded","transaction.refund_failed"]}'A resposta 201 traz o campo secret, o segredo de assinatura do endpoint. Ele aparece uma única vez. Guarde num cofre de segredos ou numa variável de ambiente.
As outras operações:
GET /api/v1/webhookslista os endpoints, com o segredo mascarado.PATCH /api/v1/webhooks/{id}com{ "active": false }pausa o endpoint, etruereativa.DELETE /api/v1/webhooks/{id}remove.
Regras: a URL precisa ser https:// e pública (um endereço interno retorna 400 invalid_url), os eventos precisam ser da lista oficial e cada conta pode ter até 20 endpoints (400 endpoint_limit).
Eventos disponíveis
| Evento | Quando chega |
|---|---|
transaction.pending |
A cobrança foi criada e aguarda pagamento |
transaction.paid |
O Pix foi pago |
transaction.refunded |
A devolução ao pagador concluiu; a cobrança passa a refunded |
transaction.refund_failed |
O provedor recusou o estorno; a cobrança segue paid e o valor retido volta ao saldo |
withdrawal.processing |
O saque está em processamento |
withdrawal.completed |
O saque foi concluído |
withdrawal.failed |
O saque falhou |
dispute.opened |
Uma contestação de Pix foi aberta; a defesa é feita no painel, em Disputas |
dispute.accepted |
A defesa foi aceita |
dispute.rejected |
A defesa foi rejeitada |
Os eventos dispute.* só chegam quando a sua conta tem o recurso de disputas ativo. Não existe evento para cobrança expirada: para saber disso, consulte o status pela API.
O que vem no corpo
Toda entrega tem o formato { event, data, createdAt }. Nos eventos transaction.*, o data segue o mesmo desenho da consulta de cobrança:
{
"event": "transaction.paid",
"data": {
"id": "clx...",
"livemode": true,
"amount": 5000,
"net": 4670,
"fee": 330,
"method": "pix",
"status": "paid",
"external_reference": "pedido-1234",
"description": "Pedido #1234",
"customer": { "name": "Cliente Exemplo", "email": "cliente@exemplo.com", "document": "00000000000" },
"payer": { "name": "Fulano de Tal", "document": "10900327570", "bank": "NU PAGAMENTOS - IP" },
"pix": { "endToEnd": "E18236120202609130426s1894658118" },
"paidAt": "2026-09-13T04:26:13.927Z"
},
"createdAt": "2026-09-13T04:26:14.100Z"
}Três detalhes que evitam bug:
- No webhook o valor vem em
amount(centavos). Na resposta da API de cobranças, o campo se chamaamountCents. netefeesó existem emtransaction.paid.- Em
transaction.pending,payerepix.endToEndvêmnull. Eles são preenchidos pelo provedor na confirmação e servem para conciliar com o extrato. Veja o que é o End-to-end ID.
Como validar a assinatura
O header chega assim: x-webhook-signature: sha256=<hex>. O valor é o HMAC-SHA256 do corpo cru da requisição, calculado com o segredo do endpoint. Calcule do seu lado e compare. Duas regras importam:
- Use o corpo cru, exatamente como chegou. Se você fizer
JSON.parsee serializar de novo, os bytes mudam e a assinatura não bate. - Compare em tempo constante, para não vazar informação pelo tempo de resposta.
Em Node.js, com Express:
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
function isValid(raw, header, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(raw).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(header ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
// express.raw mantém o corpo como Buffer, sem parse
app.post("/webhooks/xflow", express.raw({ type: "application/json" }), async (req, res) => {
if (!isValid(req.body, req.get("x-webhook-signature"), process.env.XFLOW_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const { event, data } = JSON.parse(req.body.toString("utf8"));
if (event === "transaction.paid" && data.livemode) {
await darBaixaNoPedido(data.external_reference, data); // a sua regra de negócio
}
res.sendStatus(200);
});Em Python, com Flask:
import hashlib
import hmac
import json
import os
from flask import Flask, request
app = Flask(__name__)
SEGREDO = os.environ["XFLOW_WEBHOOK_SECRET"]
def assinatura_valida(raw: bytes, header: str) -> bool:
esperado = "sha256=" + hmac.new(SEGREDO.encode(), raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperado, header or "")
@app.post("/webhooks/xflow")
def webhook():
raw = request.get_data() # corpo cru
if not assinatura_valida(raw, request.headers.get("x-webhook-signature", "")):
return "", 401
evento = json.loads(raw)
if evento["event"] == "transaction.paid" and evento["data"]["livemode"]:
dar_baixa_no_pedido(evento["data"]["external_reference"], evento["data"])
return "", 200Mais exemplos completos estão em API Pix com Node.js e API Pix com Python.
Reenvios e boas práticas
A xFlow reenvia cada evento até 3 vezes e considera a entrega confirmada quando recebe 2xx. Disso saem as regras do seu handler:
- Seja idempotente. O mesmo evento pode chegar mais de uma vez. Antes de dar baixa, confira se o pedido já foi processado. Use a combinação de
data.idcomeventcomo chave para descartar repetições. O mesmo raciocínio vale para as chamadas que você faz à API: veja idempotência em API de pagamento. - Responda rápido. Valide a assinatura, grave o evento e responda
2xx. Deixe envio de e-mail, nota fiscal e outras tarefas para uma fila. - Filtre o modo de teste. Eventos do sandbox chegam com
livemode: false(epayer: null). Nunca libere um pedido real com base neles. - Use a sua referência. O
external_referencevolta em todo evento da cobrança. É o jeito mais simples de achar o pedido no seu banco. - Tenha um plano B. Se o seu servidor ficar fora do ar por mais tempo que os reenvios cobrem, recupere o estado pela listagem, com
GET /api/v1/charges?status=paid&from=...&to=.... - Trate os estornos.
transaction.refundedconfirma a devolução.transaction.refund_failedavisa que a cobrança continua paga e o valor voltou ao saldo. O fluxo completo está em estorno Pix por API.
Como testar o webhook sem dinheiro real
Crie uma chave de teste, cadastre o endpoint, crie uma cobrança com a chave de teste e chame POST /api/v1/test/charges/{id}/pay. O evento transaction.paid chega na sua URL com livemode: false e a taxa calculada com as taxas reais da sua conta. O passo a passo está em API Pix sandbox, e a referência completa dos eventos fica na documentação de webhooks. Para ver todos os recursos para quem integra, acesse Desenvolvedores.
Perguntas frequentes
Quantas vezes a xFlow reenvia um webhook?
Até 3 vezes. A entrega é confirmada quando a sua URL responde com um status 2xx. Como o mesmo evento pode chegar mais de uma vez, processe cada um de forma idempotente.
Como validar a assinatura do webhook Pix?
Calcule o HMAC-SHA256 do corpo cru da requisição com o segredo do endpoint, em hexadecimal, prefixe com sha256= e compare com o header x-webhook-signature usando comparação de tempo constante.
O webhook funciona no ambiente de teste?
Sim. Ao simular um pagamento com a chave de teste, o evento transaction.paid é disparado com livemode: false no data. Filtre por esse campo antes de dar baixa em pedidos reais.
Posso usar uma URL local ou interna?
Não. A URL precisa começar com https:// e ser pública. Um endereço interno retorna 400 invalid_url. Para testar localmente, use um túnel HTTPS público apontando para a sua máquina.