PayZuDocs

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.

PathReceivesSecret that signs
Registered endpointThe events chosen in eventsThe endpoint's secret (whsec_…)
Operation callbackUrlAll events of that operationThe 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 secret from 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: false to PUT /transactions/webhooks/{webhookId}. An endpoint that already had a delivery cannot be deleted: the DELETE responds 409 WEBHOOK_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 with callbackUrl is refused with 412 CALLBACK_SECRET_MISSING.
  • The secret appears only in that response. GET /transactions/callback-secret only says whether it exists, and POST /transactions/callback-secret/rotate generates 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 externalRef or Idempotency-Key and another callbackUrl returns the original operation, with the original URL.
  • The operation's response carries the accepted callbackUrl. A field with another name, such as callback_url, is ignored, and it comes back null.

Events

The body of each event, field by field, is on its own page.

EventWhen it arrives
PAYMENT_CREATEDThe charge was registered and the QR code exists. It is not a payment yet.
PAYMENT_PAIDThe charge was paid. Release the order here.
PAYMENT_REFUNDEDThe bank refunded the payment received on its own. The full amount leaves the account, and the fee does not come back.
PAYMENT_EXPIREDThe charge expired without payment.
WITHDRAW_CREATEDThe withdrawal or the Pix copy-and-paste payment was requested, and the amount and the fee left the available balance.
WITHDRAW_COMPLETEDThe money reached the destination.
WITHDRAW_FAILEDThe withdrawal failed, and the amount and the fee came back to the balance.
REFUND_COMPLETEDThe refund requested through the API, the dashboard or support was completed: the amount went back to the payer.
REFUND_FAILEDThe refund was refused, and the amount came back to the balance.
DEPOSIT_RECEIVEDA Pix landed on one of the account's keys without a charge. The amount is already credited.
INTERNAL_TRANSFER_SENTThe account sent a transfer.
INTERNAL_TRANSFER_RECEIVEDThe account received a transfer.
WITHDRAW_REFUND_RECEIVEDWhoever received a Pix from the account returned the amount, in full or in part.
INFRACTION_OPENEDA MED dispute was opened against the account.
INFRACTION_CLOSEDThe dispute was closed or cancelled. The body's status says which.
INFRACTION_DEADLINEThe dispute's response deadline is approaching: 48, 24 or 6 hours left.
ACCOUNT_BLOCKEDThe bank blocked operations on the account, or the list of blocks changed. blockedOperations says what the API will refuse.
ACCOUNT_UNBLOCKEDThe 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"
  }
}
HeaderValue
X-Payzu-EventThe event, the same as event in the body.
X-Payzu-DeliveryDelivery identifier, the same as id in the body. The same on every attempt and on resend.
X-Payzu-TimestampTime of this attempt, in milliseconds (Unix). Part of the signature.
X-Payzu-Signaturesha256= followed by the HMAC-SHA256 in hexadecimal.
  • data carries the event body, with amounts in cents. A field without a value is left out: none arrives as null.
  • sentAt is when the first attempt was built, and it does not change on a new attempt or on resend. accountId is the account that produced the event.
  • To match the webhook to your order, use externalRef and metadata, which come in the four PAYMENT_* events.
  • To get the operation, use the identifier in data: paymentId, withdrawId, transferId or depositId. It is not the id returned at creation, but the lookup routes accept both. In the statement, it appears in originId.
  • refundId points to the refund in the refunds list 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 2xx within 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 + data identifier pair, with two exceptions: INFRACTION_DEADLINE arrives up to three times per dispute, one per hoursRemaining; ACCOUNT_BLOCKED and ACCOUNT_UNBLOCKED have 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: a PAYMENT_CREATED that arrives after the PAYMENT_PAID does not undo the payment. In the block webhooks, the most recent changedAt wins.

On this page