Checkout
Take payments in the app you build here. You bring your own payment gateway account, the money settles straight to it, and the platform never holds or touches it.
It adds four things to your project: somewhere to store your gateway keys, two functions your code calls to charge a customer, a webhook route that records what the gateway says, and one file you own where you decide what happens after an order is paid.
Before you start
- A database on this project. Orders live in your project's own database, so checkout is unavailable until one exists. Add it from Settings → Database.
- Your own gateway account. Sign up with Midtrans directly and complete their verification. The account is yours: payouts, refunds, disputes and fees are all between you and them.
Midtrans is the gateway available today. Stripe appears in the picker marked "not available yet" and cannot be selected. If you use a different gateway, pick Custom and ask the AI in chat to fill in the one adapter file for it.
Add your gateway keys
Open Settings → Checkout.
- Pick the Gateway.
- Paste your Secret key under the matching Key mode tab — Test or Live.
- Add a publishable key and a webhook signing secret if your gateway issues them.
- Click Save keys.
Keys are write-only: encrypted on save, and no screen or API can read them back. To change one, paste a new one.
You do not choose which environment gets which key: a preview always runs on your test keys, and a published app always runs on your live keys. A preview is served from a public address, so there is no "just this once" switch to find. For Midtrans, the key's own prefix decides which mode it is stored as, whichever tab you had open.
A container's environment is fixed when it starts, so a preview that is already running keeps the keys it began with — restarting the dev server does not change that. New keys land on the next fresh container: close the workspace and come back in about ten minutes. Publishing always builds with whatever is saved at that moment.
Charge a customer
Two functions, and the split between them is the point.
// On your server. Compute the total from YOUR price records.
const intent = await createIntent({ amountIdr: 50000, orderRef: 'INV-1041' });
// Reachable from the browser. It takes a lookup key and nothing else.
const { redirectUrl } = await startCheckout(intent.id, origin);
createIntent freezes the amount before a browser is involved. startCheckout is the one a customer can reach, and it accepts no amount — so there is no figure in devtools to edit. That covers tampering only: if your own code adds a cart up wrongly, it will charge the wrong total correctly.
The customer pays on your gateway's own hosted page. Never build your own card form — collecting card numbers moves your app into a far heavier compliance obligation, and the AI is instructed to refuse.
Fulfil the order
When the gateway confirms payment, your app runs onPaid in lib/checkout-fulfil.ts. That file and your custom gateway adapter are the only two checkout files the platform never regenerates — put your fulfilment there, not in the webhook route, or an upgrade will quietly delete it.
Fulfilment is deliberately separate from payment truth. If onPaid throws, the order stays paid and the failure is recorded on it, so you can retry shipping without the gateway replaying a payment it already delivered.
Test the webhook
Copy the Webhook URL from the panel into your gateway dashboard. Your gateway posts to your app directly — the request never passes through the platform.
Send a test delivery signs a sandbox notification with the test key you saved and reports what your app answered. It proves the route is reachable and that your app holds the same key. It cannot mark an order paid: your app re-reads every transaction from the gateway before settling it, and the gateway has never heard of an order we invented. Only a real sandbox payment turns "A test payment completed" green.
Deliveries are recorded as metadata only. The request body carries your customer's name, email and address, so it is never stored — which also means a delivery cannot be replayed from the panel.
Limits & notes
- The webhook URL contains your project slug. Renaming the project moves it while your gateway keeps posting to the old address, and nothing in your app's logs will show it, because the request never arrives. A custom domain gives you an address that survives a rename.
- Give your gateway a published URL, not a preview one. Preview addresses are reclaimed when they go idle.
- Refunds, payouts and disputes happen in your gateway's dashboard. Nothing here touches them.
- Amounts are whole rupiah. Rupiah has no subunit, so do not pass cents.
- Recurring subscriptions inside your app are not part of checkout.
Troubleshooting
- "Your app rejected the signature" — the running app holds a different key than the one saved. Usually the preview started before you saved it; close the workspace, come back in about ten minutes, and try again.
- The readiness list says the checkout tables are missing — send one prompt so the schema lands in your database.
- An order stays pending after a sandbox payment — your app asks the gateway to confirm every transaction before settling it. A notification the gateway will not confirm is refused on purpose.