MOONEUM
Business crypto payments portal

Pay API

Accept crypto and card payments with as little or as much of your own UI as you want, plus Bitcoin wallets that hand out a fresh address per invoice.

Accepting payments

Every integration starts the same way — mint a token, then resolve it with whichever path below fits how much UI you want to own. The token is a Payment Link's publicToken (reusable indefinitely, mints a fresh Order on every visit) or an Invoice's own id (reuses its pending Order if one exists), so switching paths later doesn't change how you generate or store it.

import { TreasuryClient } from '@mooneum/sdk';

const client = new TreasuryClient({
  baseUrl: 'https://api.mooneum.com',
  token: 'sk_…',
});
const link = await client.createPaymentLink({ priceId: 'price_…' });
// link.publicToken -> pass to whichever path you pick below

Which one should I use?

No UI to build? Use Buttons — a drop-in widget, live in minutes. Sending a bill? Use Invoices — a full invoice document with Pay built in. Building your own checkout? Use Server-side — the same calls the widgets use, with no UI opinions at all.

The drop-in entry point — same idea as a Stripe Buy Button. Give it a token and it handles method selection, checkout, and confirmation in one embed. It never sees your sk_… key; you mint a token server-side and pass only that to the browser.

Install mooneum-js

npm install mooneum-js
<script type="module">
  import 'mooneum-js';
</script>

Drop in the widget and configure it

Try the options below — the snippet and preview update to match:

Display

Payable via

<mooneum-pay-button
  payment-link-id="pl_token_…"
  api-url="https://api.mooneum.com"
></mooneum-pay-button>

<script type="module">
  import 'mooneum-js';
</script>

Preview — not a live payment

↓ opens in a modal
AC

Acme Co.

Pro plan — Monthly

$49.00 USD

BTC
ETH
USDC

token accepts either a Payment Link's publicToken or an Invoice id — both resolve the same way (or use the equivalent payment-link-id/invoice-id attributes shown above, if that's clearer for your integration). api-url should point at the Mooneum API your organization is on.

By default <mooneum-pay-button> renders a small "Pay Now" button that opens checkout in a modal. The card attribute renders the checkout panel inline instead — no button, no modal, useful for a hosted paylink/invoice page. Under the hood it mounts <mooneum-pay>, the lower-level checkout widget — use that directly only if you're building your own trigger UI around it.

The widget owns its entire lifecycle — asset/method selection through payment confirmation — without any page navigation. Reloading the page just re-resolves the token and (for an in-progress crypto attempt) reuses it rather than minting a new one.

Events

<mooneum-pay>, <mooneum-pay-button>, <mooneum-invoice>, and the wallet-connect components dispatch CustomEvents on themselves (they bubble and are composed, so a listener on document or any ancestor catches them too) — useful for things a webhook can't do, like updating the host page's own UI the instant checkout completes, without waiting on a server-to-server delivery.

EventdetailFires when
checkout-createdthe created checkout attempt (includes its token)the customer picks a payment method and an attempt is minted — informational; the widget keeps working without you handling this
payment-received{ token }polling detects the checkout has moved to PAID
checkout-opened / checkout-closed—the <mooneum-pay-button> dialog opens or closes (not fired in card mode, and not fired by <mooneum-pay> used standalone)
mooneum-error—reserved for widget-level errors; today errors surface in the widget's own UI rather than this event
document.addEventListener('payment-received', (e) => {
  const { token } = (e as CustomEvent).detail;
  // e.g. redirect to an order-confirmation page
});

If you also use the wallet-connect components (<mooneum-connect>, <mooneum-submit>) directly — <mooneum-pay> uses them internally for its "pay with a connected wallet" option — they dispatch their own events on document: wallet-connected (detail: { address, chainId, walletType }), wallet-disconnected, wallet-connect-closed, account-changed (detail: { address, chainId }), chain-changed (detail: { chainId }), and on the submit button itself: tx-submitted (detail: { txHash }), tx-confirmed (detail: { txHash, receipt }), tx-error (detail: { error, code }).

