One-time purchases and payment links
Not everything you sell is a subscription. A setup fee, a workshop or a credit pack is paid once. tmbilling sells it as a purchase: the buyer pays, and the result is a paid invoice with no subscription behind it. This recipe creates a one-time product, sells it three ways, and charges a customer you already know.
Before you begin
Do the Quickstart first: you need a test API key in
$TMBILLING_API_KEY, your base URL in $TMBILLING_URL, and the client set up:
import { Tmbilling } from "@two-minute-billing/sdk";
const tmb = new Tmbilling({
apiKey: process.env.TMBILLING_API_KEY!,
baseUrl: process.env.TMBILLING_URL,
});
The client does not wrap checkout sessions or payment links yet, so the TypeScript examples call those endpoints with this small helper:
async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`${process.env.TMBILLING_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.TMBILLING_API_KEY}`,
...(body ? { "Content-Type": "application/json" } : {}),
...(method === "GET" ? {} : { "Idempotency-Key": crypto.randomUUID() }),
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(await res.text());
return (await res.json()) as T;
}
Create a one-time product and price
A one-time product has kind: "charge", and its price has type: "one_time" and no interval.
const biz = await tmb.businesses.create({ name: "Fernhill Analytics", default_currency: "USD" });
const product = await tmb.products.create(biz.id, {
name: "Onboarding workshop",
kind: "charge",
});
const price = await tmb.prices.create(biz.id, {
product: product.id,
name: "Onboarding workshop USD",
model: "flat",
type: "one_time",
currency: "USD",
amount_minor: 49900,
});
curl -X POST $TMBILLING_URL/v1/businesses \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "name": "Fernhill Analytics", "default_currency": "USD" }'
export BIZ=biz_…
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/products \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "name": "Onboarding workshop", "kind": "charge" }'
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/prices \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"product": "onboarding-workshop",
"name": "Onboarding workshop USD",
"model": "flat",
"type": "one_time",
"currency": "USD",
"amount_minor": 49900
}'
{
"object": "price",
"id": "onboarding-workshop-usd",
"product": "onboarding-workshop",
"name": "Onboarding workshop USD",
"type": "one_time",
"model": "flat",
"currency": "USD",
"amount_minor": 49900,
"interval": null
}
Sell it through a hosted checkout
Create a checkout session with mode: "purchase" and send the buyer to its url. The hosted page
collects their details and card, takes the payment, and returns them to your redirect_url.
const session = await api<{ id: string; url?: string }>(
"POST",
`/v1/businesses/${biz.id}/checkout_sessions`,
{
mode: "purchase",
items: [{ price: price.id, quantity: 1 }],
redirect_url: "https://example.com/thanks",
},
);
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/checkout_sessions \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"mode": "purchase",
"items": [{ "price": "onboarding-workshop-usd", "quantity": 1 }],
"redirect_url": "https://example.com/thanks"
}'
{
"object": "checkout_session",
"id": "cosess_XXXXXXXXXXXXXXXXXXXXXX01",
"mode": "purchase",
"redirect_url": "https://example.com/thanks",
"token": "cstk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"url": "https://billing.example.com/checkout/s/cstk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"expires_at": "2026-10-21T19:10:46.136Z"
}
The token and the url that carries it are returned once, when you create the session, and never
again. The session expires at expires_at, 24 hours later by default. Create a new session for each buyer.
Checkout pages, payment links and the pricing page are served from your dashboard's host, shown here
as billing.example.com. To sell to a customer you already have, add
"customer_id": "cus_…": the purchase is billed to them and their saved details stay theirs.
A purchase takes one-time prices only. Coupons and metadata are not accepted in purchase mode.
Share a payment link
A payment link is a checkout you create once and share anywhere: an email, a button on your site, a chat message. Each buyer who opens it gets their own checkout.
const link = await api<{ id: string; url: string | null }>(
"POST",
`/v1/businesses/${biz.id}/payment_links`,
{
name: "Onboarding workshop",
items: [{ price: price.id, quantity: 1 }],
show_on_pricing_page: true,
redirect_url: "https://example.com/thanks",
},
);
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/payment_links \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"name": "Onboarding workshop",
"items": [{ "price": "onboarding-workshop-usd", "quantity": 1 }],
"show_on_pricing_page": true,
"redirect_url": "https://example.com/thanks"
}'
export PLINK=plink_…
{
"object": "payment_link",
"id": "plink_XXXXXXXXXXXXXXXXXXXXXX01",
"name": "Onboarding workshop",
"active": true,
"mode": "purchase",
"show_on_pricing_page": true,
"url": "https://billing.example.com/buy/plink_XXXXXXXXXXXXXXXXXXXXXX01",
"items": [
{
"price": "onboarding-workshop-usd",
"quantity": 1,
"min_quantity": null,
"max_quantity": null,
"optional": false
}
],
"opens": 0,
"completions": 0
}
mode follows the prices: one-time prices only make a purchase link. A link with a recurring
plan starts a subscription. opens and completions count the checkouts the link started and how
many were paid.
To let the buyer select a quantity, give the item min_quantity and max_quantity. To make a line
optional, set optional: true. Quantities need a per_unit price: a flat price is one fixed
amount, so its quantity cannot change.
Show it on your pricing page
Every business has a public pricing page that lists its active links with
show_on_pricing_page: true. Buyers open it at /pricing/{biz} on the same host as your payment
links. To build your own page instead, read the same data as JSON. This endpoint is public: it
needs no API key, so call it from your website.
const pricing = await fetch(`${process.env.TMBILLING_URL}/checkout/v1/pricing/${biz.id}`);
if (!pricing.ok) throw new Error(await pricing.text());
curl "$TMBILLING_URL/checkout/v1/pricing/$BIZ"
{
"object": "pricing_page",
"business": {
"name": "Fernhill Analytics",
"livemode": "test",
"logo_url": null,
"brand_color": null
},
"links": [
{
"id": "plink_XXXXXXXXXXXXXXXXXXXXXX01",
"name": "Onboarding workshop",
"mode": "purchase",
"description": null,
"image_url": null,
"price": {
"amount": "$499.00",
"currency": "USD",
"amount_minor": 49900,
"interval": null,
"interval_count": 1
}
}
]
}
Turn a link off
A link you no longer sell stops working for new buyers when you set active: false. It also
leaves the pricing page. Set it back to true to sell again.
await api("PATCH", `/v1/businesses/${biz.id}/payment_links/${link.id}`, { active: false });
curl -X PATCH $TMBILLING_URL/v1/businesses/$BIZ/payment_links/$PLINK \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "active": false }'
{
"object": "payment_link",
"id": "plink_XXXXXXXXXXXXXXXXXXXXXX01",
"name": "Onboarding workshop",
"active": false,
"mode": "purchase",
"url": "https://billing.example.com/buy/plink_XXXXXXXXXXXXXXXXXXXXXX01"
}
Charge a customer you already have
When the customer has a saved card, you do not need a checkout. Raise a one-off invoice with
collect: true and tmbilling charges the card at once. Here the test card 4242424242424242
stands in for their saved card.
const customer = await tmb.customers.create(biz.id, {
email: "[email protected]",
first_name: "Maya",
last_name: "Castillo",
currency: "USD",
});
await tmb.paymentMethods.create(biz.id, customer.id, { token: "4242424242424242" });
const invoice = await tmb.invoices.create(biz.id, {
customer: customer.id,
lines: [{ description: "Onboarding workshop", amount_minor: 49900 }],
collect: true,
});
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/customers \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"first_name": "Maya",
"last_name": "Castillo",
"currency": "USD"
}'
export CUS=cus_…
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/customers/$CUS/payment_methods \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "token": "4242424242424242" }'
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/invoices \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"customer": "'$CUS'",
"lines": [{ "description": "Onboarding workshop", "amount_minor": 49900 }],
"collect": true
}'
{
"object": "invoice",
"id": "inv_XXXXXXXXXXXXXXXXXXXXXX01",
"number": "INV-1",
"status": "paid",
"currency": "USD",
"total_minor": 49900,
"amount_paid_minor": 49900,
"paid_at": "2026-10-20T19:10:46.827Z"
}
Leave out collect to send the invoice for payment instead, with net_days for its terms. To add
a charge to a running subscription's next invoice, use
POST /v1/businesses/{biz}/subscriptions/{sub}/charges.
Check it worked
- A completed purchase is a
paidinvoice with nosubscription. List it withGET /v1/businesses/{biz}/invoices?customer={cus}. - Your webhook endpoint receives
invoice_paidandpayment_succeededfor it. - The customer sees the purchase, with its receipt, under Purchases in the customer portal.
Common errors
| Error | Cause | Fix |
|---|---|---|
… is not a one-time price. A purchase sells one-time prices only. | A recurring price, or a price of a plan product, in purchase mode. | Use a one_time price on a charge product, or drop mode: "purchase" to start a subscription. |
You cannot use a coupon on a one-time purchase. | coupons sent with mode: "purchase". | Remove the coupons. |
… has a flat price, so its quantity cannot change. To sell more than one, use a per-unit price. | A payment link item with a flat price and a quantity above 1, or quantity limits. | Use a per_unit price. |
All items must use one currency. … | Items in different currencies on one link. | Make one link per currency. |
A payment link with a recurring price needs exactly one plan | A link mixing recurring prices without exactly one plan. | Put one plan on the link, with add-ons beside it. |
This payment link is not available (404) | The buyer opened a link that is turned off. | Set active: true, or share a different link. |
Related
Coupons
Create reusable coupons, generate single-use code sets for a campaign, apply a one-off discount without a catalog object, and see where each shows on invoices.
Checkout and portal
Create short-lived, single-use sessions so buyers can subscribe and customers can manage their own billing, without your API key ever reaching a browser.