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 event | V2 webhook type |
|---|---|
cash_in | RECEIVE |
cash_out | TRANSFER |
refund_in | REFUND (you refunded a received PIX) |
refund_out | REFUND (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
| Type | Description | V1 equivalent |
|---|---|---|
RECEIVE | PIX received (Cash-In) | CashIn |
TRANSFER | PIX sent (Cash-Out) | CashOut |
REFUND | Refund (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;
}| Field | Description |
|---|---|
id | Transaction 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 |
txId | External transaction identifier: the charge txid in RECEIVE, the x-idempotency-key in TRANSFER and the original transaction's one in REFUND |
endToEndId | PIX End to End ID (in REFUND, the original PIX's) |
providerId | Transaction ID at the settlement provider, or null if not yet assigned |
payment.amount | Amount in reais as a decimal string, with no fixed number of decimal places (e.g. "100.5", "100", "0.01") |
mismatchAmount | Only in RECEIVE and TRANSFER. Difference between the charged amount and the amount actually paid, when there is one; otherwise null |
refunds | Empty in RECEIVE and TRANSFER. In REFUND, it carries one item with the refund that triggered the webhook |
errorCode | Error code when status is ERROR; otherwise null. The set of codes depends on the provider |
debtorAccount / creditorAccount | Counterparty data. Your own account's data always comes with all fields null |
name / bankName | Account holder name / institution name |
document | Counterparty CPF/CNPJ, as provided by the provider |
ticketData | Always {} |
V1 vs V2 Differences
| Aspect | V1 | V2 |
|---|---|---|
| Format | Fields at the root | {data, type} envelope |
| Event type | event: "CashIn" | type: "RECEIVE" |
| Aspect | V1 | V2 |
|---|---|---|
| PIX success | CONFIRMED | LIQUIDATED |
| Refund success | CONFIRMED | REFUNDED |
| Error | ERROR | ERROR |
| Aspect | V1 | V2 |
|---|---|---|
| Field | counterpart | debtorAccount / creditorAccount |
| Bank | bank.bankName | bankName |
| ISPB | bank.bankISPB | ispb |
| Aspect | V1 | V2 |
|---|---|---|
| Type | number | string (except refunds[].payment.amount, which is a number) |
| Format | 100.5 | "100.5" |
Account Mapping
PIX Received (RECEIVE)
debtorAccount = Who paid (counterparty)
creditorAccount = Your account (null fields)
creditDebitType = CREDITPIX Sent (TRANSFER)
debtorAccount = Your account (null fields)
creditorAccount = Who received (counterparty)
creditDebitType = DEBITRefund of a Receipt (REFUND, refund_in event)
debtorAccount = Your account (null fields)
creditorAccount = Who gets the money back (counterparty)
creditDebitType = DEBITRefund of a Transfer (REFUND, refund_out event)
debtorAccount = Who is refunding (counterparty)
creditorAccount = Your account (null fields)
creditDebitType = CREDITDelivery
- HTTPS URL; up to 3 URLs per event
POSTmethod,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:
| Attempt | Interval | Cumulative |
|---|---|---|
| 1st | Immediate | 0 |
| 2nd | 30 seconds | 30 s |
| 3rd | 1 minute | 1 min 30 s |
| 4th | 3 minutes | 4 min 30 s |
| 5th | 5 minutes | 9 min 30 s |
| 6th | 15 minutes | 24 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.