Bitcoin HD wallets

Every other chain tracks one fixed, reused address per wallet. Bitcoin doesn't work that way — address reuse is discouraged, and accepting payments means handing out a fresh address per invoice from a deterministic key tree (BIP32). Import an extended public key (xpub/ypub/zpub) instead of a single address, and the API derives and watches addresses from it on demand.

Watch-only

Only public addresses are ever derived. No private key is generated, stored, or transmitted anywhere in this flow — signing/broadcasting a Bitcoin payout is a separate, unrelated capability this import does not grant.

Import an xpub wallet

import { TreasuryClient } from '@mooneum/sdk';

const client = new TreasuryClient({
  baseUrl: 'https://api.mooneum.com',
  token: 'sk_…',
});
const wallet = await client.importWalletXpub({
  app: 'payments',
  name: 'BTC receiving wallet',
  chain: 'bitcoin',
  xpub: 'zpub6…',
});
// wallet.addresses -> already-used addresses discovered by the initial scan

Equivalently: POST /api/v1/accounts/wallets/xpub with the same body.

On import, the API scans forward from index 0 on both the receive and change branches, checking each derived address for on-chain/mempool activity, and stops once it hits 20 consecutive unused addresses — the standard BIP44 "gap limit." Every address it finds used becomes a normal WalletAddress, synced and balance-tracked exactly like any pasted address. A background job re-scans periodically afterward, so funds sent to a derived address outside this API's own issuance are still picked up.

PrefixScript typeDerivation
xpub / tpubLegacy (P2PKH)BIP44
ypub / upubNested SegWit (P2SH-P2WPKH)BIP49
zpub / vpubNative SegWit (P2WPKH)BIP84

The script type is inferred from the prefix automatically — pass scriptType explicitly only to override it. Taproot isn't supported yet: BIP86 reuses the plain xpub prefix, so it can't be distinguished from legacy without an explicit flag this API doesn't accept yet.

No xpub to export? Import a single address instead

Some wallets — including MetaMask's built-in Bitcoin wallet — only ever expose one receiving address, with no xpub to export. Import that address the same way you'd import any other chain's address (chain: 'bitcoin', no xpub field). It works as a payment method the same way — the difference is that one address is reused for every invoice, the same as every other chain, instead of a fresh address per invoice.

Register it as a payment method

The walletId from the import response is what you pass, not an address — and which id you pass depends on which import path you used above:

const methods = await client.listPaymentMethods();
const crypto = methods.find((m) => m.type === 'CRYPTO');

// xpub wallet:
await client.addPaymentMethodWallet(crypto.id, {
  walletXpubId: wallet.xpubs[0].id,
});

// single-address wallet:
await client.addPaymentMethodWallet(crypto.id, {
  walletAddressId: wallet.addresses[0].id,
});

Accept payments as usual

From here, checkout works exactly like any other chain — startCheckout(token, { method: 'CRYPTO', chain: 'bitcoin' }) or <mooneum-pay-button>. The difference is invisible to the payer: instead of reusing one static address, each invoice is minted its own never-before-used address, derived on demand from the xpub. Two invoices from the same wallet always get two different addresses.

If invoices are minted faster than they're paid, the API won't derive further than 20 addresses past the last one it's confirmed received funds — the same gap-limit rule a recovery wallet follows — and instead reissues the oldest still-unpaid address rather than risk deriving an address a standard wallet recovery would never scan far enough to find.

Raising the gap limit

Pass gapLimit (20–200) on import, or call updateWalletXpubGapLimit(walletId, xpubId, gapLimit) on an already-imported wallet, if your invoice volume genuinely outpaces 20 unpaid invoices at once. Whatever value you set here must match the gap limit configured in your own wallet software, or that wallet may fail to discover funds this API has tracked beyond its own (lower) scan range. Most integrations never need this.

See the API reference for the full Accounts and Payment Methods request/response shapes.