Integrating Midtrans Snap Payments into a Next.js App

Photo by Torsten Dettlaff via Wikimedia Commons (CC BY-SA 4.0)
Yes. The Client Key is designed to be public and is only used by snap.js to render the payment popup. The Server Key is the secret one — it authorizes token creation and signs webhooks, so it must stay server-side and never be prefixed with NEXT_PUBLIC_.
The onSuccess callback runs in the user's browser and can be missed if they close the tab or lose connection. For Virtual Account and QRIS, payment may complete hours later. The only reliable confirmation is the server-side HTTP notification, whose signature you verify before marking an order paid.
Concatenate order_id, status_code, gross_amount and your Server Key into one string, then compute its SHA512 hash as hex. Compare that to the signature_key in the notification body. If they do not match, reject the request with a 403 because it is forged or corrupted.
Pending means the payment instruction (a Virtual Account number or QRIS code) was created but the customer has not paid yet. Settlement means the bank or provider has confirmed the funds. Only fulfil an order on settlement, or on capture with fraud_status accept for card payments.
Sandbox creates tokens at app.sandbox.midtrans.com/snap/v1/transactions and loads app.sandbox.midtrans.com/snap/snap.js. Production uses app.midtrans.com for both. The two environments have separate keys and dashboards, and a sandbox token will not open in the production popup.

Photo by Torsten Dettlaff via Wikimedia Commons (CC BY-SA 4.0)
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 the payment gateway most Indonesian apps reach for first, and Snap is its drop-in checkout: one popup that covers Virtual Account (BCA, BNI, Mandiri, Permata), 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.
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 it
NEXT_PUBLIC_MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxxxxxxxxxxSandbox keys are prefixed SB-Mid- and production keys are just Mid-. The two environments have completely separate dashboards, transactions, and snap.js URLs. A token created in sandbox will never open in the production popup, so a mismatched environment is the first thing to check when the popup silently fails.
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 in whole Rupiah and must be an integer — Midtrans rejects decimals because IDR has no cents. Also make sure the amount you send equals the sum of item_details if you include them, or the request fails validation.
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_PROD === "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.
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"].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 HTTP 200 quickly and do the heavy work asynchronously. Midtrans retries notifications that do not get a 200, so a slow handler causes duplicate deliveries. Make your order-update logic idempotent — key it on order_id so a retried settlement notification never double-ships an order or double-credits a wallet.
The field that matters is transaction_status, sometimes 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.
| 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 | Payment rejected by provider | Mark order failed |
| expire | Not paid within the time limit | Release stock, mark failed |
| cancel / refund | Cancelled or money returned | Reverse fulfilment |