Skip to content
Engineering

How we make PayFast payments idempotent

A payment notification that's rejected, delivered twice or arrives late shouldn't cost you money or customers. Here's the layered approach we use for PayFast integrations.

STM Analytics · 4 October 2026 · 3 min read

Payments are where "it works on my machine" goes to die. Networks drop, users double-click, browsers retry, and payment gateways deliver the same notification more than once. Framework upgrades can break payments silently, too: a change in how CSRF protection is configured can start rejecting every gateway notification, leaving successful payments stuck at pending.

Here's the approach we now use on every PayFast integration.

1. Accept the notification, then prove it's real

PayFast's Instant Transaction Notification (ITN) is a server-to-server POST, so it can't carry a CSRF token. It must be excluded from CSRF checks — in Laravel 11 that's validateCsrfTokens(except: [...]) in bootstrap/app.php, not the old HTTP kernel.

Excluding CSRF is safe only because we verify everything else, in PayFast's recommended order:

  1. Signature — rebuilt from the raw request body in field order, so framework middleware that trims strings can't break it.
  2. Merchant ID — the notification must be for our account.
  3. Server confirmation — we post the data back to PayFast's validate endpoint and only continue on VALID.
  4. Amount — what was paid must equal what we priced on the server. Never trust a price from the browser.

2. Make every state change a state machine

A transaction moves pending → complete | failed | cancelled | expired, and complete is terminal. A duplicate COMPLETE is a no-op; a late CANCELLED can't undo a payment. A COMPLETE arriving after we've expired an abandoned checkout still completes it — money received always wins.

All changes run inside a database transaction with a row lock, and each one is written to an audit timeline.

3. Idempotency keys for checkout

The browser generates an Idempotency-Key per checkout attempt and reuses it on retries. The server scopes the key to the user, serialises checkouts per user with a lock, and backs it all with a unique database index. A retried request gets the same payment session back instead of creating a second one.

We also write the transaction row before calling PayFast, so a notification can never arrive for a payment we have no record of.

4. Fulfil exactly once

Unlocking what the customer bought goes through a unique (user, product) constraint, so repeating the grant is harmless. If someone genuinely pays twice — two open tabs — the second payment is flagged for a refund instead of being silently swallowed.

Receipts are sent by a queued job that claims a receipt_sent_at column atomically, so retries never email twice.

5. Retry the right things

  • Outbound calls to PayFast retry connection errors and 5xx responses with exponential backoff.
  • If PayFast's validation endpoint is down, we answer the ITN with HTTP 503 so PayFast re-delivers it later.
  • Bad signatures get a 400 — retrying won't fix them.

6. Reconcile what slipped through

Even with all of this, you want a safety net. A reconciliation command pulls PayFast's transaction history, matches it to unconfirmed payments by reference, and completes them through the same state machine — dry run first, apply second.

Integrating payments and want them to be boring? We can help.

Have a system in mind? Let's build it.

Tell us what you need and we will come back to you with honest next steps — no hard sell.