Visão Geral
O que são Webhooks V2?
Webhooks V2 são notificações enviadas para a sua aplicação quando uma transação PIX muda de estado. O formato V2 usa um envelope {data, type}, separa pagador (debtorAccount) e recebedor (creditorAccount) e usa os status LIQUIDATED e REFUNDED.
Como receber no formato V2
O formato é escolhido por URL de webhook. No painel (https://banking.fypay.me), em Configurações → Webhooks, ao cadastrar a URL escolha a versão do payload PIX BACEN. URLs cadastradas como Padrão (V1) continuam recebendo o formato V1.
O formato V2 vale para os eventos de transação:
| Evento cadastrado | type do webhook V2 |
|---|---|
cash_in | RECEIVE |
cash_out | TRANSFER |
refund_in | REFUND (você devolveu um PIX recebido) |
refund_out | REFUND (você recebeu de volta um PIX enviado) |
Os eventos de MED (med_created, med_accepted, med_rejected) não mudam com a versão: são sempre entregues no formato descrito em Webhooks de MED.
Estrutura Base
Todos os webhooks V2 seguem esta estrutura:
{
"data": {
"...": "dados do evento"
},
"type": "RECEIVE"
}type é RECEIVE, TRANSFER ou REFUND.
Tipos de Evento
| Type | Descrição | Equivalente V1 |
|---|---|---|
RECEIVE | PIX recebido (Cash-In) | CashIn |
TRANSFER | PIX enviado (Cash-Out) | CashOut |
REFUND | Devolução (de recebimento ou de envio) | CashInReversal / CashOutReversal |
Estrutura Completa do Data
interface WebhookV2Data {
id: number | null;
txId: string | null;
endToEndId: string | null;
providerId: string | null;
pixKey: string | null;
status: 'PENDING' | 'LIQUIDATED' | 'REFUNDED' | 'ERROR';
payment: {
amount: string;
currency: 'BRL';
};
mismatchAmount?: string | null;
refunds: RefundInfo[];
createdAt: string;
errorCode: string | null;
webhookType: 'RECEIVE' | 'TRANSFER' | 'REFUND';
creditDebitType: 'CREDIT' | 'DEBIT';
transactionType: 'PIX';
localInstrument: 'DICT';
debtorAccount: AccountInfo;
creditorAccount: AccountInfo;
idempotencyKey: string | null;
ticketData: Record<string, unknown>;
remittanceInformation: string | null;
}
interface AccountInfo {
ispb: string | null;
name: string | null;
bankName: string | null;
issuer: string | null;
number: string | null;
document: string | null;
accountType: string | null;
}
interface RefundInfo {
status: 'PENDING' | 'LIQUIDATED' | 'ERROR';
payment: {
amount: number;
currency: 'BRL';
};
errorCode: string | null;
eventDate: string;
endToEndId: string | null;
information: string | null;
}| Campo | Descrição |
|---|---|
id | ID da transação na FyPay. Em REFUND, é o ID da transação original. Em TRANSFER, pode ser null quando a transferência foi recusada antes de ser criada |
txId | Identificador externo da transação: o txid da cobrança em RECEIVE, o x-idempotency-key em TRANSFER e o da transação original em REFUND |
endToEndId | End to End ID do PIX (em REFUND, o do PIX original) |
providerId | ID da transação no provedor de liquidação, ou null se ainda não atribuído |
payment.amount | Valor em reais como string decimal, sem número fixo de casas (ex.: "100.5", "100", "0.01") |
mismatchAmount | Só em RECEIVE e TRANSFER. Diferença entre o valor cobrado e o efetivamente pago, quando houver; senão null |
refunds | Vazio em RECEIVE e TRANSFER. Em REFUND, traz um item com a devolução que gerou o webhook |
errorCode | Código de erro quando status é ERROR; senão null. O conjunto de códigos depende do provedor |
debtorAccount / creditorAccount | Dados da contraparte. Os dados da sua própria conta vêm sempre com todos os campos null |
name / bankName | Nome do titular da conta / nome da instituição |
document | CPF/CNPJ da contraparte, como informado pelo provedor |
ticketData | Sempre {} |
Diferenças V1 vs V2
| Aspecto | V1 | V2 |
|---|---|---|
| Formato | Campos na raiz | Envelope {data, type} |
| Tipo evento | event: "CashIn" | type: "RECEIVE" |
| Aspecto | V1 | V2 |
|---|---|---|
| Sucesso PIX | CONFIRMED | LIQUIDATED |
| Sucesso devolução | CONFIRMED | REFUNDED |
| Erro | ERROR | ERROR |
| Aspecto | V1 | V2 |
|---|---|---|
| Campo | counterpart | debtorAccount / creditorAccount |
| Banco | bank.bankName | bankName |
| ISPB | bank.bankISPB | ispb |
| Aspecto | V1 | V2 |
|---|---|---|
| Tipo | number | string (exceto refunds[].payment.amount, que é number) |
| Formato | 100.5 | "100.5" |
Mapeamento de Contas
PIX Recebido (RECEIVE)
debtorAccount = Quem pagou (contraparte)
creditorAccount = Sua conta (campos null)
creditDebitType = CREDITPIX Enviado (TRANSFER)
debtorAccount = Sua conta (campos null)
creditorAccount = Quem recebeu (contraparte)
creditDebitType = DEBITDevolução de Recebimento (REFUND, evento refund_in)
debtorAccount = Sua conta (campos null)
creditorAccount = Quem recebe de volta (contraparte)
creditDebitType = DEBITDevolução de Envio (REFUND, evento refund_out)
debtorAccount = Quem está devolvendo (contraparte)
creditorAccount = Sua conta (campos null)
creditDebitType = CREDITEntrega
- URL HTTPS; até 3 URLs por evento
- Método
POST,Content-Type: application/json - Timeout de 10 segundos; só respostas 2xx contam como sucesso
- Até 5 headers customizados por URL, cadastrados no painel
- IPs de origem:
184.197.16.190,54.85.39.76,184.197.130.162,184.196.180.161,52.0.159.232,54.88.75.254
Autenticação
Depois que você gera a chave de assinatura no painel, todo webhook passa a trazer o header X-Avista-Signature com o HMAC-SHA256 (hex) do corpo bruto da requisição. Enquanto a chave não for gerada, os webhooks são entregues sem esse header.
O nome do header é mesmo X-Avista-Signature. Valide a assinatura sobre os bytes recebidos, antes de fazer o parse do JSON. Veja como validar.
A FyPay não envia Basic Auth por conta própria. Se o seu endpoint exige Authorization ou outro segredo, cadastre-o como header customizado.
Exemplo de Handler
import express from 'express';
import crypto from 'node:crypto';
const app = express();
app.use(express.raw({ type: 'application/json' }));
const WEBHOOK_SECRET = process.env.FYPAY_WEBHOOK_SECRET!;
const processed = new Set<string>();
function isValidSignature(rawBody: Buffer, signature: string | undefined) {
if (!signature) return false;
const expected = crypto.createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function dedupKey(webhook: WebhookV2) {
const { type, data } = webhook;
if (type === 'REFUND') {
const refund = data.refunds[0];
return `REFUND:${refund?.endToEndId ?? data.providerId}:${refund?.status}`;
}
return `${type}:${data.id ?? data.idempotencyKey}:${data.status}`;
}
app.post('/webhooks/pix', (req, res) => {
if (!isValidSignature(req.body, req.header('x-avista-signature'))) {
return res.status(401).end();
}
const webhook: WebhookV2 = JSON.parse(req.body.toString('utf8'));
res.status(200).json({ acknowledged: true });
const key = dedupKey(webhook);
if (processed.has(key)) return;
processed.add(key);
switch (webhook.type) {
case 'RECEIVE':
handleReceive(webhook.data);
break;
case 'TRANSFER':
handleTransfer(webhook.data);
break;
case 'REFUND':
handleRefund(webhook.data);
break;
}
});
function handleReceive(data: WebhookV2Data) {
if (data.status === 'LIQUIDATED') {
console.log(`PIX recebido: R$ ${data.payment.amount}`);
}
}
function handleTransfer(data: WebhookV2Data) {
if (data.status === 'LIQUIDATED') {
console.log(`PIX enviado: ${data.endToEndId}`);
} else if (data.status === 'ERROR') {
console.log(`PIX falhou: ${data.errorCode}`);
}
}
function handleRefund(data: WebhookV2Data) {
const refund = data.refunds[0];
if (data.status === 'REFUNDED') {
console.log(`Devolução: R$ ${refund.payment.amount}`);
}
}Em produção, use um armazenamento persistente (banco ou Redis) para a deduplicação. O mesmo webhook pode chegar mais de uma vez por causa das retentativas ou de um reenvio manual.
Retentativas
Se o seu endpoint não responder com HTTP 2xx em 10 segundos, a entrega é repetida:
| Tentativa | Intervalo | Acumulado |
|---|---|---|
| 1ª | Imediato | 0 |
| 2ª | 30 segundos | 30 s |
| 3ª | 1 minuto | 1 min 30 s |
| 4ª | 3 minutos | 4 min 30 s |
| 5ª | 5 minutos | 9 min 30 s |
| 6ª | 15 minutos | 24 min 30 s |
Após 6 tentativas sem sucesso, o webhook não é mais reenviado automaticamente. Você pode pedir um reenvio com POST https://api.fypay.me/api/resend-webhook/{transactionIdentifier} (limite de 60 requisições por minuto). O reenvio usa a versão de payload cadastrada em cada URL. Veja Reenvio de webhook.
Implemente uma consulta periódica como fallback para garantir que nenhuma transação seja perdida.