PayZuPayZu Docs
Best practices

Error handling

PayZu returns standard HTTP codes. Your strategy depends on the category.

Handling table

CodeWhat it meansWhat to do
200Operation succeeded.Process the response.
201Resource created (charge, withdrawal).Process the response.
400Invalid payload.Do not retry. Log message + requestId and fix the code.
401Missing, invalid or revoked token.Do not retry. Check the token (whitespace, encoding, rotation).
403Valid token but no permission for the endpoint.Do not retry. Contact support to validate account enablement.
404Resource not found.Do not retry. Confirm id or clientReference.
409Conflict (duplicate, resource in invalid state).Do not retry. Query the current state via GET before trying again.
422Semantic validation failed.Do not retry. Fix the payload according to message.
429Rate limit reached.Wait, apply exponential backoff and try again.
5xxPayZu server error.Retry with exponential backoff (1s → 2s → 4s → 8s, max 4 attempts).
TimeoutNo response within the deadline.The operation may have been applied. Query by clientReference before recreating.

Retry helper

Retry only on 429 and 5xx. Never on 4xx.

ATTEMPTS=4
DELAY=1

for i in $(seq 1 $ATTEMPTS); do
  STATUS=$(curl -s -o /tmp/resp.json -w "%{http_code}" \
    -X POST https://api.payzu.processamento.com/v1/pix \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"amount":99.90,"clientReference":"order-1234"}')

  case $STATUS in
    2*) cat /tmp/resp.json; exit 0 ;;
    429|5*) sleep $DELAY; DELAY=$((DELAY*2)) ;;
    *) echo "Error $STATUS"; cat /tmp/resp.json; exit 1 ;;
  esac
done
echo "Max retries exceeded"
exit 1
async function withRetry<T>(
  fn: () => Promise<Response>,
  attempts = 4,
): Promise<T> {
  let lastErr: unknown;
  for (let i = 0; i < attempts; i++) {
    try {
      const res = await fn();
      if (res.ok) return res.json();

      if (res.status >= 400 && res.status < 500 && res.status !== 429) {
        const body = await res.text();
        throw new Error(`Client error ${res.status}: ${body}`);
      }
    } catch (err) {
      lastErr = err;
    }

    const delay = Math.min(8000, 1000 * 2 ** i) + Math.random() * 250;
    await new Promise((r) => setTimeout(r, delay));
  }
  throw lastErr ?? new Error('Max retries exceeded');
}
import time, random, requests

def with_retry(fn, attempts=4):
    last_err = None
    for i in range(attempts):
        try:
            res = fn()
            if res.ok:
                return res.json()
            if 400 <= res.status_code < 500 and res.status_code != 429:
                raise RuntimeError(f'Client error {res.status_code}: {res.text}')
        except Exception as e:
            last_err = e

        delay = min(8.0, 1.0 * (2 ** i)) + random.random() * 0.25
        time.sleep(delay)

    raise last_err or RuntimeError('Max retries exceeded')
func WithRetry(fn func() (*http.Response, error), attempts int) ([]byte, error) {
    var lastErr error
    for i := 0; i < attempts; i++ {
        res, err := fn()
        if err == nil {
            defer res.Body.Close()
            if res.StatusCode >= 200 && res.StatusCode < 300 {
                return io.ReadAll(res.Body)
            }
            if res.StatusCode >= 400 && res.StatusCode < 500 && res.StatusCode != 429 {
                body, _ := io.ReadAll(res.Body)
                return nil, fmt.Errorf("client error %d: %s", res.StatusCode, body)
            }
        }
        lastErr = err

        delay := time.Duration(math.Min(8000, 1000*math.Pow(2, float64(i)))) * time.Millisecond
        time.Sleep(delay + time.Duration(rand.Intn(250))*time.Millisecond)
    }
    return nil, lastErr
}

Timeout: the "it might have worked" trap

A timeout is not equivalent to a failure. PayZu may have received, processed and persisted the transaction, and only the response failed to come back. Your app does not know.

Solution: use a unique clientReference and query before retrying.

async function createOrRetry(orderId: string, amount: number) {
  const ref = `order-${orderId}`;
  try {
    return await withRetry(() => postPix({ amount, clientReference: ref }));
  } catch (err) {
    // it may have succeeded despite the error/timeout
    const existing = await fetch(
      `https://api.payzu.processamento.com/v1/pix?clientReference=${ref}`,
      { headers },
    ).then((r) => (r.ok ? r.json() : null));
    if (existing) return existing;
    throw err;
  }
}

Error observability

Always log, at a minimum:

FieldWhy
requestIdComes in PayZu error responses. Support traces it directly.
Local idYour identifier (order, withdrawal).
PayZu idIf one already exists.
endToEndIdUseful for tracing at Bacen in a dispute.
clientReferenceThe universal correlation key.
HTTP status + messageThe root cause is almost always in message.
Attempt N of MTells a first attempt apart from a retry.
log.error('PayZu /pix failed', {
  requestId: body.requestId,
  status: res.status,
  message: body.message,
  clientReference: ref,
  attempt: i + 1,
  attempts,
});

Useful error messages for the end user

Do not expose message raw. Translate it into something actionable:

PayZu errorMessage for the user
401 Unauthorized"Configuration error. Contact support with the requestId code."
400 amount must be >= 1"Minimum charge amount is R$ 1.00."
400 invalid pixKey"Invalid Pix key. Check it and try again."
429 Too Many Requests"We have too many requests. Try again shortly."
5xx"System temporarily unavailable. We are already looking into it."

Common pitfalls

PitfallSymptom
Retrying on 400Spamming the API, same error N times
Retrying on 401 without rotating the tokenToken leaks even more into the log
No backoff (immediate retry in a loop)Becomes rate limited, then gets banned
No jitter in the backoffN clients hit at the same time, "thundering herd"
Treating a timeout as a definitive failureCustomer is charged twice
Not logging requestIdSupport cannot investigate

Open a support ticket with the requestId

Got the requestId saved? Send it straight to the team.

On this page