---
title: "One-time purchases and payment links"
description: "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."
canonical_url: "https://twominutebilling.com/help/developers/recipes/one-time-purchases"
---
# 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](https://twominutebilling.com/help/developers/getting-started/quickstart) first: you need a test API key in
`$TMBILLING_API_KEY`, your base URL in `$TMBILLING_URL`, and the client set up:

```ts [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:

```ts [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.

<code-group>

```ts [TypeScript]
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,
});
```

```bash [curl]
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
  }'
```

</code-group>

```json [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`.

<code-group>

```ts [TypeScript]
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",
  },
);
```

```bash [curl]
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"
  }'
```

</code-group>

```json [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.

## 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.

<code-group>

```ts [TypeScript]
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",
  },
);
```

```bash [curl]
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_…
```

</code-group>

```json [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.

<code-group>

```ts [TypeScript]
const pricing = await fetch(`${process.env.TMBILLING_URL}/checkout/v1/pricing/${biz.id}`);
if (!pricing.ok) throw new Error(await pricing.text());
```

```bash [curl]
curl "$TMBILLING_URL/checkout/v1/pricing/$BIZ"
```

</code-group>

```json [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
      }
    }
  ]
}
```

## 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.

<code-group>

```ts [TypeScript]
await api("PATCH", `/v1/businesses/${biz.id}/payment_links/${link.id}`, { active: false });
```

```bash [curl]
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 }'
```

</code-group>

```json [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.

<code-group>

```ts [TypeScript]
const customer = await tmb.customers.create(biz.id, {
  email: "maya@example.com",
  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,
});
```

```bash [curl]
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": "maya@example.com",
    "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
  }'
```

</code-group>

```json [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

<table>
<thead>
  <tr>
    <th>
      Error
    </th>
    
    <th>
      Cause
    </th>
    
    <th>
      Fix
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        … is not a one-time price. A purchase sells one-time prices only.
      </code>
    </td>
    
    <td>
      A recurring price, or a price of a <code>
        plan
      </code>
      
       product, in purchase mode.
    </td>
    
    <td>
      Use a <code>
        one_time
      </code>
      
       price on a <code>
        charge
      </code>
      
       product, or drop <code>
        mode: "purchase"
      </code>
      
       to start a subscription.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        You cannot use a coupon on a one-time purchase.
      </code>
    </td>
    
    <td>
      <code>
        coupons
      </code>
      
       sent with <code>
        mode: "purchase"
      </code>
      
      .
    </td>
    
    <td>
      Remove the coupons.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        … has a flat price, so its quantity cannot change. To sell more than one, use a per-unit price.
      </code>
    </td>
    
    <td>
      A payment link item with a <code>
        flat
      </code>
      
       price and a quantity above 1, or quantity limits.
    </td>
    
    <td>
      Use a <code>
        per_unit
      </code>
      
       price.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        All items must use one currency. …
      </code>
    </td>
    
    <td>
      Items in different currencies on one link.
    </td>
    
    <td>
      Make one link per currency.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        A payment link with a recurring price needs exactly one plan
      </code>
    </td>
    
    <td>
      A link mixing recurring prices without exactly one plan.
    </td>
    
    <td>
      Put one plan on the link, with add-ons beside it.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        This payment link is not available
      </code>
      
       (<code>
        404
      </code>
      
      )
    </td>
    
    <td>
      The buyer opened a link that is turned off.
    </td>
    
    <td>
      Set <code>
        active: true
      </code>
      
      , or share a different link.
    </td>
  </tr>
</tbody>
</table>

## Related

- [Hosted checkout and payment links](https://twominutebilling.com/product/checkout-portal)
- [Checkout and portal sessions](https://twominutebilling.com/help/developers/recipes/checkout-and-portal)


## Sitemap

See the full [sitemap](https://twominutebilling.com/sitemap.md) for all pages.
