Key Takeaway
Midtrans Snap needs three moving parts: a server route that trades your Server Key for a Snap token, the client-side snap.js popup that opens with that token, and a webhook that verifies every payment by recomputing SHA512 of order_id, status_code, gross_amount and your Server Key. Never trust the browser redirect alone.
Midtrans is an Indonesian payment gateway, and Snap is its drop-in checkout: one popup that covers bank-transfer Virtual Accounts (BCA, BNI, BRI, Permata and more) and Mandiri Bill Payment, QRIS, the big e-wallets (GoPay, OVO, DANA, ShopeePay), and cards. On a recent project for an Indonesian client I wired Snap into a Next.js 15 App Router build, and the flow is cleaner than the old docs make it look, as long as you keep one rule in mind: the money is only real when the webhook says so.
This walkthrough covers the full round trip: creating a transaction token from a server route, opening the Snap popup on the client, receiving the HTTP notification, verifying its signature, and reading the settlement status correctly. The examples use the App Router and plain fetch so you can drop them into any Next.js codebase without the official SDK if you prefer.
The three keys and two environments
Midtrans gives you a Server Key and a Client Key, and both come in a sandbox pair and a production pair. The Server Key must live only on the server; it is the secret that both authorizes token creation and signs webhook notifications. The Client Key is safe to expose in the browser because it only lets snap.js render the popup. Getting this split wrong is the single most common security mistake I see in Indonesian Midtrans tutorials.
# .env.local
# Server-only — never prefix with NEXT_PUBLIC_
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxxxxxxxxxxxxx
MIDTRANS_IS_PRODUCTION=false
# Safe in the browser — snap.js needs them
NEXT_PUBLIC_MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxxxxxxxxxx
NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION=falseSandbox and production are separate environments with separate Server and Client Keys: pick the environment in the dashboard's top-left dropdown, then read the pair under Settings, Access Keys. Midtrans's own sandbox examples use keys starting SB-Mid-, but trust the dashboard rather than the prefix. The snap.js and API hosts differ too (app.sandbox.midtrans.com versus app.midtrans.com), so a mismatched environment is the first thing to check when the popup silently fails.
Step 1 — Create the Snap token server-side
The backend job is to POST your order details to the Snap transactions endpoint using HTTP Basic Auth, where the username is your Server Key and the password is empty. Midtrans returns a token that the client uses to open the popup. Build the order_id yourself and make it unique and idempotent per order, because you will match on it later in the webhook.
// app/api/payment/route.ts
import { NextRequest, NextResponse } from "next/server";
const IS_PROD = process.env.MIDTRANS_IS_PRODUCTION === "true";
const SNAP_URL = IS_PROD
? "https://app.midtrans.com/snap/v1/transactions"
: "https://app.sandbox.midtrans.com/snap/v1/transactions";
export async function POST(req: NextRequest) {
const { orderId, grossAmount, customer } = await req.json();
// Basic auth: base64(serverKey + ":") — empty password
const auth = Buffer.from(`${process.env.MIDTRANS_SERVER_KEY}:`).toString("base64");
const res = await fetch(SNAP_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Basic ${auth}`,
},
body: JSON.stringify({
transaction_details: {
order_id: orderId, // must be unique per order
gross_amount: grossAmount, // integer IDR, no decimals
},
customer_details: {
first_name: customer.name,
email: customer.email,
phone: customer.phone,
},
}),
});
if (!res.ok) {
return NextResponse.json({ error: "Failed to create transaction" }, { status: 502 });
}
const data = await res.json(); // { token, redirect_url }
return NextResponse.json({ token: data.token });
}gross_amount is typed as an integer in whole Rupiah, and Midtrans also says not to put decimals in item prices. If you send item_details, price times quantity across all items must equal gross_amount exactly. Notifications return it as a string such as 10000.00, so hash that string exactly as received.
Step 2 — Open the Snap popup on the client
On the client you load snap.js once with your Client Key, then call window.snap.pay(token) with the token your route returned. In Next.js the clean way to inject the script is next/script with the correct sandbox or production URL. The popup handles every payment method Midtrans has enabled for your account, so you do not build any card or Virtual Account UI yourself.
"use client";
import Script from "next/script";
import { useState } from "react";
const SNAP_JS = process.env.NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION === "true"
? "https://app.midtrans.com/snap/snap.js"
: "https://app.sandbox.midtrans.com/snap/snap.js";
export function CheckoutButton({ orderId, amount, customer }: any) {
const [loading, setLoading] = useState(false);
async function pay() {
setLoading(true);
const res = await fetch("/api/payment", {
method: "POST",
body: JSON.stringify({ orderId, grossAmount: amount, customer }),
});
const { token } = await res.json();
setLoading(false);
// @ts-expect-error snap is injected by snap.js
window.snap.pay(token, {
onSuccess: (r) => console.log("paid", r),
onPending: (r) => console.log("pending — VA/QRIS awaiting payment", r),
onError: (r) => console.log("error", r),
onClose: () => console.log("popup closed without finishing"),
});
}
return (
<>
<Script src={SNAP_JS} data-client-key={process.env.NEXT_PUBLIC_MIDTRANS_CLIENT_KEY} strategy="afterInteractive" />
<button onClick={pay} disabled={loading}>Bayar Sekarang</button>
</>
);
}The onSuccess callback fires in the browser and is great for UX, but it is not proof of payment. A user can close the tab, lose signal, or a Virtual Account can be paid hours later. Use these callbacks only to update the screen. The source of truth is always the server-side webhook in Step 3.
Step 3 — Handle and verify the webhook
Midtrans sends an HTTP notification to a URL you register in the dashboard whenever a transaction changes state. This is the only reliable signal that money moved. Every notification carries a signature_key, and you must recompute it yourself: SHA512 of order_id, status_code, gross_amount and your Server Key concatenated. If your hash does not match, discard the request — it is forged or corrupted. Only after the signature checks out do you read the status.
// app/api/payment/notification/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createHash } from "crypto";
export async function POST(req: NextRequest) {
const body = await req.json();
const { order_id, status_code, gross_amount, signature_key,
transaction_status, fraud_status } = body;
// Recompute: SHA512(order_id + status_code + gross_amount + ServerKey)
const expected = createHash("sha512")
.update(order_id + status_code + gross_amount + process.env.MIDTRANS_SERVER_KEY)
.digest("hex");
if (expected !== signature_key) {
return NextResponse.json({ error: "Invalid signature" }, { status: 403 });
}
// Signature valid — now decide the real outcome
const paid =
(transaction_status === "settlement") ||
(transaction_status === "capture" && fraud_status === "accept");
if (paid) {
// idempotent: mark order paid only if not already
// await markOrderPaid(order_id);
} else if (["deny", "cancel", "expire", "failure"].includes(transaction_status)) {
// await markOrderFailed(order_id);
} // "pending" — VA/QRIS created, wait for the next notification
return NextResponse.json({ ok: true }); // return 200 fast
}Return a 2xx fast and do the heavy work asynchronously. Midtrans waits up to 15 seconds, asks you to answer within about 5, and retries a failed delivery up to five times over several hours (fewer times for 500, 503, 400 and 404). It also warns that the same notification may, rarely, arrive twice, so key your order-update logic on order_id and a retried settlement never double-ships an order or double-credits a wallet.
Reading transaction statuses correctly
The field that matters is transaction_status, paired with fraud_status for card payments. A common bug is treating pending as success — for a Virtual Account or QRIS, pending simply means the payment instruction was created and the customer has not paid yet. Only settlement (or capture with an accept fraud status for cards) means the funds are confirmed. One documented exception: for Permata VA, Mandiri Bill Payment and Indomaret, a settlement can rarely reverse to deny, usually within minutes, and Midtrans says to treat it as unpaid. When a notification is late or out of order, ask the GET Status API.
| transaction_status | What it means | Action |
|---|---|---|
| pending | VA/QRIS created, customer has not paid | Wait for the next notification |
| settlement | Funds confirmed by bank/provider | Fulfil the order |
| capture | Card authorized; check fraud_status | Fulfil only if fraud_status is accept |
| deny | Rejected by the provider or Midtrans fraud detection | Mark order failed |
| expire | Not paid within the time limit | Release stock, mark failed |
| cancel / refund | Cancelled or money returned | Reverse fulfilment |
Testing in sandbox before going live
- Use Midtrans's web-based sandbox simulator (simulator.sandbox.midtrans.com) to pay a test Virtual Account or QRIS code without real money, use the documented test cards for accepted, challenged and denied card payments, and set a short custom expiry to watch an expire notification arrive.
- Expose your local webhook with a tunnel (for example ngrok) and register that URL in Settings, Configuration, Payment Notification URL.
- Test the signature check by sending a fake notification with a wrong signature_key and confirm your route returns 403.
- When switching to production, swap both keys, flip the environment flag, and re-register the production notification URL — they do not carry over from sandbox.

