Quickstart
Customer → deposit address → webhooks → balance → payout, with the requests written out.
4.1 Create a customer
curl -X POST https://api.molnpay.com/v1/customers \
-H "Authorization: Bearer $WALLET_KEY" -H "Content-Type: application/json" \
-d '{"reference_id":"user_4471","email":"u@example.com"}'{ "object":"customer", "id":"9ab3…", "reference_id":"user_4471",
"email":"u@example.com", "status":"active", "metadata":{},
"created_at":"2026-08-01T10:00:00.000Z" }reference_id is your own user id. Re-posting the same one returns the existing
customer with 200 instead of creating a duplicate.
4.2 Get a deposit address
curl -X POST https://api.molnpay.com/v1/wallets \
-H "Authorization: Bearer $WALLET_KEY" -H "Content-Type: application/json" \
-d '{"customer_id":"9ab3…","chain":"tron"}'{ "object":"wallet", "id":"7c1e…", "customer_id":"9ab3…", "chain":"tron",
"role":"customer", "status":"active", "address":"TR7NHq…", "memo":null,
"created_at":"2026-08-01T10:00:05.000Z" }There is one wallet per (customer, chain), enforced by the database. Calling
this again returns the same address with 200. That is deliberate: deriving a
second address would split the customer's deposits across addresses your UI does
not show.
TON requires the memo. If memo is non-null, the sender must include it or
the funds are unattributable.
Which chains, and what kind of address. Read GET /v1/chains before you
render a picker — it is already filtered to what the platform lists for you.
Each chain carries:
account_kind—contractis a keyless smart account: the same address on every EVM chain markedcontract, no private key anywhere.eoais a keypair the platform's signer holds. Keypair chains (Bitcoin, Dogecoin, Solana, TON, …) are listed only when the operator has switched them on; today the platform issues smart accounts only.status(live|paused|coming_soon) andstatus_note, the operator's reason in their words. Show the note verbatim.deposits_enabledat the chain level, and per asset underassets[]. A new address is issued only when both are true.
A request for a new address on a chain that is not issuing is refused with
422 and a code that names the level: account_kind_disabled (the kind of
address that chain hands out is switched off), chain_not_live_yet or
chain_deposits_disabled. A quote for a coin whose own deposits are off
(invoices) fails with asset_deposits_disabled. An address the customer
already holds is always returned, whatever the switches say, and funds sent
to it keep being credited — the one exception is a coin the operator has
archived (a compromised contract), whose transfers are not credited.
4.3 The customer sends funds → you get webhooks
Register where they go first (§9.6 has the whole lifecycle):
curl -X POST https://api.molnpay.com/v1/webhooks \
-H "Authorization: Bearer $WALLET_KEY" -H "Content-Type: application/json" \
-d '{"url":"https://example.com/molnpay/webhook","event_types":["deposit.confirmed","deposit.orphaned"]}'The 201 carries the endpoint's secret (whsec_…) once. Store it; §9.3
is how you use it. Then:
deposit.detected seen on chain. NOT credited. Show as "pending".
deposit.confirmed passed the chain's confirmation threshold. Credited. This is money.
deposit.orphaned a confirmed deposit was reorged out. Reverse what you credited.4.4 Read the balance
curl "https://api.molnpay.com/v1/customers/9ab3…/balances" \
-H "Authorization: Bearer $WALLET_KEY"available is spendable. locked is reserved against an in-flight payout.
4.5 Send a payout
curl -X POST https://api.molnpay.com/v1/payouts \
-H "Authorization: Bearer $WALLET_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: 3f7a1c92-payout-8812" \
-d '{"customer_id":"9ab3…","chain":"tron","asset":"USDT","decimals":6,
"amount":"12500000","to_address":"TQn9Y…","reference":"wd_8812"}'Returns 202 — accepted, not yet sent. The transaction hash arrives later via
payout.submitted / payout.confirmed.
Above your configured threshold a payout enters pending_approval and waits for
a human. That is a safety feature, not an error.
4.6 Take a payment: invoice and hosted checkout
curl -X POST https://api.molnpay.com/v1/invoices \
-H "Authorization: Bearer $CHECKOUT_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: order_8812" \
-d '{"price_amount":"4990","price_currency":"USD","reference_id":"order_8812",
"title":"Order #8812","return_url":"https://shop.example/thanks"}'{ "object":"invoice", "id":"9f3c1b2e-…", "status":"open",
"price_amount":"4990", "price_currency":"USD",
"received_amount":"0", "excess_amount":"0",
"checkout_url":"https://pay.molnpay.com/i/ck_9f3c…",
"expires_at":"2026-09-16T12:30:00.000Z",
"addresses":[{"chain":"bsc","address":"0x71C7…","memo":null}],
"quotes":[{"chain":"bsc","asset":"USDT","contract":"0x55d3…",
"amount_atomic":"49900000000000000000","amount":"49.90",
"decimals":18,"rate":"1.000000000000000000","rate_source":"bitget_ws"}] }Send the payer to checkout_url. That is all the integration there is: the page
shows the address, the QR and the amount, watches the chain, and returns them to
your return_url.
price_amount is minor units. 4990, not 49.90. amount_atomic on each
quote is what the payer actually sends; amount beside it is derived for
display and is never what a payment is checked against.
decimals is per (chain, asset), and it is not decorative. USDT is an
18-decimal token on BSC and a 6-decimal token everywhere else. The quoted amount
is computed with the number in this field, so use that field rather than a
constant of your own.
Treat checkout_url like a bearer credential. The token in it is the entire
authorisation for the checkout page — it is deliberately not the same value as
id, so that pasting an invoice id into a support ticket hands over nothing.
What the payer sees, and what you get
| Paid within ±0.5% | invoice.paid. Fulfil here, and only here. |
| Short by more than that | invoice.underpaid. The address stays live, the page shows the remainder with its own QR, and the window is extended (up to three times) so nobody loses it mid-transfer. Do not fulfil. A second invoice.underpaid is a new fact, not a duplicate. |
| Over by more than that | invoice.overpaid, with excess_amount saying by how much. Your overpayment policy decides what happens to it: keep credits it to you, refund offers it back on the page. |
| Nothing, in time | invoice.expired. Money arriving afterwards is still credited and marked late. |
invoice.pending fires when a transfer is seen on chain but is below finality.
It is not money — a reorg can take it away — so use it to update a screen,
never to release goods.
Cancelling
curl -X POST https://api.molnpay.com/v1/invoices/9f3c1b2e-…/cancel \
-H "Authorization: Bearer $CHECKOUT_KEY"Only while nothing has been received. Cancelling does not close the payment address: it stays watched forever, so a late transfer is still detected and credited rather than lost.