Skip to content

REST API

Everything is JSON. Two audiences, and the split matters:

  • Public — the customer's browser calls these while paying. No secret. None of them can change what anything costs. On a multi-tenant deployment the merchant is named by the publishable key (0billing-publishable-key header or ?pk=); a deployment with exactly one merchant may omit it.
  • Staff — defines what things cost, scoped to the caller's merchant. Authorization: Bearer <sk_…> (the merchant's secret key — rotate it any time on the Integrate page), or a signed-in dashboard session carrying a team role.

CORS is on for every origin: the checkout widget runs on your page and calls the public routes from there. Nothing authenticates with cookies (public routes carry the publishable key, staff routes a bearer key), so * costs nothing. Self-hosting for one known frontend? billingRoutes takes cors: { origin: "https://app.example" }, or cors: false.

Errors are { "error": "<message>" } with 400 for bad input, 401 for missing admin auth, 404 for unknown ids. Messages are written to be safe to show a customer.


Public

GET /v1/assets

What a payer can pay with — every asset the router can source, right now.

jsonc
{ "assets": [
  { "assetId": "nep141:btc.omft.near", "chain": "btc", "symbol": "BTC",
    "decimals": 8, "priceUsd": 64210.5, "coingeckoId": "bitcoin" }
] }

Live from the router, so it reflects what's actually swappable rather than a list we maintain.

GET /v1/products

The catalog. Products and prices in one call; only active entries.

jsonc
{ "products": [ { "id": "prod_pro", "name": "Pro plan", "active": true,  } ],
  "prices":   [ { "id": "price_monthly", "productId": "prod_pro", "amountUsd": 20,
                  "interval": "month", "intervalCount": 1, "active": true,  } ] }

?all=true includes archived entries — admin only, ignored for anyone else.

GET /v1/checkout/quote

What a customer owes, before committing to anything.

Query
priceIdrequired
quantitydefault 1
codediscount code, optional
jsonc
{ "quote": { "subtotalUsd": 200, "discountUsd": 40, "totalUsd": 160,
             "grants": { "interval": "month", "count": 12, "quantity": 1 } } }

An unusable code is a 400 explaining why (expired, fully redeemed, wrong product) — show it as-is.

POST /v1/checkout

Reserve a one-time deposit address.

jsonc
{
  "customerId": "cus_1029",        // required — yours, echoed on the webhook
  "originAssetId": "nep141:…",     // required — from GET /v1/assets
  "refundTo": "bc1q…",             // required — payer's address on the origin chain
  "priceId": "price_yearly",       // strongly preferred
  "quantity": 1,
  "promoCode": "LAUNCH20",
  "metadata": { "orderId": "…" },
  "amountUsd": 49                  // only honoured when no priceId is given
}
jsonc
{ "payment": {
  "id": "pay_4d2a…", "status": "pending", "amountUsd": 160,
  "origin": { "assetId": "…", "chain": "btc", "symbol": "BTC",
              "amount": "0.00249", "depositAddress": "bc1q…" },
  "treasury": { "chain": "sol", "asset": "USDC", "address": "…" },
  "createdAt": "…", "expiresAt": "…" } }

Show origin.amount and origin.depositAddress; the address belongs to this payment alone, so an exchange withdrawal identifies itself.

refundTo must be valid on the origin chain — it's where funds go if the swap fails. A 100%-off price returns a confirmed payment immediately, with no deposit address to use.

GET /v1/payments/:id

Current state, and the call a checkout UI polls.

jsonc
{ "payment": { "id": "pay_4d2a…", "status": "detected",  } }

Not a passive read: it asks the router where things stand and persists any change, so an open checkout tab drives its own payment forward. Terminal payments are returned as-is. Poll every few seconds; the bundled component uses 4s.

GET /v1/receipts/:paymentId

The receipt for a confirmed payment — print-first HTML, or the raw snapshot with ?format=json. Public by the same capability logic as the payment itself; 404 until the payment confirms. Old confirmed payments are backfilled on first request.

GET /v1/codes/:code

Look up a discount before applying it. Returns only the discount — never redemption counts or limits.

jsonc
{ "code": { "code": "LAUNCH20", "percentOff": 20 } }

404 if unknown or inactive.


Admin

GET /v1/payments

The filtered, paginated list behind the dashboard's payments table. All parameters compose.

Query
qexact match on payment id / tx id / deposit address; substring on customer id and link reference
statusone of the payment statuses
customerIdfilter to one customer
chainorigin chain slug (what the payer paid from)
from / toISO timestamps, inclusive
minUsd / maxUsdamount range
linkIdpayments collected through one payment link
cursoropaque, from the previous page's nextCursor
limitdefault 50, max 200
jsonc
{ "payments": [  ], "nextCursor": "…" }   // newest first; nextCursor only while more pages exist

Cursor pagination is positional (created_at, id), so pages stay stable while new payments arrive — no offset drift.

