MultiCoinPay
Integration guide

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.

Create payment (server)-> Send customer to checkout_url-> Verify payment.confirmed webhook
STEP 01

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
Keep the key server-side. It can create payments and read your account. Never ship it in browser or mobile-app code. Create payments from your backend only.

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:

ChainAssetsHow the customer is paid
baseUSDC, USDTFresh EVM address per invoice
bitcoinBTCFresh address per invoice
tronUSDTFresh address per invoice
solanaUSDCYour 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.

ChainExtended pubkey / walletFormat & derivationknownAddress (index 0)Where to get it
baseAccount-level EVM xpubxpub… at m/44'/60'/0' (invoices: …/0/N)0x… = m/44'/60'/0'/0/0Trezor Suite (Show public key), Ledger via a desktop wallet, or derived offline from your seed. MetaMask cannot export an xpub.
bitcoinNative-segwit account keyzpub… 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.
tronAccount xpubxpub… at m/44'/195'/0' (secp256k1, coin 195)T… = index 0Hardware wallet with an app that shows the account key, or derived offline from your seed. TronLink cannot export it.
solanaYour wallet addresson-curve base58 pubkey, no derivationthe same addressPhantom, Solflare or Ledger. Not an exchange deposit address, not a program-derived address.
Public keys only. Never paste a seed phrase or a private key (xprv…, zprv…): the API refuses them, and no MultiCoinPay page will ever ask for one.

Going live with real funds

  1. 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.
  2. Create a small real invoice and confirm its address is yours. Every payment carries a derivation_index — on the create response, or from GET /v1/payments/{id} for an invoice leg. Re-derive that index from your own extended key in a wallet or library you already trust — Base m/44'/60'/0'/0/N, Bitcoin m/84'/0'/0'/0/N, Tron m/44'/195'/0'/0/N — and check it matches the address we reported. Sparrow and Electrum list addresses by index; ethers and bitcoinjs derive one in a line. Solana has nothing to derive: derivation_index is null and address is 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.)
  3. Confirm funds on-chain in your wallet before fulfilling the first orders, and verify the webhook signature on every event.
  4. 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.
STEP 02

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();
FieldRequiredNotes
Idempotency-KeyyesA 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, assetboth, or neitherBoth = 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, currencyyesPriced in fiat (USD, EUR, ...); MultiCoinPay locks the crypto amount
return_urlnoAbsolute 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
metadatanoAny JSON; returned on the webhook
store_idnoBill a specific store (else your default store)
testnotrue 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.

Reconciling an invoice. Webhooks fire on the leg the customer paid, so the event's 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.
STEP 03

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.

STEP 04

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
});
Deliveries retry with exponential backoff and then dead-letter. You can inspect and resend any delivery from the console. Make your handler idempotent (key off payment_id + type).

Event types

EventMeaning
payment.confirmedPaid and confirmed. Fulfil the order.
payment.underpaidPartial payment seen; customer may top up.
payment.overpaidConfirmed, but more than requested arrived.
payment.expiredThe quote lapsed before payment.
payment.paid_after_expiryFunds arrived after expiry; reconcile manually.
payment.failed / .canceled / .blockedFailed, canceled, or blocked by screening.
STEP 05

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.

CHECKLIST

Before you go live

  • Create payments from your server only. The API key never reaches the browser or app.
  • Verify the x-gateway-signature on every webhook, over the raw body, and reject stale timestamps.
  • Fulfil only on payment.confirmed. Make the handler idempotent.
  • Send an Idempotency-Key on 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.