Receive a signed webhook on your server for every change on the account, such as a paid charge, a completed withdrawal or an opened dispute.
Webhooks arrive through two paths, which can be used together. With a registered endpoint and a callbackUrl on the operation, the webhook arrives at both.
| Path | Receives | Secret that signs |
|---|---|---|
| Registered endpoint | The events chosen in events | The endpoint's secret (whsec_…) |
Operation callbackUrl | All events of that operation | The account's callback secret (cbsec_…) |
Registered endpoint
Register the URL and the events with POST /transactions/webhooks, scope WEBHOOK_WRITE, or in the dashboard.
curl -X POST https://api.hub.payzu.com.br/api/v1/transactions/webhooks \
-H "Authorization: Bearer $PAYZU_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://sualoja.com.br/webhooks/payzu",
"events": ["PAYMENT_PAID", "PAYMENT_EXPIRED", "WITHDRAW_COMPLETED", "WITHDRAW_FAILED"]
}'const res = await fetch('https://api.hub.payzu.com.br/api/v1/transactions/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYZU_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://sualoja.com.br/webhooks/payzu',
events: ['PAYMENT_PAID', 'PAYMENT_EXPIRED', 'WITHDRAW_COMPLETED', 'WITHDRAW_FAILED'],
}),
});
const endpoint = await res.json();- The URL must be HTTPS, public and up to 2048 characters long. It cannot repeat the URL of another endpoint on the account.
- Send at least one event. The endpoint starts active.
- Store the
secretfrom the response: it appears only there. In the dashboard, the new secret button generates another one, and the previous one stops working right away. - To stop receiving, send
isActive: falsetoPUT /transactions/webhooks/{webhookId}. An endpoint that already had a delivery cannot be deleted: theDELETEresponds409WEBHOOK_HAS_DELIVERIES.
Operation callbackUrl
Send callbackUrl in the body of the charge, the withdrawal, the Pix copy-and-paste payment or the transfer to receive all webhooks of that operation. Here you do not choose events.
- First, issue the account's callback secret with
POST /transactions/callback-secret. Without it, an operation withcallbackUrlis refused with412CALLBACK_SECRET_MISSING. - The secret appears only in that response.
GET /transactions/callback-secretonly says whether it exists, andPOST /transactions/callback-secret/rotategenerates another one; the previous one stops working right away. - The URL follows the endpoint rules: HTTPS, public, up to 2048 characters.
- All events of the operation arrive, including refund and dispute, except
WITHDRAW_REFUND_RECEIVED, which goes only to registered endpoints. - In a transfer, the URL belongs to the sender: the
INTERNAL_TRANSFER_RECEIVED, from the destination account, does not go to it. - The URL is fixed at creation. Repeating the operation with the same
externalReforIdempotency-Keyand anothercallbackUrlreturns the original operation, with the original URL. - The operation's response carries the accepted
callbackUrl. A field with another name, such ascallback_url, is ignored, and it comes backnull.
Events
The body of each event, field by field, is on its own page.
| Event | When it arrives |
|---|---|
PAYMENT_CREATED | The charge was registered and the QR code exists. It is not a payment yet. |
PAYMENT_PAID | The charge was paid. Release the order here. |
PAYMENT_REFUNDED | The bank refunded the payment received on its own. The full amount leaves the account, and the fee does not come back. |
PAYMENT_EXPIRED | The charge expired without payment. |
WITHDRAW_CREATED | The withdrawal or the Pix copy-and-paste payment was requested, and the amount and the fee left the available balance. |
WITHDRAW_COMPLETED | The money reached the destination. |
WITHDRAW_FAILED | The withdrawal failed, and the amount and the fee came back to the balance. |
REFUND_COMPLETED | The refund requested through the API, the dashboard or support was completed: the amount went back to the payer. |
REFUND_FAILED | The refund was refused, and the amount came back to the balance. |
DEPOSIT_RECEIVED | A Pix landed on one of the account's keys without a charge. The amount is already credited. |
INTERNAL_TRANSFER_SENT | The account sent a transfer. |
INTERNAL_TRANSFER_RECEIVED | The account received a transfer. |
WITHDRAW_REFUND_RECEIVED | Whoever received a Pix from the account returned the amount, in full or in part. |
INFRACTION_OPENED | A MED dispute was opened against the account. |
INFRACTION_CLOSED | The dispute was closed or cancelled. The body's status says which. |
INFRACTION_DEADLINE | The dispute's response deadline is approaching: 48, 24 or 6 hours left. |
ACCOUNT_BLOCKED | The bank blocked operations on the account, or the list of blocks changed. blockedOperations says what the API will refuse. |
ACCOUNT_UNBLOCKED | The blocks were lifted. |
A full refund requested through the API takes the charge to REFUNDED, but the webhook is REFUND_COMPLETED, not PAYMENT_REFUNDED.
Request
POST /webhooks/payzu
Content-Type: application/json
X-Payzu-Event: PAYMENT_PAID
X-Payzu-Delivery: cmu1r7x2k000a01s6h4f2b9qd
X-Payzu-Timestamp: 1791210790441
X-Payzu-Signature: sha256=8f3b2c1d...{
"event": "PAYMENT_PAID",
"id": "cmu1r7x2k000a01s6h4f2b9qd",
"sentAt": "2026-10-05T14:33:10.441Z",
"accountId": "cmu0z8k2a000001s6acct0001",
"data": {
"paymentId": "cmu2wbljx0000e8gtlic8q1gi",
"status": "PAID",
"amount": 1500,
"serviceFee": 105,
"netAmount": 1395,
"metadata": { "pedido": "4821", "canal": "checkout-web" },
"externalRef": "pedido-4821",
"endToEndId": "E99999999202610051433a1b2c3d4e5f"
}
}| Header | Value |
|---|---|
X-Payzu-Event | The event, the same as event in the body. |
X-Payzu-Delivery | Delivery identifier, the same as id in the body. The same on every attempt and on resend. |
X-Payzu-Timestamp | Time of this attempt, in milliseconds (Unix). Part of the signature. |
X-Payzu-Signature | sha256= followed by the HMAC-SHA256 in hexadecimal. |
datacarries the event body, with amounts in cents. A field without a value is left out: none arrives asnull.sentAtis when the first attempt was built, and it does not change on a new attempt or on resend.accountIdis the account that produced the event.- To match the webhook to your order, use
externalRefandmetadata, which come in the fourPAYMENT_*events. - To get the operation, use the identifier in
data:paymentId,withdrawId,transferIdordepositId. It is not theidreturned at creation, but the lookup routes accept both. In the statement, it appears inoriginId. refundIdpoints to the refund in therefundslist of the charge or the deposit.
Signature
The signature is the HMAC-SHA256 of <X-Payzu-Timestamp>.<raw body>, with the destination's secret: the registered endpoint's secret or, for webhooks sent to the callbackUrl, the account's callback secret.
Read X-Payzu-Timestamp, X-Payzu-Signature and the body exactly as it arrived. Serializing the JSON again changes spaces and key order, and the signature will not match.
Compute HMAC-SHA256(secret, "<timestamp>.<body>") in hexadecimal and compare it, in constant time, with the value after sha256=.
Refuse a timestamp outside a tolerance window. Each attempt is signed when it is sent, so the timestamp of a new attempt is always recent.
import crypto from 'node:crypto';
const TOLERANCE_MS = 5 * 60 * 1000;
function verifyPayzuSignature(rawBody, headers, secret) {
const timestamp = headers['x-payzu-timestamp'];
const signature = headers['x-payzu-signature'];
if (typeof timestamp !== 'string' || typeof signature !== 'string') return false;
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() - Number(timestamp)) > TOLERANCE_MS) return false;
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const received = /^sha256=([0-9a-f]{64})$/i.exec(signature);
if (!received) return false;
return crypto.timingSafeEqual(Buffer.from(received[1], 'hex'), Buffer.from(expected, 'hex'));
}Response and new attempts
- Respond with any
2xxwithin 10 seconds. After that, the attempt counts as a failure. If processing takes long, respond first and process afterwards. - There are 10 attempts in total, with increasing waits, from minutes to hours; the last two are 6 hours apart. From start to finish, about 18 hours. After the tenth, the delivery is abandoned, and the endpoint stays active.
- An account's webhooks go out in the order they happened. While a webhook waits for a new attempt, the following ones from the same account wait too, including those for other endpoints.
In the dashboard, the deliveries tab shows each attempt and your server's response. A failed or abandoned delivery can be sent again with the Resend button, using the operation PIN: the same body goes out, with the same X-Payzu-Delivery.
Duplicate or out-of-order webhook
- The same webhook can arrive more than once. Discard what was already processed by
X-Payzu-Delivery, which is the same on every attempt and on resend. - To deduplicate by operation, use the event +
dataidentifier pair, with two exceptions:INFRACTION_DEADLINEarrives up to three times per dispute, one perhoursRemaining;ACCOUNT_BLOCKEDandACCOUNT_UNBLOCKEDhave no identifier. - Order is lost when an abandoned delivery is resent later, and there is no order across accounts. Where there is
data.status, it is the state at the time of the event: aPAYMENT_CREATEDthat arrives after thePAYMENT_PAIDdoes not undo the payment. In the block webhooks, the most recentchangedAtwins.