Lesson 5 / 7 · 12 min
Payments in chat with the Midtrans sandbox
Create an invoice, send the link, verify the payment webhook signature, auto-confirm. Checkpoint: a full paid order, end to end.
Register a free Midtrans account and switch the dashboard to Sandbox mode: real API, test money. Copy the sandbox Server Key into MIDTRANS_SERVER_KEY in .env. We will use Snap, their hosted payment page that accepts QRIS, virtual accounts, e-wallets, and cards, so we never touch card data ourselves.
Step 1: create the invoice (pay.js)
export async function createInvoice(orderId, amount) {
const auth = Buffer
.from(process.env.MIDTRANS_SERVER_KEY + ":")
.toString("base64");
const res = await fetch(
"https://app.sandbox.midtrans.com/snap/v1/transactions",
{
method: "POST",
headers: {
Authorization: "Basic " + auth,
"Content-Type": "application/json",
},
body: JSON.stringify({
transaction_details: {
order_id: orderId,
gross_amount: amount,
},
}),
},
);
const data = await res.json();
return data.redirect_url; // the hosted payment page
}Step 2: send the link from the flow
// server.js: after step(), handle the action from CONFIRM
import { createInvoice } from "./pay.js";
const { reply, action } = step(session, msg.text.body);
saveSession(session);
if (reply) await sendText(msg.from, reply);
if (action === "create_invoice") {
const link = await createInvoice(
session.data.orderId,
session.data.total,
);
await sendText(
msg.from,
"Total Rp " + session.data.total.toLocaleString("id-ID") +
". Silakan bayar di sini ya: " + link,
);
}Step 3: the payment webhook, with signature verification
// server.js
import crypto from "crypto";
import { db, addLabel } from "./db.js";
app.post("/payment/notify", async (req, res) => {
res.sendStatus(200);
const n = req.body;
// Midtrans signs: sha512(order_id + status_code + gross_amount + ServerKey)
const expected = crypto
.createHash("sha512")
.update(
n.order_id + n.status_code + n.gross_amount +
process.env.MIDTRANS_SERVER_KEY,
)
.digest("hex");
if (expected !== n.signature_key) {
return console.error("BAD SIGNATURE for", n.order_id);
}
if (n.transaction_status === "settlement" ||
n.transaction_status === "capture") {
// idempotent: only flips once, no matter how many callbacks arrive
const r = db.prepare(
"UPDATE orders SET status='paid', paid_at=unixepoch() " +
"WHERE id = ? AND status != 'paid'",
).run(n.order_id);
if (r.changes === 1) {
const o = db.prepare("SELECT phone FROM orders WHERE id = ?")
.get(n.order_id);
addLabel(o.phone, "customer");
await sendText(o.phone,
"Pembayaran diterima. Pesanan Anda sedang diproses ya, terima kasih!");
}
}
});Step 4: tell Midtrans where to call
In the Midtrans sandbox dashboard, set the Payment Notification URL to your ngrok address plus /payment/notify. Then place an order from your phone, open the link, and pay with one of the sandbox test methods listed in the Midtrans docs (test cards and simulated QRIS or virtual accounts).
Checkpoint: order from your phone, pay in the sandbox, and watch the bot confirm by itself, with the order flipped to paid in the database and the customer label applied. That loop, request to money to confirmation with zero human touches, is the heart of commerce automation.
What you take away
- Snap gives a hosted payment page; you never handle card data
- Verify the sha512 signature on every notification, no exceptions
- The paid update is idempotent: WHERE status != 'paid' guards double callbacks
- Sandbox proves the full loop with test money before any real rupiah moves
Curated sources
Hand-picked references for going deeper, reviewed by our team.