GET /v1/payments/export.csv

The same filters, as a CSV download (one row per payment, including origin, settlement, tx id and link reference). Capped at 10,000 rows — narrow the date range beyond that.

POST /v1/payments/:id/refund

Refund a confirmed payment, in full or part. See refunds for the custody and currency model — in short: USD-denominated, delivered in the asset the customer paid with, funded by one treasury transfer the merchant signs.

jsonc
{
  "amountUsd": 5,            // optional — defaults to everything still refundable
  "toAddress": "bc1q…"       // optional — defaults to the payer's checkout refund address
}
jsonc
{ "refund": {  } }          // see the Refund object below

Partial refunds stack until the payment is fully returned; failed and expired attempts don't count against the cap. Refunding more than the remainder is a 400.

GET /v1/refunds

Query
paymentIdfilter to one payment
limitdefault 50

GET /v1/refunds/:id

One refund. Polling this drives it forward, mirroring GET /v1/payments/:id.

POST /v1/merchants

Self-serve signup: any signed-in person founds a merchant and becomes its admin. Body { "name": "Acme Inc." }. Returns the merchant plus its secret key — shown exactly once; only a hash is stored.

jsonc
{ "merchant": { "id": "mer_…", "name": "Acme Inc.", "publishableKey": "pk_…" },
  "secretKey": "sk_…" }

GET /v1/merchant

The caller's merchant (including the publishable key) and its webhook config.

POST /v1/merchant/keys/rotate

Invalidates the current secret key immediately and returns a new one — shown once. Admin only.

PUT /v1/merchant/webhook

Where this merchant's payment.* / refund.* events land. Body { "url": "https://…", "secret": "whsec_…" }. Admin only.

GET /v1/treasury · PUT /v1/treasury

Where payments settle — the merchant's own wallet. PUT validates that the router can actually deliver that asset on that chain before saving:

jsonc
{ "chain": "sol", "asset": "USDC", "address": "9m3F…" }
jsonc
{ "treasury": { "chain": "sol", "asset": "USDC", "address": "9m3F…" } }

Changing it affects payments created from then on. Anything already quoted — payments and refunds alike — settles where it was quoted.

POST /v1/products

jsonc
{ "name": "Pro plan", "description": "…", "id": "prod_pro", "metadata": {} }

name required. Supplying id makes seeding idempotent — writes are upserts.

POST /v1/prices

jsonc
{ "productId": "prod_pro", "amountUsd": 20,
  "interval": "month", "intervalCount": 12, "billedUnits": 10 }

productId and amountUsd required; the product must exist. interval defaults to once, intervalCount to 1, billedUnits to intervalCount. See catalog & pricing for what those combinations express.

POST /v1/codes

jsonc
{ "code": "LAUNCH20", "percentOff": 20, "maxRedemptions": 100,
  "productId": "prod_pro", "expiresAt": "2026-12-31T23:59:59Z" }

code plus exactly one of percentOff / amountOffUsd. Codes are upper-cased on write.

GET /v1/codes

Every code, including redemption counts.

DELETE /v1/products/:id · DELETE /v1/prices/:id

Archive (active: false), never delete — payments reference them. Archiving a product archives its prices too.

jsonc
{ "archived": "price_monthly" }

DELETE /v1/codes/:code

Actually deletes. A payment records the code string it used, so nothing dangles. Already-granted entitlements are unaffected.

jsonc
{ "deleted": "LAUNCH20" }

Payment object

Field
idpay_…
customerIdyours, from checkout
amountUsdwhat was owed, after discount
statuspending · detected · confirmed · expired · failed · refunded
detailrouter-supplied context, when there is any
origin{ assetId, chain, symbol, amount, amountAtomic, contractAddress, depositAddress, refundTo } — what the payer sends
treasury{ chain, asset, address } — where it lands
settleAmountwhat the treasury receives, in its own units. Depends on who pays the overhead (see below)
priceId, quantity, promoCodewhat was bought
metadatayours, plus grantInterval / grantCount
txIdsettlement transaction, once confirmed
createdAt, expiresAt, confirmedAtISO timestamps

The last four statuses are terminal: they never change again, and each fires exactly one webhook.

Refund object

Field
idref_…
paymentIdthe payment being refunded — always confirmed, never mutated
customerIdcopied from the payment
amountUsdwhat the customer gets back, in USD terms
statusawaiting_funds · detected · completed · expired · failed
source{ chain, asset, amount, amountAtomic, contractAddress, depositAddress } — what the merchant sends from the treasury
destination{ assetId, chain, symbol, amount, address } — what the customer receives
txIddelivery transaction, once completed
createdAt, expiresAt, completedAtISO timestamps

completed, expired and failed are terminal. failed means the swap bounced and the treasury got its money back — nothing reached the customer, and the amount becomes refundable again.

0billing — crypto billing for your app · pay from any chain, settle in one