Adding online payments looks simple in a demo: call the API, show the payment page, done. The problems show up later in production. A customer pays but the order stays unpaid, an order is marked paid twice, a notification arrives before the order is saved, or finance cannot match the gateway report with the orders in the system. Most of these issues come from treating payment as a single request instead of a process with several steps that can arrive late, twice, or out of order.
Choose the Payment Methods Your Customers Use
| Method | Fits | Notes |
|---|---|---|
| Virtual account | B2B invoices and larger amounts paid by bank transfer | Each order gets its own account number, so payments are matched automatically |
| QRIS | Retail and small amounts, paid from any bank or e-wallet app | One standard QR code works across many apps |
| E-wallet such as GoPay, OVO, DANA, or ShopeePay | Consumer apps and mobile checkout | Usually redirects to or opens the e-wallet app to confirm |
| Credit and debit card | International customers and subscriptions | Card data should stay with the gateway, with 3D Secure for verification |
| Retail outlets | Customers who prefer to pay cash at a convenience store | Payment can happen hours later, so expiry times matter |
Fees differ per method and per gateway, and they change, so compare the current price lists for the methods you plan to offer. Offering every method is not always better. A short list of the methods your customers actually use keeps checkout simple and reconciliation easier.
Hosted Checkout or Direct API
Gateways offer a hosted payment page or popup, such as Midtrans Snap or Xendit Invoice, and a direct API where you build the payment screens yourself. The hosted option is faster to build, handles each method's flow for you, and keeps card data off your servers, which greatly reduces PCI DSS scope. The direct API gives full control over the look and flow, but you take on more screens, more edge cases, and more testing. For most business applications, start with the hosted checkout and move specific methods to the direct API only when there is a clear reason.
The Webhook Is the Source of Truth
After paying, the customer is redirected back to your site. That redirect is only for the user experience. The customer can close the browser before it happens, and the URL can be opened by anyone, so it must never mark an order as paid. The payment status comes from the gateway's server-to-server notification, the webhook, which your backend receives and verifies.
- Verify every notification. Midtrans includes a signature key built from the order ID, status code, gross amount, and your server key, which you recompute and compare. Xendit sends a callback verification token in a request header that you compare with the token in your dashboard.
- For extra certainty, call the gateway's status API with the transaction ID before updating the order.
- Check that the amount and currency in the notification match the order in your database.
- Respond quickly with a success status, and move slow work such as sending emails or creating shipments to a background job.
- Log every notification you receive, including the raw body, so disputes and bugs can be investigated later.
Handle Duplicates and Out-of-Order Notifications
Gateways retry notifications when your server does not answer in time, and the same payment can produce several notifications as its status changes. Your handler must be idempotent: processing the same notification twice must not ship the order twice or add credit twice. Store the gateway transaction ID with a unique constraint, update the order inside a database transaction, and only allow valid status changes. A paid order should not move back to pending because an older notification arrived late.
Design the Order Status Carefully
| Order status | Meaning | Allowed next status |
|---|---|---|
| Awaiting payment | Payment created and shown to the customer | Paid, expired, or cancelled |
| Paid | Verified settlement from the gateway | Processing, refunded, or partially refunded |
| Expired | The payment window closed without payment | A new payment attempt can be created |
| Cancelled | Cancelled by the customer or the system before payment | None |
| Refunded | Money returned to the customer | None |
Keep the gateway's own transaction statuses in a separate table and map them to your order status in one place. Set an expiry time that suits each method, and release reserved stock when a payment expires. For card payments, also handle fraud review statuses, where the gateway holds a payment for checking before it is final.
Test Thoroughly in Sandbox
- 1Use the sandbox keys and simulate each payment method you offer, including success, failure, and expiry.
- 2Send the same notification twice and confirm the order is processed only once.
- 3Send a notification with a wrong signature or token and confirm it is rejected.
- 4Make the webhook endpoint fail temporarily and confirm the order is updated correctly when the gateway retries.
- 5Test a refund and a partial refund, and check what customers and finance see.
- 6Keep sandbox and production keys in separate environment variables, and never commit either to Git.
Reconcile Every Day
Even with a correct integration, mismatches happen: a webhook that failed for hours, a manual refund in the dashboard, or a settlement that arrives later than expected. A daily job that compares the gateway's transaction report with your orders catches these before customers complain. Report paid transactions without a paid order, paid orders without a settled transaction, and amount differences. Finance teams also need settlement reports that show gross amount, fees, and the net amount transferred to the bank.
Add an admin screen that shows the full notification history for one order. When a customer says they paid but the order is still pending, support can answer in a minute instead of asking a developer to search the logs.
Key takeaways
- Offer the payment methods your customers actually use, and compare current fees per method.
- Start with the gateway's hosted checkout to keep card data off your servers and reduce integration work.
- Treat the verified webhook as the only source of payment status, never the browser redirect.
- Make notification handling idempotent, check amounts, and allow only valid order status changes.
- Test duplicates, bad signatures, retries, and refunds in sandbox, and reconcile transactions every day.


