Skip to main content
CentraPoint

Payment webhooks: 10 best practices for reliable integrations

Payment webhooks best practices for developers: verify signatures, acknowledge fast, process idempotently, and handle retries and out-of-order events safely.

Published
Reading time
7 min read
By
CentraPoint Team
On this page
  1. Why payment webhooks are harder than they look
  2. 1. Verify the signature before anything else
  3. 2. Re-verify the payment with the provider's API
  4. 3. Acknowledge fast, process later
  5. 4. Make processing idempotent
  6. 5. Don't trust event order
  7. 6. Return the right status codes
  8. 7. Handle retries and dead letters
  9. 8. Log every delivery
  10. 9. Reconcile against settlements
  11. 10. Separate environments and secrets
  12. A quick checklist
  13. How CentraPoint helps
  14. Frequently asked questions

Payment webhooks are HTTP requests that a payment gateway or billing platform sends to your server when something happens, such as a payment succeeding, a debit order returning unpaid or a subscription renewing. To handle them reliably you need to verify every request's signature, respond quickly, process each event exactly once even if it arrives several times, and never rely on the webhook alone as proof that money moved.

These ten practices apply whether you're receiving webhooks from PayFast, Paystack, Ozow, Netcash or a billing layer that sits above them.

Why payment webhooks are harder than they look

Webhooks look like a simple POST request, but payment events have properties that break naive handlers:

  • They're delivered at least once, not exactly once. Retries mean duplicates.
  • They can arrive out of order. A "refunded" event can land before "paid" if the first delivery attempt failed.
  • They can be forged. Your endpoint is public, so anyone can send a request that looks like a payment notification.
  • They carry money. A bug doesn't just show a wrong page; it ships goods that were never paid for, or suspends a customer who did pay.

1. Verify the signature before anything else

Most providers sign each webhook, commonly with an HMAC over the raw request body using a secret shared only with you. Compute the signature yourself and compare it in constant time. Reject anything that doesn't match with a 4xx response and don't process it.

Our dedicated guide to webhook signature verification covers the details, including the classic mistake of verifying a re-serialised JSON body instead of the raw bytes.

2. Re-verify the payment with the provider's API

A valid signature proves the message came from the provider. It doesn't protect you from your own bugs, a leaked secret or a misconfigured sandbox. For anything that releases value (shipping an order, activating an account, marking an invoice paid), look up the transaction through the provider's API server-side and confirm the status, amount and currency match what you expect.

3. Acknowledge fast, process later

Providers usually treat a slow response as a failure and retry. Keep the handler tiny: verify, store the event, respond with 2xx, then do the real work in a background job.

// Express example: store the event, acknowledge, process asynchronously.
// SIGNATURE_HEADER and verifySignature() come from your provider's docs.
app.post("/webhooks/payments", express.raw({ type: "application/json" }), async (req, res) => {
  const signature = req.get(SIGNATURE_HEADER);
  if (!verifySignature(req.body, signature, process.env.WEBHOOK_SECRET)) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body.toString("utf8"));
  await db.webhookEvents.insertIfAbsent({ id: event.id, payload: event, status: "received" });
  await queue.add("process-payment-event", { id: event.id });
  res.status(200).end();
});

4. Make processing idempotent

Because the same event can be delivered more than once, store the provider's event ID (or transaction reference) with a unique constraint and skip events you've already processed. Better still, make the business action itself idempotent: "mark invoice INV-1042 as paid by transaction T-889" is safe to run twice; "add R499.00 to the customer's balance" is not. The same idea applies to outgoing requests; see idempotency keys.

5. Don't trust event order

Design state transitions so that an older event can't overwrite a newer state. Practical approaches:

  • Store the provider's event timestamp and ignore events older than the last one applied to that object.
  • Fetch the current state from the provider's API rather than applying the event payload blindly.
  • Use a state machine that only allows valid transitions (for example, "refunded" can't go back to "pending").

6. Return the right status codes

Situation Response Effect
Signature invalid 401 or 400 Provider may retry; you log and alert
Event stored successfully 200 Provider stops retrying
Duplicate event already processed 200 Provider stops retrying
Your database is down 5xx Provider retries later

Returning 200 for a duplicate is important. Returning an error makes the provider keep retrying something you've already handled.

7. Handle retries and dead letters

Your background job will occasionally fail too: a downstream API times out, or a customer record is missing. Retry with exponential backoff, cap the number of attempts, and move persistent failures to a dead-letter queue that a human reviews. Never silently drop a payment event.

8. Log every delivery

Keep a webhook log with the received time, event type, object reference, signature result, processing status and any error. When a customer says "I paid but my account is still suspended", this log is the first place support looks. Redact card data and secrets before logging.

9. Reconcile against settlements

Webhooks tell you about individual events. Settlement reports and bank statements tell you what actually arrived. Run a daily reconciliation that compares the two, so a missed webhook becomes an exception within a day rather than an unexplained difference at month-end.

10. Separate environments and secrets

Use different endpoints and signing secrets for sandbox and production, rotate secrets when staff with access leave, and make sure a sandbox event can never mark a production invoice paid.

A quick checklist

  • Raw body captured and signature verified in constant time
  • Payment re-verified via API before releasing value
  • 2xx returned within a few seconds; work done asynchronously
  • Unique constraint on event ID
  • Out-of-order events can't regress state
  • Retries with backoff and a dead-letter queue
  • Webhook log with redaction
  • Daily reconciliation against settlements

How CentraPoint helps

CentraPoint receives inbound webhooks from the gateways you enable (PayFast, Paystack, Ozow, Yoco, Peach Payments, Netcash and others), checks each signature and then re-verifies the payment server-side before updating invoices or subscriptions. It also sends outbound webhooks to your own systems and keeps webhook and audit logs. The webhooks documentation explains event types and verification, and the developers page gives an overview of the REST API.

Frequently asked questions

What is a payment webhook?

It's an HTTP request a payment provider sends to your server when an event happens, such as a successful payment, refund or failed debit order. It lets your system react without polling the provider.

Can I rely on a webhook alone to mark an order as paid?

It's safer not to. Verify the signature and then confirm the transaction status and amount with the provider's API before releasing goods or access.

Why am I receiving the same webhook more than once?

Providers retry when they don't receive a timely 2xx response, and some deliver duplicates by design. Store event IDs and make processing idempotent so duplicates are harmless.

How quickly should my webhook endpoint respond?

As quickly as possible, ideally within a few seconds. Store the event and acknowledge it, then do slower work such as emails and accounting sync in a background job.

  • #webhooks
  • #api
  • #developers
  • #integrations