MolnPayDocs
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_kindcontract 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.

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.

On this page