# Build your own checkout (/guides/own-checkout)



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 [#the-short-version]

```bash
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"
  }'
```

```json
{
  "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 [#which-mode-to-ask-for]

| `checkout_mode`                | `checkout_url` | `client_token` | What it means                                                        |
| ------------------------------ | -------------- | -------------- | -------------------------------------------------------------------- |
| `hosted&#x60; &#x2A;(default)* | set            | `null`         | Redirect the payer to us. Unchanged; every plan.                     |
| `embedded`                     | `null`         | set            | You render it. No redirect.                                          |
| `both`                         | set            | set            | You 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 [#two-ways-to-wire-it]

### The client token, in the payer's browser [#the-client-token-in-the-payers-browser]

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

<Callout type="warn">
  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.
</Callout>

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 [#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 [#what-the-document-gives-you]

```json
{
  "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 [#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.

<Callout type="error">
  **1. Do not build your page from `GET /v1/invoices/{id}`.** Its `quotes[]` is
  the **full price*&#x2A;, 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.
</Callout>

**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.**

| Field                       | `null` means                                                                           |
| --------------------------- | -------------------------------------------------------------------------------------- |
| `refresh_at`                | a stablecoin — quoted once, never re-priced. Do not show a countdown that never fires. |
| `uri`                       | this chain has no payment URI worth emitting (Solana, TON). Show the address.          |
| `refund.fee` / `refund.net` | **not priced yet**, not free. Show a working state.                                    |
| `refund.sender_class`       | **not 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.

<Callout type="error">
  **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.
</Callout>

**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 [#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.
