Money and precision
Explains that amounts travel as reais with decimals and how to preserve precision in storage.
The PayZu Pix API uses reais with decimal places. Heads up: many PSPs work in cents, so if you're migrating or comparing integrations, the decimal point here is not optional.
{ "amount": 99.90 }Decimal precision in code
In JavaScript, 0.1 + 0.2 !== 0.3. In Python Decimal is safe but float is not. In SQL, FLOAT loses precision.
| Language | Use |
|---|---|
| JavaScript | Integer in cents, or decimal.js library. |
| Python | decimal.Decimal when calculating, float only at the API. |
| Go | shopspring/decimal or integer in cents. |
| Java | BigDecimal, never double. |
| PHP | bcmath, or integer in cents. |
| SQL/Postgres | NUMERIC(15,2), never FLOAT or REAL. |
Recommended pattern: cents internally
Store as an integer in cents in your DB and convert only at the API edge:
function centsToReais(cents: number): number {
return cents / 100;
}
function reaisToCents(reais: number): number {
return Math.round(reais * 100);
}
await createPixCharge({
amount: centsToReais(order.totalCents),
clientReference: `order-${order.id}`,
});
const callbackAmountCents = reaisToCents(callback.amount);
if (callbackAmountCents !== order.totalCents) {
throw new Error('Amount mismatch between callback and order');
}from decimal import Decimal
def cents_to_reais(cents: int) -> Decimal:
return Decimal(cents) / Decimal(100)
def reais_to_cents(reais: float | Decimal) -> int:
return int((Decimal(str(reais)) * Decimal(100)).quantize(Decimal('1')))
create_pix_charge({
'amount': float(cents_to_reais(order.total_cents)),
'clientReference': f'order-{order.id}',
})Minimum limits per operation
PayZu validates on the server. A request below the minimum returns 400 Bad Request.
| Operation | Minimum amount |
|---|---|
POST /pix (charge) | R$ 1.00 |
POST /withdraw (Pix payment by key) | R$ 0.01 |
POST /withdraw/qrcode (pay QR) | R$ 0.10 |
POST /internal-transfer | R$ 0.01 |
These are the API schema minimums. Your account effective minimum and maximum are in GET /user (cashInTicketMin, cashInTicketMax, cashOutTicketMin, cashOutTicketMax) and the call is validated against those — read them before building the amount. The refusal states the amount applied and the errorCode varies per route: PZD600 on POST /pix, PZS600 on POST /withdraw and POST /withdraw/qrcode, PZC602 on POST /internal-transfer.
Fee
The fee charged by PayZu arrives in the callback in the serviceFeeCharged field (in reais).
{
"amount": 99.90,
"serviceFeeCharged": 0.99,
"status": "COMPLETED"
}For financial reconciliation, consider:
| Value | Meaning |
|---|---|
amount | What the customer paid or you withdrew. |
serviceFeeCharged | PayZu fee on the operation. |
| Net | amount - serviceFeeCharged (incoming) or amount + serviceFeeCharged (total outgoing). |
Validate received amount in the callback
Always check that the callback matches the order. The customer may pay a different amount (Pix allows QR without a fixed amount in some cases).
async function handleDepositCallback(tx: PayzuCallback, order: Order) {
const callbackCents = reaisToCents(tx.amount);
if (callbackCents !== order.totalCents) {
log.warn('Amount mismatch', {
order: order.totalCents,
received: callbackCents,
});
await flagForReview(order, tx);
return;
}
await markOrderPaid(order, tx);
}Common pitfalls
| Pitfall | Symptom |
|---|---|
Sending amount: 9990 thinking it's cents | Charges R$ 9,990.00 from the customer |
Storing amount as FLOAT in Postgres | Loss of cents when summing many rows |
Adding Decimal with float in Python | Type error or lost precision |
Trusting parseFloat(tx.amount) without rounding | 99.90 becomes 99.9000000000001 |
| Not checking received amount vs expected amount | Partial payment goes through as completed |