Guides

Build your own checkout

Render the payment page yourself, on your own domain, with no redirect — and the nine things that go wrong when you do.

Every invoice can be paid on our hosted page at pay.molnpay.com. You can also render that page yourself, on your own domain, in your own design, and never send your customer anywhere.

Available from the Growth plan up. Test keys (mptest…) are never gated, so you can build and finish the whole integration before you pay for anything.

The short version

curl -X POST https://api.molnpay.com/v1/invoices \
  -H "Authorization: Bearer $MOLNPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "price_amount": "4900",
    "reference_id": "order_10231",
    "checkout_mode": "embedded"
  }'
{
  "object": "invoice",
  "id": "5c1f…",
  "checkout_mode": "embedded",
  "checkout_url": null,
  "client_token": "ck_3xN…",
  "status": "open"
}

Hand client_token to your front end and poll one endpoint:

GET https://api.molnpay.com/v1/checkout/ck_3xN…

That is the whole payer-facing API. There is no separate status endpoint — the same document tells you what to render and whether the money has arrived, so the two can never disagree.

Which mode to ask for

checkout_modecheckout_urlclient_tokenWhat it means
hosted (default)setnullRedirect the payer to us. Unchanged; every plan.
embeddednullsetYou render it. No redirect.
bothsetsetYou render it, and keep our page as a fallback or a mobile hand-off.

embedded withholds the URL on purpose, so that code which redirects by accident fails on your side instead of standing your customer on our brand. The hosted page still answers for that token — a payer who already has a link never meets a dead page.

The mode is frozen on the invoice at creation. It survives a plan change: an invoice created while you were on Growth keeps working if you downgrade, because a payer mid-order must not lose the page. New invoices are refused with embedded_checkout_not_on_plan.

Two ways to wire it

The client token, in the payer's browser

GET /v1/checkout/{token} is unauthenticated, serves Access-Control-Allow-Origin: * without credentials, and is safe to call from your own domain.

The client token is the only credential of ours that belongs in a browser. It grants the payer view of one invoice: no fee, no metadata, no other order, nothing that reaches this API. Your mp… secret key carries every invoice and every scope it holds — it must never be in front-end JavaScript.

Your page will need these origins allowed in its CSP, or it will fail in ways that look like nothing at all:

connect-src https://api.molnpay.com;
img-src     https://assets.molnpay.com;

Miss connect-src and the page never loads. Miss img-src and every coin logo is a grey disc.

Your own server, if the browser must not reach us

GET  /v1/invoices/{id}/checkout
POST /v1/invoices/{id}/checkout/select
POST /v1/invoices/{id}/checkout/refund

The same document, from the same builder, with your API key instead of the payer's token. It stays payer-safe, so you may relay the body to a browser verbatim without leaking your own rate card to your own customer.

It also costs your plan's rate limit. The public route allows 120/minute per IP address, so proxying every payer through one backend would put all of them in one bucket and run out at around six concurrent checkouts.

What the document gives you

{
  "object": "checkout",
  "status": "open",
  "merchant": { "name": "Northwind", "logo_url": "https://assets.molnpay.com/…" },
  "currency": "USD",
  "price": "49.00",
  "received": "0.00",
  "remaining": "49.00",
  "expires_at": "2026-09-18T12:30:00.000Z",
  "options": [
    {
      "chain": "bsc", "asset": "USDT", "name": "Tether USD",
      "address": "0x…", "memo": null,
      "amount": "49.000", "amount_atomic": "49000000000000000000",
      "decimals": 18, "rate": "1.0000", "stable": true,
      "refresh_at": null,
      "uri": "ethereum:0x…@56/transfer?address=0x…&uint256=49000000000000000000"
    }
  ],
  "payments": [],
  "refund": null
}

Render amount into the field, amount_atomic into nothing (it is the authoritative figure, for comparison), uri into the QR code, and address into the copy button.

Nine things that go wrong

These are not general advice. Each one is a real failure of this API that we have either seen or built a guard against.

1. Do not build your page from GET /v1/invoices/{id}. Its quotes[] is the full price, always, because that is what the rate was locked at. Render it on a part-paid invoice and you tell a payer who has already sent $31 of a $50 order to send $50 more. They overpay by $31, and that needs a refund we may not be able to make.

options[] on the checkout document is the remainder, re-priced at the same locked rate with integer arithmetic that rounds up. Use it.

2. Never do money arithmetic in the browser. amount and amount_atomic are rendered verbatim. A remainder derived in JavaScript from a decimal string and a rate is a float bug in front of a payment address.

3. Use each option's own decimals, never a table. USDT on BSC is 18 and on Tron is 6. Get it wrong and you show a figure a trillion times out.

4. A failed poll must never replace a document already on screen. Once a payer is reading an address and an amount, a network hiccup must leave both exactly where they are; try again on the next tick. Only the first read may show an error state, because only then is there nothing true to show. not_found is the one exception — the link is dead, and retrying will not resurrect it.

5. Poll on a decaying ladder, and stop when the tab is hidden. 3 seconds for the first two minutes, then 8, then 20 — measured from when your page mounted, not from when the invoice was created. Pause while document.visibilityState is hidden (the normal case is a payer switching to their wallet app) and catch up immediately on visibilitychange. A background tab hammering the limit can rate-limit the very read that would have told them they paid.

6. null does not mean zero.

Fieldnull means
refresh_ata stablecoin — quoted once, never re-priced. Do not show a countdown that never fires.
urithis chain has no payment URI worth emitting (Solana, TON). Show the address.
refund.fee / refund.netnot priced yet, not free. Show a working state.
refund.sender_classnot classified yet, not "unknown". Show a working state.

7. Never cache the document. We send no-store. If you proxy it through your own CDN, a payer who has already paid will watch an "unpaid" page count down to a deadline that no longer applies.

8. If you render your own page, you must build the refund form. When status is overpaid and refund.status is awaiting_address, the payer has to tell us where the excess goes — and nothing happens until they do. An embedded checkout that shows the amount and the address but never asks the question leaves that money in awaiting_address for ever.

Wait until both refund.sender_class and refund.net are non-null before showing the form, then POST /v1/checkout/{token}/refund with the address. Ours refuses with refund_not_ready until then, because the form promises three lines — excess, network fee, what you receive — and a payer must not agree to a figure nobody had worked out.

9. Your page is not the truth — the webhook is. Browser state is for the payer's comfort. Ship the order on invoice.paid reaching your server, deduped on event.id, after you have verified the signature. This is also why the feature starts on the plan where webhooks do.

Things you do not have to handle

  • The payer sending on a different chain from the one they clicked. One address serves every EVM chain, and select locks nothing and starts no clock. Whatever arrives on any offered chain is attributed to the invoice. Send the selection anyway — it lets us prioritise re-pricing the asset they are actually reading.
  • Money arriving after the deadline. The address is watched forever. A late payment is surfaced as late, never lost.
  • Rounding the top-up. A part-paid invoice's options are already the remainder, under the same precision rule the first quote used.