Start

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 — contract is a keyless smart account: the same address on every EVM chain marked contract, no private key anywhere. eoa is 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) and status_note, the operator's reason in their words. Show the note verbatim.
  • deposits_enabled at the chain level, and per asset under assets[]. 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 thatinvoice.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 thatinvoice.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 timeinvoice.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):

FieldDefaultWhat it does
tolerance_bps50The 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_policyyour account'skeep (record the excess) or refund (open a payer-directed refund) for THIS invoice.
fee_to_payer_bps0Charge your processing fee to the payer: this many bps (0–2000) are added to what the payer sends — Cryptomus's subtract.
markup_bps0A 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 band

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