A 13-part series on the UmimaClean stack. 1 — AdonisJS · 2 — VineJS · 3 — Lucid · 4 — Inertia · 5 — Transformers · 6 — Transmit · 7 — Leaflet · 8 — Midtrans · 9 — Fonnte · 10 — ExcelJS · 11 — Frontend · 12 — Security · 13 — Testing
Payment integration is where a codebase stops being a codebase and starts being an accounting system. Everything upstream can be retried, refactored, or fixed tomorrow. A payment that is recorded wrong is money.
This part is the whole flow: charge, webhook, status mapping, and the escape hatch.
QRIS
QRIS is Indonesia’s unified QR payment standard, mandated by Bank Indonesia. One QR code, scannable by every Indonesian wallet and banking app — GoPay, OVO, DANA, ShopeePay, and every bank’s own app.
That is the reason it is the only online method here. Supporting “QRIS” is supporting all of them; supporting them individually is one integration each.
Midtrans is the gateway. It generates the QR, receives the money, and tells you about it.
Core API, Not Snap
Midtrans offers two integration levels, and the choice is not obvious.
Snap is hosted checkout. You create a token, redirect the customer to Midtrans’s page, they pay, they come back. Less work, less to get wrong, and the payment screen is not yours.
Core API is server-to-server. You charge, you get a QR URL back, you render it in your own page.
I took Core API for reasons that are all about the page:
- The QR sits next to the itemised order summary the customer is paying for
- The page is styled like the rest of the app, which matters on a phone where a redirect to an unfamiliar domain reads as suspicious
- The customer stays on a page my SSE channel is updating, so it reacts the moment payment clears instead of relying on the return redirect
- No redirect means no lost customers on flaky mobile connections
The cost is that the failure modes are mine. Snap handles retry, expiry, and error display; with Core API I handle all three.
Configuration
import env from '#start/env'
import { createHash } from 'node:crypto'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const midtransClient = require('midtrans-client')
const isProduction = env.get('NODE_ENV') === 'production'
export const core = new midtransClient.CoreApi({
isProduction,
serverKey: env.get('MIDTRANS_SERVER_KEY').release()
})Two things worth noting.
createRequire. midtrans-client is CommonJS; this project is ESM. createRequire(import.meta.url) builds a require that works inside an ES module. It is the standard escape hatch for a CJS dependency with no ESM build, and it is worth knowing before you spend an hour on import errors.
isProduction derived from NODE_ENV. Sandbox and production are the same code path with a different flag, so there is no chance of a stray environment variable leaving a production deploy pointed at sandbox — which would take real payments to an account that does not exist.
.release() unwraps the Env.schema.secret() value from Part 1. The friction is deliberate: a secret you have to explicitly unwrap does not end up in a log line by accident.
Charging
const pending = await Transaction.query()
.where('orderId', order.id)
.where('status', TransactionStatus.PENDING)
.first()
if (pending) {
return pending
}Reuse before create. A customer who closes the payment page and comes back should see the same QR, not a second one. Two live QRs for one order is a support conversation about which one is real.
The database backs this up rather than trusting the check — the partial unique index from Part 3:
CREATE UNIQUE INDEX transactions_order_id_pending_unique
ON transactions (order_id) WHERE status = 'pending'Then the rate limit, and where it is applied is the point:
/**
* Only a real charge is metered — reusing a pending transaction above
* never reaches Midtrans, so it must not count against the order's budget.
*/
try {
await midtransChargeLimiter.consume(`midtrans_charge:${order.id}`)
} catch {
throw new vineErrors.E_VALIDATION_ERROR([
{ field: 'status', message: 'Terlalu banyak percobaan pembayaran. Silakan coba lagi nanti.' }
])
}Not on the route — after the reuse check, keyed on order id. On the route it would count every visit to the payment page, and a customer who reloads five times would be locked out of an order they have not been charged for once.
Then the charge:
const midtransOrderId = `${order.orderNumber}-${Date.now()}`
const response = await core.charge({
payment_type: 'qris',
transaction_details: {
order_id: midtransOrderId,
gross_amount: order.totalPrice
},
qris: { acquirer: 'gopay' }
})
const qrCode = (response.actions as { name: string; url: string }[] | undefined)?.find(
(action) => action.name === 'generate-qr-code'
)?.urlThe Order ID Trap
midtransOrderId is not order.orderNumber, and this is the single most important detail in the integration.
Midtrans requires order_id to be globally unique forever, across your entire merchant account. Not per day, not per customer — for the lifetime of the account.
Use your own order number and the first retry fails. QR expires, customer tries again, Midtrans rejects the charge because it has seen ORD260728-001 before. The failure arrives when a customer is trying to give you money, which is the worst possible moment to discover it.
${order.orderNumber}-${Date.now()} makes each attempt unique while keeping the order number readable in the Midtrans dashboard — so reconciling a payment against an order is still eyeballing a prefix rather than a lookup.
The general rule: never send a gateway an identifier whose uniqueness you do not control across retries.
Digging the QR Out
The QR URL is not a top-level field. It arrives inside an actions array, identified by name:
?.find((action) => action.name === 'generate-qr-code')?.urlOptional chaining throughout, and qrCode: qrCode ?? null when persisting. Reaching blindly into a third-party response shape is how you get a TypeError in the payment path the day the provider adds an action.
The Webhook
Midtrans confirms payment by calling your endpoint. Public, because Midtrans calls it directly — no session, no cookie, no auth header.
Which means the payload has to prove its own authenticity:
export function verifyNotificationSignature(payload: MidtransNotification): boolean {
const serverKey = env.get('MIDTRANS_SERVER_KEY').release()
const expectedSignature = createHash('sha512')
.update(`${payload.order_id}${payload.status_code}${payload.gross_amount}${serverKey}`)
.digest('hex')
return expectedSignature === payload.signature_key
}SHA-512 over order_id + status_code + gross_amount + serverKey, compared against the signature_key in the payload.
This is the entire security of the payment flow. Skip it and anyone who learns the URL can mark any order paid by POSTing JSON at it. The endpoint is public, the payload format is documented publicly, and order numbers are guessable by design — ORD260728-001 through -010 is a morning’s work.
The controller does that and nothing else:
/**
* Receives Midtrans HTTP notification webhooks. Public by design — Midtrans
* calls this endpoint directly, so authenticity is proven via the payload's
* signature key rather than a session.
*/
@inject()
export default class TransactionController {
constructor(protected transactionService: TransactionService) {}
async update({ request, response }: HttpContext) {
const payload = request.body() as MidtransNotification
if (!verifyNotificationSignature(payload)) {
return response.forbidden({ message: 'Invalid signature' })
}
await this.transactionService.handleNotification(payload)
return response.ok({ message: 'OK' })
}
}Signature, delegate, 200. The 200 matters: gateways retry on non-2xx, and returning an error for a payload you have already processed produces a retry storm.
The route sits outside every middleware group. No auth(), no role(), and — critically — outside the CSRF-protected form routes. A third party posting to your endpoint has no CSRF token and never will. Shield’s config has to exempt it, and forgetting that produces a webhook that returns 403 to every legitimate call.
Mapping Their Statuses to Yours
Midtrans has more statuses than the app needs, and one of them is conditional:
private resolveStatus(payload: MidtransNotification): TransactionStatus {
const { transaction_status: transactionStatus, fraud_status: fraudStatus } = payload
if (transactionStatus === 'capture') {
return fraudStatus === 'accept' ? TransactionStatus.PAID : TransactionStatus.FAILED
}
if (transactionStatus === 'settlement') {
return TransactionStatus.PAID
}
if (transactionStatus === 'pending') {
return TransactionStatus.PENDING
}
if (transactionStatus === 'expire') {
return TransactionStatus.EXPIRED
}
return TransactionStatus.FAILED
}| Midtrans | Meaning | Ours |
|---|---|---|
capture + fraud accept | Authorised and cleared fraud screening | PAID |
capture + fraud anything else | Authorised, flagged | FAILED |
settlement | Funds settled | PAID |
pending | Awaiting the customer | PENDING |
expire | QR timed out | EXPIRED |
| anything else | deny, cancel, refund, future additions | FAILED |
capture alone is not payment. It means the transaction was authorised — and if fraud_status is challenge, Midtrans wants a human to look at it. Treating bare capture as paid is the classic mistake in this integration, and it means releasing goods on a transaction that may be reversed.
The default is FAILED, not PENDING. An unrecognised status — a gateway addition, a payment type this code has not seen — resolves to failed. That is the conservative direction: a failed order that was actually fine gets fixed by the reconciliation flow below, with a human looking at it. A paid order that was actually denied is shoes out the door for free.
Boundaries between systems are exactly where “unknown means bad” beats “unknown means probably fine”.
Applying It
async handleNotification(payload: MidtransNotification): Promise<void> {
const transaction = await Transaction.query()
.where('midtransOrderId', payload.order_id)
.preload('order')
.first()
if (!transaction) {
return
}
const status = this.resolveStatus(payload)
const order = transaction.order
await db.transaction(async (trx) => {
await transaction
.merge({ status, midtransTransactionId: payload.transaction_id })
.useTransaction(trx)
.save()
if (status === TransactionStatus.PAID && order.status === OrderStatus.AWAITING_PAYMENT) {
await order.merge({ status: OrderStatus.IN_CLEANING }).useTransaction(trx).save()
}
})
this.broadcastService.orderChanged(order, status)
if (status === TransactionStatus.PAID) {
this.broadcastService.orderPaid(order)
}
}Unknown transaction returns silently. A 200 with no action. Anything else invites retries for a payload this system will never recognise.
Both writes are in one transaction. Marking the transaction paid but failing before the order advances leaves money received against an order still waiting for it.
The status guard is a real business rule, and the comment explains why it is not just defensiveness:
/**
* - A booked order was quoted at inspection and has been sitting in
* `AWAITING_PAYMENT` ever since. Payment is the thing that releases it
* into cleaning, so it moves.
* - A counter order was paid for as it was written up and went straight into
* cleaning. By the time a QRIS scan confirms, the shoes are already being
* washed — possibly already washed. Advancing it would drag a finished
* order backwards, so the payment is recorded and the status left alone.
*/Without order.status === AWAITING_PAYMENT, a walk-in customer paying by QRIS at the counter would have their nearly-finished order shoved back into cleaning by the confirmation callback.
Broadcasts come after the commit. Broadcast inside the transaction and a rollback leaves every open payment page believing money arrived.
This handler is also idempotent, which it has to be — gateways retry, and duplicate deliveries are normal. Re-processing the same payload sets the same status and re-checks the same guard. The only visible effect is a duplicate broadcast, which updates a page to the value it already has.
The Escape Hatch
The part I did not want to build and would not ship without.
The webhook will sometimes not arrive. Network blip, a deploy at the wrong second, a retry policy that gave up. Rare. Not zero.
When it happens the order sits in Menunggu Pelunasan forever. The customer has paid and cannot prove it to the system. Staff have no tool. Nothing in the normal flow rescues it.
/**
* Resolves orders that are stuck waiting for a payment confirmation that
* never arrived.
*
* Midtrans confirms a payment by calling the webhook. If that call is lost the
* order sits in `AWAITING_PAYMENT` forever: the customer cannot mark their own
* order paid and staff have no tool for it either. This is the only way out,
* and it is admin-only because overriding a payment state should be rare,
* deliberate, and attributable to a person.
*/The screen lists stuck orders oldest first — the longer one has sat there, the more likely it is a lost callback rather than a customer still deciding. And it exports to a spreadsheet, because the actual workflow is taking that list to a bank statement.
The constraints are the design:
- Admin only. Not staff.
- Cash or card only. A QRIS payment is always confirmed by Midtrans, because Midtrans is the only party that can know whether the money moved. The override exists for money that arrived in person and was witnessed.
- A reason is required, and the override is permanently recorded in that administrator’s name.
An override that is easy, unattributed, and available to everyone is not a safety valve — it is a hole in the accounting. Possible, never casual.
Cash and Card
Not every payment goes through a gateway:
/**
* Cash carries what the customer actually handed over, so the change given
* back is part of the record rather than a sum somebody did in their head
* and nobody can check afterwards.
*/
async createManualTransaction(
order: Order,
paymentMethod: ManualPaymentMethod,
cashReceived: number | null = null
): Promise<Transaction> {
const transaction = await Transaction.create({
orderId: order.id,
paymentMethod,
midtransOrderId: null,
midtransTransactionId: null,
status: TransactionStatus.PAID,
qrCode: null,
cashReceived: paymentMethod === PaymentMethod.CASH ? cashReceived : null,
})
}Immediately PAID — the money is in the drawer. Midtrans fields null, which is what makes “was this a gateway payment?” answerable from the row.
cashReceived is the small feature the counter staff care about most: enter what the customer handed over, the change is computed on screen and printed on the receipt. Not arithmetic in someone’s head at the end of a queue.
Revenue Comes From Orders
One schema decision that follows from all of this: revenue is summed from orders.total_price, never from transaction rows.
An order can accumulate several transactions — expired QR, retry, eventual success. Summing transactions double-counts. The order carries the amount that was quoted and owed; the transactions are the attempts to pay it.
Money is a property of the order. Transactions are a log of attempts.
What I Would Change
No webhook replay tooling. When a callback is lost, the fix is a human override. Midtrans exposes a status API — a “re-check with the gateway” button on the reconciliation screen would resolve most of those without anyone deciding anything.
Signature comparison is not constant-time. expectedSignature === payload.signature_key is theoretically timing-attackable. crypto.timingSafeEqual is the correct call, and it is a one-line change I should make.
No stored webhook log. Payloads are processed and discarded. A raw log table would make “what did Midtrans actually send us on the 14th” answerable without opening their dashboard.
Testing is genuinely awkward. Sandbox behaves subtly differently from production, and local webhook testing means a tunnel and patience. This is not a Midtrans complaint — it is true of every gateway — but it is the part of the work that takes the longest and looks the smallest in the diff.
Next: Part 9 — Fonnte and WhatsApp as the Notification Channel, where an API returns HTTP 200 to tell you it failed.