FPX is Malaysia's online banking payment network. An online shop usually reaches it through a payment gateway: the gateway's payment page shows the list of banks, and your code talks only to the gateway.
The examples below use bayarkit, an open-source library we maintain that puts fourteen Malaysian gateways behind one interface. The steps are the same if you call a gateway's API yourself.
How an FPX payment moves
- Your server asks the gateway to create a payment for an amount and a reference.
- The customer goes to the gateway's page, picks a bank and approves the payment in online banking.
- The gateway sends the customer back to your return URL and, separately, posts a callback to your server.
- Your server checks the callback, asks the gateway for the payment's status, and only then marks the order paid.
1. Install
npm install @bayarkit/core @bayarkit/toyyibpaybayarkit needs Node.js 22.12 or later. The adapters use node:crypto, so Cloudflare Workers and Vercel Edge are not supported; a normal Node.js server, or a Next.js route handler on the Node.js runtime, works.
2. Create the payment
Amounts are integer sen: 1050 is RM10.50, so floating-point ringgit never reaches a comparison. Give every payment attempt its own reference, so a late callback from an earlier attempt cannot match a new one.
// lib/payment.ts
import { toyyibpay } from '@bayarkit/toyyibpay'
export const gateway = toyyibpay({
secretKey: process.env.TOYYIBPAY_SECRET_KEY ?? '',
categoryCode: process.env.TOYYIBPAY_CATEGORY_CODE ?? '',
environment: 'sandbox', // 'production' when you go live
})// app/api/checkout/route.ts
import { checkoutResponse } from '@bayarkit/core'
import { gateway } from '@/lib/payment'
import { orders } from '@/lib/orders' // your database
export async function POST(request: Request) {
const { orderId } = (await request.json()) as { orderId: string }
const order = await orders.get(orderId)
const reference = `ORD${order.id}T${Date.now()}` // one reference per attempt
const payment = await gateway.createPayment({
reference,
amount: order.amount, // sen: 1050 = RM10.50
description: `Order ${order.id}`,
customer: { name: order.name, email: order.email },
returnUrl: 'https://shop.example/thank-you',
callbackUrl: 'https://shop.example/api/payment/callback',
})
await orders.update(order.id, { reference, paymentId: payment.id })
return checkoutResponse(payment, 'Continue to payment') // 303 redirect to the gateway's bank list
}3. Confirm the payment in the callback
A callback is a claim, not proof. It can be forged, replayed from an earlier payment, or real but for another order or amount. confirmPayment makes three checks every time: the callback signature, a fresh status query to the gateway, and an exact match on reference, amount and currency.
// app/api/payment/callback/route.ts
import { confirmPayment, readCallback } from '@bayarkit/core'
import { gateway } from '@/lib/payment'
import { orders } from '@/lib/orders'
export async function POST(request: Request) {
const callback = await readCallback(request) // toyyibPay posts multipart/form-data
const verified = gateway.verifyCallback(callback) // null when the signature is wrong
const order = verified ? await orders.findByReference(verified.reference) : undefined
if (!order) return new Response('unknown order', { status: 404 })
if (order.status === 'paid') return new Response('OK') // gateways retry callbacks: answer the same way
const result = await confirmPayment(gateway, callback, { reference: order.reference, amount: order.amount })
if (result.ok) await orders.update(order.id, { status: 'paid' })
// When !result.ok, log result.reason: invalid_signature, not_paid, mismatch or gateway_error.
return new Response('OK')
}Never mark an order paid from the return URL. The customer's browser carries it, so anyone can type it.
What the sandboxes showed us
We ran paid and failed payments through four gateway sandboxes in October 2026. Each finding below breaks code written from the documentation alone.
- toyyibPay posts its callback as
multipart/form-data. Code that only parses URL-encoded bodies sees an empty callback. - toyyibPay answers
No data found!for a bill with no transactions, and gives the same answer to a wrong secret key. A wrong key looks like payments that never confirm, not like an error. - Billplz sends a callback for a failed payment too, with
paid=false, and its callback does not carry your reference. Find the order by the bill id saved at checkout. - Stripe sends no
checkout.sessionevent when an FPX payment fails: the session stays open and unpaid until it expires. - Bayarcash uses one secret key for every portal in an account, so a correctly signed callback can belong to another portal. The adapter compares the portal name with the configured one.
Testing without real money
Each of these gateways has a sandbox or a test mode. For automated tests, @bayarkit/testing runs a fake of each gateway on localhost, so the two route handlers above can run in CI without network access or keys.
Switching gateways later
The two routes stay the same; only the adapter and its keys change, for example bayarcash({ token, secretKey, portalKey, environment: 'sandbox' }) instead of toyyibpay(...). How the gateways differ for a developer is in Malaysian payment gateways compared.