Descripción General
¿Qué son los Webhooks V2?
Los webhooks V2 son notificaciones enviadas a su aplicación cuando una transacción PIX cambia de estado. El formato V2 usa un sobre {data, type}, separa al pagador (debtorAccount) y al receptor (creditorAccount) y usa los estados LIQUIDATED y REFUNDED.
Cómo recibir en formato V2
El formato se elige por URL de webhook. En el panel (https://banking.fypay.me), en Configuración → Webhooks, al registrar la URL elija la versión del payload PIX BACEN. Las URLs registradas como Estándar (V1) siguen recibiendo el formato V1.
El formato V2 aplica a los eventos de transacción:
| Evento registrado | type del webhook V2 |
|---|---|
cash_in | RECEIVE |
cash_out | TRANSFER |
refund_in | REFUND (usted devolvió un PIX recibido) |
refund_out | REFUND (usted recibió de vuelta un PIX enviado) |
Los eventos de MED (med_created, med_accepted, med_rejected) no cambian con la versión: siempre se entregan en el formato descrito en Webhooks de MED.
Estructura Base
Todos los webhooks V2 siguen esta estructura:
{
"data": {
"...": "datos del evento"
},
"type": "RECEIVE"
}type es RECEIVE, TRANSFER o REFUND.
Tipos de Evento
| Type | Descripción | Equivalente V1 |
|---|---|---|
RECEIVE | PIX recibido (Cash-In) | CashIn |
TRANSFER | PIX enviado (Cash-Out) | CashOut |
REFUND | Devolución (de recepción o de envío) | CashInReversal / CashOutReversal |
Estructura Completa del 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 | Descripción |
|---|---|
id | ID de la transacción en FyPay. En REFUND, es el ID de la transacción original. En TRANSFER, puede ser null cuando la transferencia fue rechazada antes de crearse |
txId | Identificador externo de la transacción: el txid del cobro en RECEIVE, el x-idempotency-key en TRANSFER y el de la transacción original en REFUND |
endToEndId | End to End ID del PIX (en REFUND, el del PIX original) |
providerId | ID de la transacción en el proveedor de liquidación, o null si aún no fue asignado |
payment.amount | Valor en reales como string decimal, sin número fijo de decimales (ej.: "100.5", "100", "0.01") |
mismatchAmount | Solo en RECEIVE y TRANSFER. Diferencia entre el valor cobrado y el efectivamente pagado, cuando exista; si no, null |
refunds | Vacío en RECEIVE y TRANSFER. En REFUND, trae un ítem con la devolución que generó el webhook |
errorCode | Código de error cuando status es ERROR; si no, null. El conjunto de códigos depende del proveedor |
debtorAccount / creditorAccount | Datos de la contraparte. Los datos de su propia cuenta vienen siempre con todos los campos null |
name / bankName | Nombre del titular de la cuenta / nombre de la institución |
document | CPF/CNPJ de la contraparte, tal como lo informa el proveedor |
ticketData | Siempre {} |
Diferencias V1 vs V2
| Aspecto | V1 | V2 |
|---|---|---|
| Formato | Campos en la raíz | Sobre {data, type} |
| Tipo de evento | event: "CashIn" | type: "RECEIVE" |
| Aspecto | V1 | V2 |
|---|---|---|
| Éxito PIX | CONFIRMED | LIQUIDATED |
| Éxito devolución | CONFIRMED | REFUNDED |
| Error | ERROR | ERROR |
| Aspecto | V1 | V2 |
|---|---|---|
| Campo | counterpart | debtorAccount / creditorAccount |
| Banco | bank.bankName | bankName |
| ISPB | bank.bankISPB | ispb |
| Aspecto | V1 | V2 |
|---|---|---|
| Tipo | number | string (excepto refunds[].payment.amount, que es number) |
| Formato | 100.5 | "100.5" |
Mapeo de Cuentas
PIX Recibido (RECEIVE)
debtorAccount = Quien pagó (contraparte)
creditorAccount = Su cuenta (campos null)
creditDebitType = CREDITPIX Enviado (TRANSFER)
debtorAccount = Su cuenta (campos null)
creditorAccount = Quien recibió (contraparte)
creditDebitType = DEBITDevolución de Recepción (REFUND, evento refund_in)
debtorAccount = Su cuenta (campos null)
creditorAccount = Quien recibe de vuelta (contraparte)
creditDebitType = DEBITDevolución de Envío (REFUND, evento refund_out)
debtorAccount = Quien está devolviendo (contraparte)
creditorAccount = Su cuenta (campos null)
creditDebitType = CREDITEntrega
- URL HTTPS; hasta 3 URLs por evento
- Método
POST,Content-Type: application/json - Timeout de 10 segundos; solo las respuestas 2xx cuentan como éxito
- Hasta 5 headers personalizados por URL, registrados en el panel
- IPs de origen:
184.197.16.190,54.85.39.76,184.197.130.162,184.196.180.161,52.0.159.232,54.88.75.254
Autenticación
Después de que usted genera la clave de firma en el panel, todo webhook pasa a traer el header X-Avista-Signature con el HMAC-SHA256 (hex) del cuerpo crudo de la solicitud. Mientras la clave no se genere, los webhooks se entregan sin ese header.
El nombre del header es realmente X-Avista-Signature. Valide la firma sobre los bytes recibidos, antes de hacer el parse del JSON. Vea cómo validar.
FyPay no envía Basic Auth por su cuenta. Si su endpoint exige Authorization u otro secreto, regístrelo como header personalizado.
Ejemplo 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 recibido: 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 falló: ${data.errorCode}`);
}
}
function handleRefund(data: WebhookV2Data) {
const refund = data.refunds[0];
if (data.status === 'REFUNDED') {
console.log(`Devolución: R$ ${refund.payment.amount}`);
}
}En producción, use un almacenamiento persistente (base de datos o Redis) para la deduplicación. El mismo webhook puede llegar más de una vez por los reintentos o por un reenvío manual.
Reintentos
Si su endpoint no responde con HTTP 2xx en 10 segundos, la entrega se repite:
| Intento | Intervalo | Acumulado |
|---|---|---|
| 1º | Inmediato | 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 |
Tras 6 intentos sin éxito, el webhook ya no se reenvía automáticamente. Puede pedir un reenvío con POST https://api.fypay.me/api/resend-webhook/{transactionIdentifier} (límite de 60 solicitudes por minuto). El reenvío usa la versión de payload registrada en cada URL. Vea Reenvío de webhook.
Implemente una consulta periódica como respaldo para garantizar que no se pierda ninguna transacción.