The safe integration flow
Every gateway differs in API, but the secure shape is the same. Follow this order and a payment can't be spoofed.
┌────────────────────────────────────────────────────┐
│ CUSTOMER │
│ Clicks "Pay" on your site │
└────────────────────────────────────────────────────┘
│
│ create order
▼
┌────────────────────────────────────────────────────┐
│ YOUR BACKEND │
│ Writes a PENDING order (unique reference) │
└────────────────────────────────────────────────────┘
│
│ request checkout
▼
┌────────────────────────────────────────────────────┐
│ GATEWAY │
│ Returns a hosted checkout URL │
└────────────────────────────────────────────────────┘
│
│ redirect
▼
┌────────────────────────────────────────────────────┐
│ CUSTOMER PAYS │
│ FPX / card / e-wallet / DuitNow QR │
└────────────────────────────────────────────────────┘
│
│ bank authorises
▼
┌────────────────────────────────────────────────────┐
│ GATEWAY │
│ Sends a SIGNED webhook to your backend │
└────────────────────────────────────────────────────┘
│
│ webhook
▼
╔════════════════════════════════════════════════════╗
║ YOUR BACKEND (the trust gate) ║
║ 1. verify the signature ║
║ 2. re-fetch the real state from the gateway ║
║ 3. match reference + amount + status ║
║ 4. mark paid ONCE (idempotent) ║
╚════════════════════════════════════════════════════╝
│
│ confirmed
▼
┌────────────────────────────────────────────────────┐
│ UNLOCK ACCESS │
│ Show success to the customer │
└────────────────────────────────────────────────────┘Step by step
- 1
Create a local pending payment
Before sending the customer anywhere, write your own order/payment row with status pending and a unique reference. This is the record you'll later confirm; never rely on the gateway's memory alone.
- 2
Redirect to checkout (or open the hosted page)
Hand off to the gateway's hosted checkout / payment link with your reference and amount. Let the gateway handle the card/FPX/e-wallet UI, so you collect nothing sensitive.
- 3
Verify the webhook signature
When the gateway calls your backend, verify the signature/checksum (usually HMAC with your secret) before reading anything. An unverified callback is untrusted input, so reject it.
- 4
Re-fetch the state from the provider
Don't trust the webhook body's status at face value. Call the gateway's API to read the authoritative payment state for that reference.
- 5
Match provider + reference + amount + currency + status
Confirm the payment maps to your pending record on every field. A mismatch (wrong amount, wrong currency, unknown reference) means do not fulfil.
- 6
Mark paid, idempotently
Flip your record to paid and unlock access. If the same event arrives again (gateways retry), detect it by reference and do nothing the second time. No double-fulfilment, no double-counting.
The two rules that matter most
- Never unlock paid access from a redirect alone. The browser can be replayed or faked. Only a verified webhook + re-fetched provider state should grant access.
- Keep secrets server-side. Secret keys and signature verification live on your backend, never in client code or the repo.
Tests to write
- • Invalid signature → rejected, nothing fulfilled.
- • Duplicate webhook event → processed once only (idempotent).
- • Wrong amount or currency → not marked paid.