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.
Withdrawing from the customer's own address (brokerages, exchanges)
Send "source": "customer_account" (or have MolnPay make it your account's
default) and a payout leaves only from the paying customer's own deposit
address on that chain — the address they deposited to. from_wallet_id names
one of their wallets and implies it.
When that address holds less than the payout — a client who made money — we
first move the shortfall into it from where your coins sit (another of your
customers' addresses, or our custody), then send from the customer's address.
Meanwhile the payout stays queued with waiting_on: "account_topup". The
customer's balance must still cover the payout: credit it first (a trade
settled on your books), or the request is refused with insufficient_funds.
EVM chains and Tron only. Elsewhere: 422 strict_source_unsupported — send it
with source: "auto".
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.
Refunding
curl -X POST https://api.molnpay.com/v1/invoices/9f3c1b2e-…/refund \
-H "Authorization: Bearer $PAYOUT_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: refund_order_8812" \
-d '{"to_address":"0xPAYER…","reason":"order cancelled"}'Returns some or all of what a paid, overpaid or late invoice brought in,
in the coin it was paid in, to an address you name. Omit amount for
everything still refundable; give amount (atomic, as a string) and
decimals for a partial one, and repeat up to the bound. The invoice's
paid_in block says what that bound is right now.
A payout key, signing on, Idempotency-Key required. A refund is money
out to an address of your choosing, so it carries the payout gates — your
plan's payout fee from the prepaid balance, the approval threshold, the daily
line (past it the refund waits for our review; it is never refused for it) —
except the allowlist, because the destination is the payer's. 201 is a
promise: the refund is sent while the payout is queued or in flight.
invoice.refunded fires when it confirms on chain, and
invoice.refund_failed if it does not, with the amount back on your balance.
Once the whole receipt has gone back the invoice reads refunded.
Read them back under GET /v1/refunds (any key kind), including the
overpayment excess the payer directed from the checkout page.
4.7 Invoice knobs: tolerance, overpayment policy, charging the payer
Four optional fields on POST /v1/invoices tune what a payer owes and how
tight the payment band is, per invoice, at creation (they are frozen with the
price and never recomputed):
| Field | Default | What it does |
|---|---|---|
tolerance_bps | 50 | The under/overpayment band in basis points of the payer amount (0–500). A payment within the band of the payer amount is paid, not under or over. |
overpayment_policy | your account's | keep (record the excess) or refund (open a payer-directed refund) for THIS invoice. |
fee_to_payer_bps | 0 | Charge your processing fee to the payer: this many bps (0–2000) are added to what the payer sends — Cryptomus's subtract. |
markup_bps | 0 | A surcharge on the payer, bps of the price (0–2000), applied before fee_to_payer_bps. |
The invoice echoes the composed figure as payer_amount — what the
checkout asks the payer to send, priced into every quote — and
payer_fee_cents, the slice on top of your price_amount. price_amount
stays what YOU net. The settlement band centres on payer_amount, so a payer
who sends exactly what the page says is paid, never overpaid because a
fee sat on top. Omit every knob and the invoice behaves exactly as it did
before they existed.
curl -X POST https://api.molnpay.com/v1/invoices \
-H "Authorization: Bearer $CHECKOUT_KEY" -H "Content-Type: application/json" \
-d '{"price_amount":"5000","title":"Pro plan","tolerance_bps":100,
"overpayment_policy":"refund","fee_to_payer_bps":150}'
# → price_amount "5000", payer_amount "5075" (1.5% to the payer), ±$0.50 bandPer-invoice webhook_url is deliberately not offered — endpoints carry
secrets, replay protection and an audit trail, and every event goes to the
endpoints you manage under §9.