Instead of polling to check if the payment landed, we notify your server the instant the status changes, and keep retrying with spaced-out attempts up to 40 times if your system doesn't respond.
What is a webhook (callback)
A webhook (also called a callback) is a POST request that PayZu sends to your server when something happens. Unlike the normal API (where you call PayZu), here it's the opposite: PayZu calls you.
Think about a Pix charge. You created it, displayed the QR to the customer, and now you need to know when the customer pays. Two options:
- Polling, asking every X seconds "did they pay yet? did they pay yet?" (costly, slow, unnecessary).
- Webhook, letting PayZu notify you as soon as the payment comes in (instant, efficient, recommended).
How to configure
You can receive notifications in two ways:
- Registered webhook (recommended): register a persistent URL at
POST /user/webhooks, with HMAC secret and event selection. The same URL applies to all transactions. - Per-transaction
callbackUrl: provide the URL in thecallbackUrlfield of the body on each created transaction:
{
"amount": 99.90,
"callbackUrl": "https://yoursite.com/webhooks/payzu",
"clientReference": "pedido-2025-001"
}PayZu will send the callback to this URL every time that transaction changes status (PENDING → COMPLETED, COMPLETED → REFUNDED, etc).
Create a public endpoint on your server
Somewhere accessible over the internet that accepts POST with JSON. Examples: https://yoursite.com/webhooks/payzu, https://api.yourcompany.com/payzu/callback.
During local development, use tunnels like ngrok or Cloudflare Tunnel to expose localhost.
Pass the URL when creating the transaction
In every POST /pix, POST /withdraw, POST /internal-transfer, include the callbackUrl field. It can be the same URL for all.
Implement the handler
Receive the POST, read the JSON, process it, and respond 2xx within 5 seconds. See examples in Receive Pix · step 3.
PayZu sends Content-Type: application/json. The other delivery headers are
in Delivery headers.
Events
The events field of POST /user/webhooks
defines which changes trigger the notification. Leave it empty to receive all.
Seven events track the transaction's status, one for each value:
| Event | Triggers when | status in payload |
|---|---|---|
TRANSACTION_PENDING | The charge was created and awaits payment, or the Pix payment entered processing. Internal transfer does not go through PENDING. | PENDING |
TRANSACTION_COMPLETED | The payment was confirmed. For a deposit, the customer paid; for a Pix payment, the money went out. | COMPLETED |
TRANSACTION_CANCELED | The transaction was canceled before completing, by manual action or by rule. | CANCELED |
TRANSACTION_WAITING_FOR_REFUND | The refund entered the processing queue, usually after an accepted MED. | WAITING_FOR_REFUND |
TRANSACTION_REFUNDED | The amount was returned to the payer. | REFUNDED |
TRANSACTION_EXPIRED | The charge passed expiresIn without being paid. | EXPIRED |
TRANSACTION_ERROR | The transaction failed in processing. | ERROR |
If the transaction's status changes before the queue processes the event, the delivery
of the registered webhook is discarded: there is no send, no history record, no retry. In
fast Pix, TRANSACTION_PENDING typically doesn't arrive, so don't require a prior event
to accept TRANSACTION_COMPLETED.
Three events don't mirror the status:
| Event | Triggers when |
|---|---|
INFRACTION_CHANGED | A MED infraction linked to one of your transactions was opened, had its status changed, or was closed. The body is the transaction's, with the infraction object attached: the transaction id comes in id and the infraction's in infraction.id. |
TRANSACTION_SUSPECTED_FRAUD | Reserved. No service emits this event today. |
TRANSACTION_SUSPECTED_FRAUD_REVERSAL | Reserved. No service emits this event today. |
The two suspected-fraud events can be subscribed to, but no delivery is generated for them today. A webhook that subscribes only to these two receives nothing.
Retry system
PayZu webhooks have a robust retry system that guarantees delivery even under temporary failures. PayZu resends the same callback up to 40 times with exponential backoff and jitter, better distributing the load and avoiding request spikes.
Response time: the webhook must respond with a 2xx (for example
200 or 204) within 5 seconds. A response outside the 2xx range, including
4xx, and timeout enter the retry cycle just the same.
Security
To ensure integrity and security, restrict access to your webhook endpoint. Request the official PayZu Processamento IP from support and only accept callbacks from that origin.
Delivery headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Callback-Service/1.0 |
X-Callback-Attempt | Attempt number of this delivery. |
X-Callback-Event | The event that triggered the delivery. Only present in registered webhooks. |
X-Callback-Signature | HMAC signature. Present whenever the delivery has a secret: the registered webhook's or the account's callback secret. |
INFRACTION_CHANGED arrives with the transaction's status unchanged, so
X-Callback-Event is what distinguishes the deliveries when you subscribe to more than one event.
HMAC verification
Every signed delivery carries X-Callback-Signature. Which secret signs depends on the destination:
| Delivery destination | Signing secret | Where to create |
|---|---|---|
| Registered webhook | Webhook secret | generateSecret: true in POST /user/webhooks, or POST /user/webhooks/{id}/rotate-secret |
Transaction's callbackUrl | Account callback secret | POST /v1/user/callbacks/secret, rotated via PATCH /v1/user/callbacks/secret/rotate |
Delivery to the transaction's callbackUrl is only signed if the account has a callback
secret registered. Without this secret there is no signature: create the secret or
protect the endpoint by source IP.
Validate the signature before processing the body:
Read the X-Callback-Signature header. The value comes as t=<timestamp>, v1=<signature>,
with the send time in seconds (Unix) and the signature in 64-character hexadecimal.
Build the base string by concatenating the timestamp and the raw request body, separated
by ., forming <timestamp>.<body>.
Generate an HMAC SHA-256 of this string using the destination's secret, the webhook's or
the account's callback secret, and compare with the v1 value in constant time. If
they don't match, reject the delivery.
Also reject timestamps outside a tolerance window. Each attempt is signed at send time, so a retry's timestamp is always recent.
Node.js example, using crypto.timingSafeEqual to compare signatures in constant time:
const crypto = require("node:crypto");
const TOLERANCE_SECONDS = 300;
function verifyCallbackSignature(request, webhookSecret) {
const header = request.headers["x-callback-signature"];
if (typeof header !== "string") return false;
const parts = Object.fromEntries(
header.split(",").map((part) => part.trim().split("=")),
);
const timestamp = Number(parts.t);
const signature = parts.v1;
if (!Number.isInteger(timestamp) || !/^[0-9a-f]{64}$/i.test(signature ?? "")) {
return false;
}
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
if (age > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", webhookSecret)
.update(`${timestamp}.${request.rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(signature, "hex"),
);
}Compute the HMAC over the raw request body, exactly as received, before any JSON parsing.
Payload fields
Identification
| Field | Type | Description |
|---|---|---|
id | string | Transaction ID |
clientReference | string | External reference you provided |
virtualAccount | string | Virtual subaccount (up to 50 characters). Returned in the callback to correlate stores, branches, marketplaces. |
callbackUrl | string | URL configured to receive this webhook |
Status and amounts
| Field | Type | Description |
|---|---|---|
status | string | PENDING, COMPLETED, CANCELED, WAITING_FOR_REFUND, REFUNDED, EXPIRED, ERROR |
type | string | DEPOSIT, WITHDRAW, COMMISSION |
method | string | PIX, BANK_SLIP, INTERNAL_TRANSFER |
amount | number | Amount in BRL |
serviceFeeCharged | number | Fee charged |
COMMISSION identifies a commission entry credited to your account and arrives with TRANSACTION_COMPLETED.
Generated charge (deposit)
| Field | Type | Description |
|---|---|---|
qrCodeText | string | Pix copy-and-paste code |
qrCodeUrl | string | QR Code image URL |
qrCodeBase64 | string | QR Code image in Base64 format |
generatedName | string | Reference name |
generatedDocument | string | CPF or CNPJ |
generatedEmail | string | Email linked to the transaction |
Payer
| Field | Type | Description |
|---|---|---|
payerName | string | Payer's name |
payerDocument | string | Payer's document |
payerInstitutionIspb | string | ISPB of the payer's bank |
payerInstitutionName | string | Name of the payer's bank |
payerAccountNumber | string | Payer's PayZu account (6 digits). Filled in when the PayZu account is the one paying: Pix payments and internal transfers. |
Receiver
| Field | Type | Description |
|---|---|---|
receiverName | string | Recipient's name |
receiverDocument | string | Recipient's document |
receiverInstitutionIspb | string | ISPB of the recipient's bank |
receiverInstitutionName | string | Name of the recipient's bank |
receiverAccountNumber | string | Recipient's PayZu account (6 digits). Filled in when the PayZu account is the one receiving: deposits and internal transfers. |
Pix payment via key
| Field | Type | Description |
|---|---|---|
withdrawPixKey | string | Pix key used in the payment |
withdrawPixType | string | cpf, cnpj, phone, email, evp |
Settlement and refund
| Field | Type | Description |
|---|---|---|
endToEndId | string | Pix EndToEnd ID |
paidAt | string | Payment timestamp (ISO 8601) |
cancellationReason | string | Cancellation reason |
refundEndToEndId | string | Refund EndToEnd ID |
refundAmount | string | Refunded amount |
refundStatus | string | PENDING, COMPLETED, CANCELED |
refundReason | string | Refund reason |
refundDescription | string | Refund description |
refundedAt | string | Refund timestamp (ISO 8601) |
Timestamps
| Field | Type | Description |
|---|---|---|
createdAt | string | Creation timestamp (ISO 8601) |
updatedAt | string | Update timestamp (ISO 8601) |
Infraction (Pix dispute)
| Field | Type | Description |
|---|---|---|
infraction | object | Infraction details when opened (see MED) |
Best practices
- Respond fast: return
2xxin under 5s. Do heavy processing in a queue/worker, not in the handler. - Idempotency: dedupe by
idplus the event, not just byid+status. The same callback may arrive more than once (retry, successive changes), andINFRACTION_CHANGEDdoesn't change thestatus. See Callback dedupe. - Use
clientReference: pass an external identifier when creating the transaction. It comes back in the callback and makes it easier to correlate with your order. - Restrict by IP: only accept callbacks from PayZu's official IP.
- Respond
2xxto end delivery: any response outside the2xxrange, including4xx, and any timeout enter the same cycle of up to 40 attempts. To stop the resend, respond2xxand handle the error on your side. - Mask
payerDocumentin logs: printing the payload without masking personal data is an LGPD risk.
Test and resend
Test locally
Expose your localhost via ngrok or Cloudflare Tunnel and trigger the payload manually:
curl -X POST https://your-tunnel.ngrok.io/webhooks/payzu \
-H "Content-Type: application/json" \
-d '{
"id": "PAYZU20260811K7M2X9QP4T000000",
"type": "DEPOSIT",
"status": "COMPLETED",
"amount": 99.90,
"clientReference": "order-1234",
"virtualAccount": "loja-rj-01",
"paidAt": "2026-08-11T10:46:26.986Z"
}'await fetch('https://your-tunnel.ngrok.io/webhooks/payzu', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: 'PAYZU20260811K7M2X9QP4T000000',
type: 'DEPOSIT',
status: 'COMPLETED',
amount: 99.90,
clientReference: 'order-1234',
virtualAccount: 'loja-rj-01',
paidAt: '2026-08-11T10:46:26.986Z',
}),
});import requests
requests.post(
'https://your-tunnel.ngrok.io/webhooks/payzu',
headers={'Content-Type': 'application/json'},
json={
'id': 'PAYZU20260811K7M2X9QP4T000000',
'type': 'DEPOSIT',
'status': 'COMPLETED',
'amount': 99.90,
'clientReference': 'order-1234',
'virtualAccount': 'loja-rj-01',
'paidAt': '2026-08-11T10:46:26.986Z',
},
)Resend a real callback
The resend endpoint depends on where the destination URL is configured.
callbackUrl provided in the transaction:
POST /user/callbacks/resend/{transactionId}, one transactionPOST /user/callbacks/resend, batch by filter, with mandatory date window
Both only reach transactions with callbackUrl filled in and do not generate delivery for a registered webhook.
Registered webhook:
POST /user/callbacks/resend/webhook/{webhookId}, requeues the failed deliveries on that webhook
The webhook resend reprocesses one delivery per pair of transaction and event, and considers a failure any response from 300 on and the absence of a response. An event whose status no longer matches the transaction's current one is still discarded on resend.
The response comes in enqueued, with count (total accepted for resend), truncated, and items. The items list stops at 500 entries. When truncated is true, the resend still covers all count, only the response's list was cut.
200 means accepted for resend, not queued delivery: queuing happens
after the response. And the route no longer responds 200 with count: 0. Without an active webhook
for the {webhookId} it responds 404 PZW300, and without a failed callback in the period,
404 PZW310.
Inspect the history
PayZu stores all delivery attempts. Useful for investigating failures:
GET /user/callbacks, paginated listGET /user/callbacks/{id}, detail with status code, response body, response time