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

Shell
npm install @bayarkit/core @bayarkit/toyyibpay

bayarkit 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.

TypeScript
// 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
})
TypeScript
// 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.

TypeScript
// 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.session event 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.