docs
Webhooks V2

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 registradotype del webhook V2
cash_inRECEIVE
cash_outTRANSFER
refund_inREFUND (usted devolvió un PIX recibido)
refund_outREFUND (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

TypeDescripciónEquivalente V1
RECEIVEPIX recibido (Cash-In)CashIn
TRANSFERPIX enviado (Cash-Out)CashOut
REFUNDDevolució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;
}
CampoDescripción
idID 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
txIdIdentificador 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
endToEndIdEnd to End ID del PIX (en REFUND, el del PIX original)
providerIdID de la transacción en el proveedor de liquidación, o null si aún no fue asignado
payment.amountValor en reales como string decimal, sin número fijo de decimales (ej.: "100.5", "100", "0.01")
mismatchAmountSolo en RECEIVE y TRANSFER. Diferencia entre el valor cobrado y el efectivamente pagado, cuando exista; si no, null
refundsVacío en RECEIVE y TRANSFER. En REFUND, trae un ítem con la devolución que generó el webhook
errorCodeCódigo de error cuando status es ERROR; si no, null. El conjunto de códigos depende del proveedor
debtorAccount / creditorAccountDatos de la contraparte. Los datos de su propia cuenta vienen siempre con todos los campos null
name / bankNameNombre del titular de la cuenta / nombre de la institución
documentCPF/CNPJ de la contraparte, tal como lo informa el proveedor
ticketDataSiempre {}

Diferencias V1 vs V2

AspectoV1V2
FormatoCampos en la raízSobre {data, type}
Tipo de eventoevent: "CashIn"type: "RECEIVE"
AspectoV1V2
Éxito PIXCONFIRMEDLIQUIDATED
Éxito devoluciónCONFIRMEDREFUNDED
ErrorERRORERROR
AspectoV1V2
CampocounterpartdebtorAccount / creditorAccount
Bancobank.bankNamebankName
ISPBbank.bankISPBispb
AspectoV1V2
Tiponumberstring (excepto refunds[].payment.amount, que es number)
Formato100.5"100.5"

Mapeo de Cuentas

PIX Recibido (RECEIVE)

debtorAccount = Quien pagó (contraparte)
creditorAccount = Su cuenta (campos null)
creditDebitType = CREDIT

PIX Enviado (TRANSFER)

debtorAccount = Su cuenta (campos null)
creditorAccount = Quien recibió (contraparte)
creditDebitType = DEBIT

Devolución de Recepción (REFUND, evento refund_in)

debtorAccount = Su cuenta (campos null)
creditorAccount = Quien recibe de vuelta (contraparte)
creditDebitType = DEBIT

Devolución de Envío (REFUND, evento refund_out)

debtorAccount = Quien está devolviendo (contraparte)
creditorAccount = Su cuenta (campos null)
creditDebitType = CREDIT

Entrega

  • 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:

IntentoIntervaloAcumulado
1ºInmediato0
2º30 segundos30 s
3º1 minuto1 min 30 s
4º3 minutos4 min 30 s
5º5 minutos9 min 30 s
6º15 minutos24 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.

Próximos Pasos

En esta página