Recipes

One-time purchases and payment links

Sell something once through a hosted checkout, a shareable payment link or your public pricing page, or charge a saved card for a one-off amount from the API.

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:

setup.ts
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:

api.ts
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,
});
Response
{
  "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",
  },
);
Response
{
  "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.

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",
  },
);
Response
{
  "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());
Response
{
  "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
      }
    }
  ]
}

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 });
Response
{
  "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,
});
Response
{
  "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 paid invoice with no subscription. List it with GET /v1/businesses/{biz}/invoices?customer={cus}.
  • Your webhook endpoint receives invoice_paid and payment_succeeded for it.
  • The customer sees the purchase, with its receipt, under Purchases in the customer portal.

Common errors

ErrorCauseFix
… 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 planA 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.
Copyright © 2026