Appearance
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.
| Field | Meaning |
|---|---|
amountUsd | Unit price in USD |
interval | once · day · week · month · year |
intervalCount | How many intervals the purchase grants |
billedUnits | How 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}'| Field | Meaning |
|---|---|
percentOff | 1–100. Mutually exclusive with amountOffUsd |
amountOffUsd | Flat USD off, clamped so a total never goes negative |
productId | Restrict to one product; omit to allow anywhere |
maxRedemptions | Total uses across all customers |
expiresAt | ISO timestamp |
active | Set 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 tooArchiving 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.