Payment webhooks in practice: signature, idempotency and retries

How to receive payment notifications securely with the Pagnovo API — HMAC verification, idempotency, fast responses, redelivery and delivery monitoring.

Pagnovo Team · 2026-08-05

The webhook is where your system finds out the money arrived. If it fails, the customer pays and the order never ships. If it is insecure, someone marks orders as paid without paying. Worth the 30 minutes this guide takes.

Full reference: portal.pagnovo.com/docs.

Why the API response is not enough

When you create a PIX charge you get status: "PENDING". Payment happens later, when the customer opens their banking app. There is no synchronous response that says "paid" — confirmation arrives asynchronously, through the webhook.

Designing your system assuming otherwise is beginner mistake number one.

Registering the webhook

With Pagnovo you register the endpoint once and pick which events you want:

POST /v2/webhooks

{
  "url": "https://your-app.com/webhooks/pagnovo",
  "description": "Production",
  "events": ["cashin.paid", "cashin.refunded", "cashout.success", "cashout.failed"]
}

The response returns a plain-text secret — exactly once:

{ "id": "...", "secret": "..." }

Store it in a secrets manager (not in code, not in Git). If you lose it or suspect a leak: POST /v2/webhooks/:id/rotate-secret.

Available events

Category Events
Incoming (cash-in) cashin.paid, cashin.refunded
Outgoing (cash-out) cashout.success, cashout.failed, cashout.returned
Disputes infraction.updated (replaces the old CHARGEBACK/BLOCKED)
Subscriptions subscription.created, .activated, .paused, .canceled, .past_due, .expired

Subscribe only to what you actually process. Every extra event is noise on your endpoint.

The V2 envelope

Every V2 webhook arrives in the same shape:

{
  "event": "cashin.paid",
  "environment": "TEST",
  "payload": { "id": "...", "status": "APPROVED", "amount": 18990 }
}

Note the environment field: TEST or LIVE. Use it as a safety latch — if your production environment receives a TEST event, something is misconfigured and you should not release the order.

Coexistence with V1: the older model (per-transaction postbackUrl, flat camelCase payload) still works. If you have both configured, they fire in parallel — be careful not to process the same payment twice.

Verifying the signature (mandatory)

A webhook endpoint is a public URL. Without verification, anyone can POST claiming an order was paid.

Pagnovo signs with HMAC-SHA256 over the payload with recursively sorted keys:

import crypto from 'crypto';

function sortObjectKeys(obj: any): any {
  if (Array.isArray(obj)) return obj.map(sortObjectKeys);
  if (obj !== null && typeof obj === 'object') {
    return Object.keys(obj).sort().reduce((acc, key) => {
      acc[key] = sortObjectKeys(obj[key]);
      return acc;
    }, {} as any);
  }
  return obj;
}

export function isValid(payload: unknown, received: string, secret: string) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(sortObjectKeys(payload)))
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(received);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Three details that cause 90% of "invalid signature" reports:

  1. Different re-serialization. If your framework already parsed the JSON and you re-serialize with a different key order or spacing, the hash changes. That is exactly why sortObjectKeys exists — it makes ordering irrelevant.
  2. Comparing with ===. Besides leaking timing (enabling a timing attack), it breaks when lengths differ. Use timingSafeEqual with a length check first.
  3. Wrong secret. Mixing up the test and production secrets is more common than you would think.

Idempotency: the same event can arrive twice

Redelivery is normal behaviour, not a bug. If your application was slow to respond, crashed mid-processing or returned an error, the event is sent again.

The safe pattern:

// 1. Verify the signature
if (!isValid(body.payload, signature, secret)) return res.status(401).end();

// 2. Lock on the transaction ID (unique key in the database)
const inserted = await db.processedEvents.insertIfAbsent(body.payload.id);
if (!inserted) return res.status(200).end();   // already processed — ignore

// 3. Respond BEFORE processing
res.status(200).end();

// 4. Process in a queue
await queue.push({ event: body.event, payload: body.payload });

Order matters: respond 200 fast, process afterwards. If you release the order, send email and issue an invoice before responding, any slowdown becomes a timeout — and the event is redelivered, duplicating the work.

Monitoring deliveries

The API exposes two endpoints that are gold in production:

GET /v2/webhooks/:id/deliveries   → delivery history
GET /v2/webhooks/:id/metrics      → aggregated metrics

Use them to investigate "the customer paid and got nothing". Before blaming the integration, check whether the delivery went out, what HTTP status your server returned and how many attempts there were.

There is also POST /v2/webhooks/:id/test to fire a test event without a real transaction — great for validating a fresh deploy.

Safety net: never rely on the webhook alone

Webhooks are fast, but your server may be down at the exact moment. Keep a reconciliation job that sweeps pending orders:

GET /transactions/:id

This endpoint accepts Pagnovo's ID, your externalId or the PIX end2End. Run it every few minutes for orders that are created and still pending.

Also keep the x-trace-id header from every response in your logs. That is what support uses to locate the exact request on the servers.

Testing before production

In the sandbox (sk_test_*), outcomes are deterministic by amount:

Amount Outcome
R$ 10.00 Approved → cashin.paid
R$ 10.01 Rejected
R$ 10.05 Refund → cashin.refunded
R$ 10.04 Chargeback → infraction.updated

That lets you exercise every path on purpose, including the bad ones. Also test: duplicate webhook, out-of-order webhook (a refunded arriving before the paid) and an invalid signature.

Checklist


See the webhook documentation or explore our PIX API. Questions about your integration? Talk to our team.