Add crypto checkout to your site or app
MultiCoinPay is non-custodial: you create a payment from your server, send the customer to a hosted checkout, and receive a signed webhook the moment it clears. Funds settle straight to your own wallet. A basic integration is three calls.
Get your API key
Open the console, then API keys, and copy your key (it is shown once). Every server request authenticates with it:
Authorization: Bearer YOUR_API_KEY
Register your receive wallet first
In the console under Stores / Receive keys, register a watch-only extended public key (xpub) per chain. MultiCoinPay derives a fresh address per invoice from it and verifies address[0] against one you provide, so a typo cannot misroute funds. Supported today:
| Chain | Assets | How the customer is paid |
|---|---|---|
| base | USDC, USDT | Fresh EVM address per invoice |
| bitcoin | BTC | Fresh address per invoice |
| tron | USDT | Fresh address per invoice |
| solana | USDC | Your wallet + a per-invoice reference |
What to paste, per chain
Two fields per key: the extended public key (or, for Solana, the wallet address) and knownAddress — the address at index 0 that MultiCoinPay re-derives from your key and compares with yours. A mismatch is refused, so a mistyped key can never route funds elsewhere. Copy both from your own wallet. Paste the account-level key your wallet exports; a key exported one level down (the external chain, …/0) is also accepted and reaches the same first address.
| Chain | Extended pubkey / wallet | Format & derivation | knownAddress (index 0) | Where to get it |
|---|---|---|---|---|
| base | Account-level EVM xpub | xpub… at m/44'/60'/0' (invoices: …/0/N) | 0x… = m/44'/60'/0'/0/0 | Trezor Suite (Show public key), Ledger via a desktop wallet, or derived offline from your seed. MetaMask cannot export an xpub. |
| bitcoin | Native-segwit account key | zpub… at m/84'/0'/0' (xpub/ypub accepted; vpub/tpub on testnet at m/84'/1'/0') | bc1q… (tb1q… on testnet) | Sparrow (Settings → Keystore), Electrum (Wallet → Information), Trezor Suite, Ledger via Sparrow/Electrum. |
| tron | Account xpub | xpub… at m/44'/195'/0' (secp256k1, coin 195) | T… = index 0 | Hardware wallet with an app that shows the account key, or derived offline from your seed. TronLink cannot export it. |
| solana | Your wallet address | on-curve base58 pubkey, no derivation | the same address | Phantom, Solflare or Ledger. Not an exchange deposit address, not a program-derived address. |
xprv…, zprv…): the API refuses them, and no MultiCoinPay page will ever ask for one.Going live with real funds
- Register a key with
network: "mainnet"for each chain you accept (one per store). Testnet keys are active at once; a mainnet key stays pending until an operator approves it after confirming the address with you out of band. It cannot receive funds before that. - Create a small real invoice and confirm its address is yours. Every payment carries a
derivation_index— on the create response, or fromGET /v1/payments/{id}for an invoice leg. Re-derive that index from your own extended key in a wallet or library you already trust — Basem/44'/60'/0'/0/N, Bitcoinm/84'/0'/0'/0/N, Tronm/44'/195'/0'/0/N— and check it matches theaddresswe reported. Sparrow and Electrum list addresses by index;ethersandbitcoinjsderive one in a line. Solana has nothing to derive:derivation_indexisnullandaddressis a per-invoice reference, not your wallet — confirm instead that the transaction credited the wallet you registered. The point is to check with a tool other than MultiCoinPay. (Self-hosting operators running the gateway themselves can instead use the reference verifier in that repo:npx tsx scripts/verify-address.ts base xpub… 0 0x… mainnet.) - Confirm funds on-chain in your wallet before fulfilling the first orders, and verify the webhook signature on every event.
- Keep testnet and mainnet keys separate: a key is bound to the network it was registered for, and invoices on the other network refuse it.
Create a payment
From your server, POST the order amount in your own currency. Every create call must carry an Idempotency-Key header — without one the API answers 400 {"error":"Idempotency-Key header required"} — so a retry never creates a second invoice. Your own order id is the natural value, with one constraint: the key must not contain :: (reserved internally for invoice legs), which is also rejected with a 400. Replaying a key with the same parameters returns the original payment with a 200; the same key with different parameters is a 409. MultiCoinPay locks a quote, derives a receive address, and returns a hosted checkout_url.
curl -X POST https://multicoinpay.com/v1/payments \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: order-1001" \
-H "Content-Type: application/json" \
-d '{"chain":"base","asset":"USDC","amount":49.99,"currency":"USD",
"return_url":"https://shop.example.com/orders/1001"}'
Response:
{
"id": "9f3c1e04-5913-475e-831a-4affe4d45cea",
"status": "awaiting_payment",
"address": "0xa1b2c3d4...e5f6",
"derivation_index": 7,
"crypto_amount": "49990000",
"checkout_url": "https://multicoinpay.com/pay/9f3c1e04-5913-475e-831a-4affe4d45cea",
"expires_at": "2026-07-03T15:19:00.000Z"
}
The same call in Node (works in any server runtime with fetch):
const res = await fetch("https://multicoinpay.com/v1/payments", {
method: "POST",
headers: {
"Authorization": "Bearer " + process.env.MULTICOINPAY_API_KEY,
"Idempotency-Key": "order-" + orderId,
"Content-Type": "application/json",
},
body: JSON.stringify({
chain: "base", asset: "USDC", amount: 49.99, currency: "USD",
return_url: "https://shop.example.com/orders/" + orderId,
metadata: { order_id: orderId }, // echoed back on the webhook
// store_id: "...", // optional: bill a specific store
}),
});
const payment = await res.json();
| Field | Required | Notes |
|---|---|---|
| Idempotency-Key | yes | A request header, not a body field. Your own order id; must not contain ::. Missing or containing :: → 400; same key, same parameters → 200 with the original payment; same key, different parameters → 409 |
| chain, asset | both, or neither | Both = one fixed asset (see the table in Step 01). Neither = a multi-currency invoice the customer picks from (below). asset alone assumes chain: "base"; chain alone is a 400 |
| amount, currency | yes | Priced in fiat (USD, EUR, ...); MultiCoinPay locks the crypto amount |
| return_url | no | Absolute http(s) URL. Shown as a "return to store" link on every terminal status — confirmed, expired, failed, canceled and blocked — so point it at a neutral order page, never at a "thanks for your order" page |
| metadata | no | Any JSON; returned on the webhook |
| store_id | no | Bill a specific store (else your default store) |
| test | no | true to create a sandbox payment (Step 05) |
Let the customer pick the currency
Omit both chain and asset and you get a multi-currency invoice instead: MultiCoinPay quotes the same fiat amount on every asset your store has an active receive key for, and the hosted checkout shows a currency picker. The customer pays whichever leg they like; the rest are canceled as soon as one is paid in full, and no webhook fires for those.
curl -X POST https://multicoinpay.com/v1/payments \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: order-1002" \
-H "Content-Type: application/json" \
-d '{"amount":49.99,"currency":"USD",
"return_url":"https://shop.example.com/orders/1002"}'
The response is a different envelope — an invoice with one legs entry per payable option, and no top-level id, status, address or crypto_amount:
{
"invoice_id": "4b8e2f10-6c7a-4d51-9e02-1f7b55c3ad84",
"checkout_url": "https://multicoinpay.com/pay/4b8e2f10-6c7a-4d51-9e02-1f7b55c3ad84",
"expires_at": "2026-07-03T15:19:00.000Z",
"legs": [
{ "payment_id": "a1c9b2e7-...", "chain": "base", "asset": "USDC",
"address": "0xa1b2c3d4...e5f6", "crypto_amount": "49990000",
"decimals": 6, "locked_rate": "1.0001" },
{ "payment_id": "7d204f83-...", "chain": "bitcoin", "asset": "BTC",
"address": "bc1q...", "crypto_amount": "74231",
"decimals": 8, "locked_rate": "67312.40" }
]
}
Send the customer to checkout_url exactly as in Step 03 — it is the one page that offers all the legs.
payment_id is a leg id you never stored. Every payment.* payload carries data.invoice_id, so match your order on that; your metadata also rides on payment.confirmed, the event you fulfil on. Likewise GET /v1/payments/{id} takes a leg's payment_id, not the invoice_id — store the leg ids if you want to poll. Everything else is unchanged: amount, currency, return_url, metadata, store_id and test all behave as above.Send the customer to checkout
Redirect the customer to payment.checkout_url. The hosted page shows a QR, the exact amount, a live status, and clear underpaid / expired guidance, on any device. You do not build a payment UI.
On a website
// after creating the payment on your server: res.redirect(303, payment.checkout_url);
A minimal "Pay with crypto" button is just a form that hits your server, which creates the payment and redirects:
<form method="POST" action="/checkout/crypto"> <button type="submit">Pay with crypto</button> </form>
In a mobile app
Create the payment on your backend, then open checkout_url in an in-app browser (SFSafariViewController / Chrome Custom Tabs) or the system browser. Set return_url to your merchant HTTPS order page (for example https://shop.example.com/orders/1001) or an HTTPS app link handled by your app, so the checkout can bring the customer back after payment. The API accepts only absolute HTTP(S) return URLs; custom URL schemes are not supported. Do not embed the API key in the app.
Receive and verify the webhook
In the console under Webhook settings, set your endpoint URL and a signing secret. When a payment changes state, MultiCoinPay POSTs a JSON event and signs it. Treat only payment.confirmed as "money received".
Request body:
{ "type": "payment.confirmed", "payment_id": "9f3c1e04-...", "data": { ... } }
Header (Stripe-style, HMAC-SHA256 over t.body):
x-gateway-signature: t=1720021234,v1=5f8b...hex
Verify the signature over the raw body before trusting the event:
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
// IMPORTANT: use the raw body - the signature is over the exact bytes.
app.post("/multicoinpay/webhook", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("x-gateway-signature") || "";
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const t = parts.t, v1 = parts.v1 || "";
const raw = req.body.toString("utf8");
// Replay protection: reject a missing or non-numeric timestamp FIRST, then
// anything older than 5 minutes. Without the isFinite check a header with no
// t would pass here - Number(undefined) is NaN, and NaN > 300 is false.
const ts = Number(t);
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return res.sendStatus(400);
const expected = createHmac("sha256", process.env.MULTICOINPAY_WEBHOOK_SECRET)
.update(t + "." + raw)
.digest("hex");
const ok = expected.length === v1.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
if (!ok) return res.sendStatus(400);
const event = JSON.parse(raw); // { type, payment_id, data }
if (event.type === "payment.confirmed") {
markOrderPaid(event.payment_id); // idempotent: may arrive more than once
}
res.sendStatus(200); // 2xx = delivered; non-2xx retries with backoff
});
payment_id + type).Event types
| Event | Meaning |
|---|---|
| payment.confirmed | Paid and confirmed. Fulfil the order. |
| payment.underpaid | Partial payment seen; customer may top up. |
| payment.overpaid | Confirmed, but more than requested arrived. |
| payment.expired | The quote lapsed before payment. |
| payment.paid_after_expiry | Funds arrived after expiry; reconcile manually. |
| payment.failed / .canceled / .blocked | Failed, canceled, or blocked by screening. |
Test without real funds
Create a payment with "test": true. It is not watched on-chain; instead you drive its whole lifecycle from the console with the Simulate buttons — confirm, underpay, overpay, expire, paid_after_expiry, block and fail. Each action fires the same webhook your production code will receive, so you can verify your integration end to end before going live — including paid_after_expiry, the one case that needs manual reconciliation.
The same seven actions are available from your own test suite: POST /v1/payments/{payment_id}/simulate with {"event":"confirm"}. For a multi-currency invoice, target the payment_id of the leg you want to pay, not the invoice_id.
Before you go live
- Create payments from your server only. The API key never reaches the browser or app.
- Verify the
x-gateway-signatureon every webhook, over the raw body, and reject stale timestamps. - Fulfil only on
payment.confirmed. Make the handler idempotent. - Send an
Idempotency-Keyon every create so retries never double-charge. - Register your receive wallet and confirm the derived address is yours.
Full endpoint reference: API docs and OpenAPI. Ready to build? Open the console.