Features & Build Notes · · 3 min read
E-commerce feature: a correct Midtrans Snap checkout flow
The Midtrans payment flow end to end: creating the Snap transaction server-side (Server Key stays safe), the snap.pay popup in the browser, and a SHA-512 signature-verified webhook as the single source of truth.
Ringkasan pesanan
Metode pembayaran
Popup Snap terbuka di sini · status final ditentukan webhook
Midtrans Snap is the fastest way to accept local payments (every bank’s VA, QRIS, GoPay, OVO, cards) in an Indonesian online store — it’s the gateway I wire into NexaMart and client builds. The flow is three steps: the server creates a transaction, the browser opens the Snap popup, and a webhook confirms payment. Click View preview to see the checkout-summary UI.
The architecture (and its golden rules)
Browser ──POST /checkout──▶ Server ──(SERVER KEY)──▶ Midtrans ──▶ snap token
Browser ◀── token ─────────┘
Browser ──snap.pay(token)──▶ Midtrans payment popup
Midtrans ──webhook──▶ Server: verify signature ──▶ mark order PAID
Golden rule #1: the Server Key must never touch the browser. Golden rule #2: “paid” status may only be written by a verified webhook — never by a JavaScript callback (users close popups, connections drop, and callbacks can be forged).
1. Server: create the Snap transaction
// POST /checkout — runs on the server (e.g. Cloudflare Workers / Node)
const AUTH = "Basic " + btoa(env.MIDTRANS_SERVER_KEY + ":"); // note the trailing ":"
const resp = await fetch("https://app.sandbox.midtrans.com/snap/v1/transactions", {
method: "POST",
headers: { Authorization: AUTH, "Content-Type": "application/json" },
body: JSON.stringify({
transaction_details: {
order_id: `ORDER-${crypto.randomUUID()}`, // unique — Midtrans rejects duplicates
gross_amount: 170000, // total in Rupiah, no decimals
},
item_details: [
{ id: "gayo-coffee-250", name: "Gayo Coffee 250g", price: 85000, quantity: 2 },
],
customer_details: { first_name: "Budi", email: "budi@mail.com", phone: "0812..." },
}),
});
const { token } = await resp.json();
return c.json({ token }); // only the token may reach the browser
Swap app.sandbox.midtrans.com → app.midtrans.com for production.
2. Browser: open the Snap popup
<!-- sandbox; production: https://app.midtrans.com/snap/snap.js -->
<script src="https://app.sandbox.midtrans.com/snap/snap.js"
data-client-key="SB-Mid-client-xxx"></script>
<script>
async function pay() {
const { token } = await fetch("/checkout", { method: "POST" }).then(r => r.json());
window.snap.pay(token, {
onSuccess: () => location.href = "/order/thank-you",
onPending: () => location.href = "/order/awaiting", // VA: user pays later
onClose: () => {/* user closed the popup — leave it, don't mark as failed */},
});
}
</script>
These callbacks are UX navigation only — never the source of truth for status.
3. The webhook: the single source of truth
Midtrans POSTs to your notification URL. Verify the signature before trusting anything:
// POST /webhook/midtrans
const n = await c.req.json();
// signature_key = SHA512(order_id + status_code + gross_amount + ServerKey)
const raw = n.order_id + n.status_code + n.gross_amount + env.MIDTRANS_SERVER_KEY;
const hash = [...new Uint8Array(
await crypto.subtle.digest("SHA-512", new TextEncoder().encode(raw)),
)].map((b) => b.toString(16).padStart(2, "0")).join("");
if (hash !== n.signature_key) return c.text("invalid signature", 403);
const paid = n.transaction_status === "settlement" ||
(n.transaction_status === "capture" && n.fraud_status === "accept");
if (paid) await markPaid(n.order_id); // idempotent — webhooks can be re-delivered
return c.text("ok");
Make the handler idempotent: Midtrans may deliver the same notification more than once.
Lessons
- Test fully in sandbox (Midtrans provides card & VA simulators) before touching production mode.
order_idis the key that links every system — keep its format consistent and store it in the database before the popup ever opens.- Order status has more than two values:
pending(VA not yet paid) is a normal state, not an error — design your UI for it.
Want something like this built for your business?