Appearance
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-keyheader 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 | |
|---|---|
priceId | required |
quantity | default 1 |
code | discount 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 | |
|---|---|
q | exact match on payment id / tx id / deposit address; substring on customer id and link reference |
status | one of the payment statuses |
customerId | filter to one customer |
chain | origin chain slug (what the payer paid from) |
from / to | ISO timestamps, inclusive |
minUsd / maxUsd | amount range |
linkId | payments collected through one payment link |
cursor | opaque, from the previous page's nextCursor |
limit | default 50, max 200 |
jsonc
{ "payments": [ … ], "nextCursor": "…" } // newest first; nextCursor only while more pages existCursor 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 belowPartial 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 | |
|---|---|
paymentId | filter to one payment |
limit | default 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 | |
|---|---|
id | pay_… |
customerId | yours, from checkout |
amountUsd | what was owed, after discount |
status | pending · detected · confirmed · expired · failed · refunded |
detail | router-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 |
settleAmount | what the treasury receives, in its own units. Depends on who pays the overhead (see below) |
priceId, quantity, promoCode | what was bought |
metadata | yours, plus grantInterval / grantCount |
txId | settlement transaction, once confirmed |
createdAt, expiresAt, confirmedAt | ISO timestamps |
The last four statuses are terminal: they never change again, and each fires exactly one webhook.
Refund object
| Field | |
|---|---|
id | ref_… |
paymentId | the payment being refunded — always confirmed, never mutated |
customerId | copied from the payment |
amountUsd | what the customer gets back, in USD terms |
status | awaiting_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 |
txId | delivery transaction, once completed |
createdAt, expiresAt, completedAt | ISO 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.