Skip to content

Catalog & pricing

Three objects, in Stripe's shape:

  • Product — a thing you sell ("Pro plan").
  • Price — what it costs, and for how long. A product can have several.
  • Promo code — a discount off a price.

Checkout references a price id, never an amount. That's the whole reason the catalog exists server-side: what a customer owes is derived from data only you can write, so a modified browser can't understate it.

Products

bash
curl -X POST $BASE/v1/products \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"name":"Pro plan","description":"Everything, monthly"}'
jsonc
{ "product": { "id": "prod_7f3…", "name": "Pro plan", "active": true, "metadata": {}, "createdAt": "…" } }

Pass your own id to make seeding idempotent ("id": "prod_pro") — writes are upserts. metadata is yours; it isn't interpreted.

Prices

A price is a unit amount, an interval, and how many of that interval one purchase covers.

FieldMeaning
amountUsdUnit price in USD
intervalonce · day · week · month · year
intervalCountHow many intervals the purchase grants
billedUnitsHow many intervals it bills for (defaults to intervalCount)

The total is amountUsd × billedUnits × quantity, and what the customer gets is intervalCount × quantity intervals. Splitting "granted" from "billed" is what makes annual discounts a price rather than a special case:

jsonc
// $20/month
{ "productId": "prod_pro", "amountUsd": 20, "interval": "month", "intervalCount": 1 }

// A year for the price of ten months — $200, grants 12 months
{ "productId": "prod_pro", "amountUsd": 20, "interval": "month", "intervalCount": 12, "billedUnits": 10 }

// One-off, e.g. a credit pack
{ "productId": "prod_credits", "amountUsd": 49, "interval": "once", "intervalCount": 1 }

The grant travels to your app in the payment's metadata as grantInterval and grantCount, so your webhook handler never has to re-derive it:

ts
const until = addMonths(now, Number(payment.metadata.grantCount));

Quantity

quantity multiplies both the amount and the grant — seats, licences, packs. It must be a positive integer.

Discount codes

bash
curl -X POST $BASE/v1/codes \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"code":"LAUNCH20","percentOff":20,"maxRedemptions":100}'
FieldMeaning
percentOff1–100. Mutually exclusive with amountOffUsd
amountOffUsdFlat USD off, clamped so a total never goes negative
productIdRestrict to one product; omit to allow anywhere
maxRedemptionsTotal uses across all customers
expiresAtISO timestamp
activeSet false to retire it without deleting

Codes are upper-cased on write and looked up case-insensitively, so launch20 in a checkout field matches LAUNCH20.

Redemption is counted at confirmation, not at checkout. An abandoned checkout doesn't burn a use — but it also means a code near its limit can be started by more people than can finish. Set maxRedemptions with that in mind.

GET /v1/codes/:code is public (so a checkout can show the discount before committing) and returns only the discount itself — never redemption counts or limits.

Comps

A 100%-off code takes the total to zero, and a zero-total payment has nothing to swap. Those are confirmed immediately, with comped: "true" in metadata and a txId of comp:<CODE>, and they fire payment.confirmed like any other. Your entitlement code needs no special case; your revenue reporting might.

Archiving

Nothing in the catalog is deletable, because payments reference it:

bash
curl -X DELETE $BASE/v1/prices/price_monthly -H "Authorization: Bearer $KEY"
curl -X DELETE $BASE/v1/products/prod_pro   -H "Authorization: Bearer $KEY"   # archives its prices too

Archiving sets active: false. Archived entries disappear from the public catalog and can't be checked out, while old payments still resolve their price. Admins can still see them with GET /v1/products?all=true.

Discount codes are deletable (DELETE /v1/codes/:code) — a payment records the code string it used, so nothing dangles. Deleting one doesn't refund or revoke anything already granted.

Using the pricing math directly

@0billing/core exposes it as pure functions, no server required — useful for showing prices on a marketing page or testing your own tiers:

ts
import { quotePrice, validateCode } from "@0billing/core";

quotePrice(yearlyPrice, 1, launch20);
// { subtotalUsd: 200, discountUsd: 40, totalUsd: 160,
//   grants: { interval: "month", count: 12, quantity: 1 } }

validateCode throws a CatalogError explaining exactly why a code doesn't apply — expired, fully redeemed, wrong product — which is safe to show a customer.

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