Docs menu

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.

MidtransPaymentsWebhooksSecurity
Live exampleRendered by the actual component — not an image.

Ringkasan pesanan

Kopi Gayo Premium 250g × 2Rp 170.000
Ongkir — JNE REGRp 18.000
TotalRp 188.000

Metode pembayaran

VA BCAVA MandiriQRISGoPayKartu Kredit

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.comapp.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_id is 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?