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 belowWhich 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
Acme Co.
Pro plan — Monthly
$49.00 USD
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.
| Event | detail | Fires when |
|---|---|---|
checkout-created | the 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 }).
A full invoice document — organization name, invoice number, status, line items, and total — with a Pay action embedded automatically once the invoice is open or overdue.
Install mooneum-js
Same package as Buttons — skip this if it's already on the page.
npm install mooneum-jsEmbed the invoice
<mooneum-invoice invoice-id="inv_…" api-url="https://api.mooneum.com"></mooneum-invoice>
<script type="module">
import 'mooneum-js';
</script>It reads from the same getPayable resolution Server-side uses, so
there's nothing extra to mint or keep in sync — an Invoice id is already a valid token everywhere
else in this SDK, including directly in <mooneum-pay-button>.
Listen for the embedded button's payment-received event if you need to
react elsewhere on the page once payment completes — e.g. updating a surrounding order-history
list.
Everything the widgets do client-side, callable directly — for teams building their own checkout UI from scratch.
Resolve what's payable
import { HostedCheckoutClient } from '@mooneum/sdk';
const checkout = new HostedCheckoutClient({
baseUrl: 'https://api.mooneum.com',
});
const payable = await checkout.getPayable(token);
// -> { name, description, amount, currency, isActive }None of these calls require an API key — the token itself is the credential, same as the components.
Start a checkout attempt
const attempt = await checkout.startCheckout(token, {
method: 'CRYPTO',
chain: 'ethereum',
});
// -> { token: checkoutToken, clientSecret?, publishableKey? }method is 'CRYPTO' or 'STRIPE' — pass chain for crypto to steer which network the
customer pays on.
Poll for completion
const state = await checkout.getCheckout(attempt.token);
// -> { status, amount, assetSymbol, receiveAddress, paymentUri }
await checkout.checkPaid(attempt.token); // force an immediate on-chain check
await checkout.refreshCheckout(attempt.token); // re-quote an expired attemptThese live on the SDK's HostedCheckoutClient. See the API reference for the
full Payment Links, Invoices, and Orders request/response shapes.
Payment-specific events
For a durable, server-to-server record of payment activity, use
webhooks — the general webhook guide covers registration, signature
verification, and delivery mechanics. Subscribe to payment.received (funds detected at the
receive address) and payment.settled, plus the shared order.* lifecycle every vertical
publishes (order.submitted, order.approved, order.confirmed, etc. — a Payment
Link/Invoice checkout is an Order under the hood). See the
Events reference for the complete, always-current list and payload
shapes.
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 scanEquivalently: 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.
| Prefix | Script type | Derivation |
|---|---|---|
xpub / tpub | Legacy (P2PKH) | BIP44 |
ypub / upub | Nested SegWit (P2SH-P2WPKH) | BIP49 |
zpub / vpub | Native 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.