docs
Webhooks V2

Overview

What are V2 Webhooks?

V2 webhooks are notifications sent to your application when a PIX transaction changes state. The V2 format uses a {data, type} envelope, separates payer (debtorAccount) and receiver (creditorAccount) and uses the LIQUIDATED and REFUNDED statuses.

How to receive the V2 format

The format is chosen per webhook URL. In the dashboard (https://banking.fypay.me), under Settings → Webhooks, choose the PIX BACEN payload version when registering the URL. URLs registered as Default (V1) keep receiving the V1 format.

The V2 format applies to transaction events:

Registered eventV2 webhook type
cash_inRECEIVE
cash_outTRANSFER
refund_inREFUND (you refunded a received PIX)
refund_outREFUND (you got back a PIX you sent)

MED events (med_created, med_accepted, med_rejected) do not change with the version: they are always delivered in the format described in MED Webhooks.

Base Structure

All V2 webhooks follow this structure:

{
  "data": {
    "...": "event data"
  },
  "type": "RECEIVE"
}

type is RECEIVE, TRANSFER or REFUND.

Event Types

TypeDescriptionV1 equivalent
RECEIVEPIX received (Cash-In)CashIn
TRANSFERPIX sent (Cash-Out)CashOut
REFUNDRefund (of a receipt or of a transfer)CashInReversal / CashOutReversal

Full Data Structure

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;
}
FieldDescription
idTransaction ID at FyPay. In REFUND, it is the ID of the original transaction. In TRANSFER, it can be null when the transfer was rejected before being created
txIdExternal transaction identifier: the charge txid in RECEIVE, the x-idempotency-key in TRANSFER and the original transaction's one in REFUND
endToEndIdPIX End to End ID (in REFUND, the original PIX's)
providerIdTransaction ID at the settlement provider, or null if not yet assigned
payment.amountAmount in reais as a decimal string, with no fixed number of decimal places (e.g. "100.5", "100", "0.01")
mismatchAmountOnly in RECEIVE and TRANSFER. Difference between the charged amount and the amount actually paid, when there is one; otherwise null
refundsEmpty in RECEIVE and TRANSFER. In REFUND, it carries one item with the refund that triggered the webhook
errorCodeError code when status is ERROR; otherwise null. The set of codes depends on the provider
debtorAccount / creditorAccountCounterparty data. Your own account's data always comes with all fields null
name / bankNameAccount holder name / institution name
documentCounterparty CPF/CNPJ, as provided by the provider
ticketDataAlways {}

V1 vs V2 Differences

AspectV1V2
FormatFields at the root{data, type} envelope
Event typeevent: "CashIn"type: "RECEIVE"
AspectV1V2
PIX successCONFIRMEDLIQUIDATED
Refund successCONFIRMEDREFUNDED
ErrorERRORERROR
AspectV1V2
FieldcounterpartdebtorAccount / creditorAccount
Bankbank.bankNamebankName
ISPBbank.bankISPBispb
AspectV1V2
Typenumberstring (except refunds[].payment.amount, which is a number)
Format100.5"100.5"

Account Mapping

PIX Received (RECEIVE)

debtorAccount = Who paid (counterparty)
creditorAccount = Your account (null fields)
creditDebitType = CREDIT

PIX Sent (TRANSFER)

debtorAccount = Your account (null fields)
creditorAccount = Who received (counterparty)
creditDebitType = DEBIT

Refund of a Receipt (REFUND, refund_in event)

debtorAccount = Your account (null fields)
creditorAccount = Who gets the money back (counterparty)
creditDebitType = DEBIT

Refund of a Transfer (REFUND, refund_out event)

debtorAccount = Who is refunding (counterparty)
creditorAccount = Your account (null fields)
creditDebitType = CREDIT

Delivery

  • HTTPS URL; up to 3 URLs per event
  • POST method, Content-Type: application/json
  • 10 second timeout; only 2xx responses count as success
  • Up to 5 custom headers per URL, registered in the dashboard
  • Source IPs: 184.197.16.190, 54.85.39.76, 184.197.130.162, 184.196.180.161, 52.0.159.232, 54.88.75.254

Authentication

Once you generate the signing key in the dashboard, every webhook carries the X-Avista-Signature header with the HMAC-SHA256 (hex) of the request's raw body. Until the key is generated, webhooks are delivered without this header.

The header name really is X-Avista-Signature. Validate the signature over the received bytes, before parsing the JSON. See how to validate.

FyPay does not send Basic Auth on its own. If your endpoint requires Authorization or another secret, register it as a custom header.

Handler Example

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 received: R$ ${data.payment.amount}`);
  }
}

function handleTransfer(data: WebhookV2Data) {
  if (data.status === 'LIQUIDATED') {
    console.log(`PIX sent: ${data.endToEndId}`);
  } else if (data.status === 'ERROR') {
    console.log(`PIX failed: ${data.errorCode}`);
  }
}

function handleRefund(data: WebhookV2Data) {
  const refund = data.refunds[0];
  if (data.status === 'REFUNDED') {
    console.log(`Refund: R$ ${refund.payment.amount}`);
  }
}

In production, use persistent storage (database or Redis) for deduplication. The same webhook may arrive more than once because of retries or a manual resend.

Retries

If your endpoint does not respond with HTTP 2xx within 10 seconds, the delivery is retried:

AttemptIntervalCumulative
1stImmediate0
2nd30 seconds30 s
3rd1 minute1 min 30 s
4th3 minutes4 min 30 s
5th5 minutes9 min 30 s
6th15 minutes24 min 30 s

After 6 unsuccessful attempts, the webhook is no longer resent automatically. You can request a resend with POST https://api.fypay.me/api/resend-webhook/{transactionIdentifier} (limit of 60 requests per minute). The resend uses the payload version registered on each URL. See Webhook resend.

Implement periodic polling as a fallback to make sure no transaction is lost.

Next Steps

On this page