---
title: "Products and payment links"
description: "Catalogue items in two pricing modes: fixed (a plan or SKU with a price) and custom_amount (credit or quantity, the amount named per checkout within your min/max); per-chain rails, the server-generated slug, the anonymous /pay/:slug payment link, and the product lifecycle"
source: https://docs.thru.la/products.md
html: https://docs.thru.la/products
index: https://docs.thru.la/llms.txt
---
# Products and payment links

A product is an item in your catalogue. It has one of two **pricing modes**, chosen at create and fixed for life:

| `pricingMode` | What it is for | Where the amount comes from |
|---|---|---|
| `fixed` (the default) | A plan, a pack, a SKU: anything with a price | The product's `price`, or the per-chain price in `networks` |
| `custom_amount` | Prepaid credit, a balance top-up, a quantity the buyer decides | Named **per checkout**: by your server on the checkout session (`amount`), or by the buyer on the payment link. Bounded by your `minAmount` and `maxAmount`, in USD. |

Either kind can be sold two ways:

- through **checkout sessions**, which know which of your users is paying (`checkout-sessions`); or
- through its **payment link**, `https://thru.la/pay/<slug>`, which anyone can open and pay, anonymously.

Every product is a one-off charge. Each purchase is a separate payment.

## Create a fixed-price product

```bash
curl -X POST https://api.thru.la/v1/products \
  -H "x-api-key: $THRU_API_KEY" -H "content-type: application/json" \
  -d '{"name":"Pro plan, 30 days","kind":"one_off","chain":"bnb","network":"mainnet","token":"USDC","price":"20","networks":[{"chain":"bnb","token":"USDC","price":"20"},{"chain":"sui","token":"USDC","price":"20"}],"successUrl":"https://example.com/billing/done","cancelUrl":"https://example.com/billing"}'
```

| Field | Rules |
|---|---|
| `name` | Required, up to 120 characters |
| `kind` | Required. Must be `"one_off"`. |
| `pricingMode` | Optional; `"fixed"` is the default. Cannot be changed after create. |
| `chain`, `token` | Required. The default chain and its token. See `supported-chains`. |
| `price` | Required on a fixed-price product, in token units (`"20"` is 20 USDC). Sending it on a `custom_amount` product returns `400`. |
| `network` | Optional; the default is `mainnet`. Every checkout of this product happens on this network. Create a separate product with `"network": "testnet"` for testing. |
| `networks` | Optional, up to 12 rows of `{ chain, token, price }`: the chains the buyer can pick, each with its own token and price, shown in this order. `price` is required on every row of a fixed-price product. When you send `networks`, it also sets `enabledChains`, so include the product's own `chain` in it. |
| `enabledChains` | Optional. The chains the buyer can pick, all at the product's own `price` and `token`. Ignored when `networks` is sent. On a custom-amount product each chain is checked as a rail (with the product's `token`) by the same rule as a `networks` row. |
| `description`, `imageUrl` | Optional, up to 2000 and 2048 characters |
| `successUrl`, `cancelUrl` | Optional. Where the hosted page sends the buyer afterwards. Each must be on your registered return origins (Console → Developers → Checkout, or `PUT /v1/checkout/settings`). |

The response is the product. Its `slug` is **generated by the server** from the name plus 6 random hex characters (for example `pro-plan-30-days-4f1c2a`). You cannot choose it, so read it from the response or the console.

## Create a custom-amount product

One product sells every amount. Use it for credit, top-ups and anything else where the buyer, or your server, decides the number.

```bash
curl -X POST https://api.thru.la/v1/products \
  -H "x-api-key: $THRU_API_KEY" -H "content-type: application/json" \
  -d '{"name":"Account credit","kind":"one_off","pricingMode":"custom_amount","chain":"bnb","network":"mainnet","token":"USDC","minAmount":"1","maxAmount":"500","networks":[{"chain":"bnb","token":"USDC"},{"chain":"sui","token":"USDC"}],"successUrl":"https://example.com/credit/done","cancelUrl":"https://example.com/credit"}'
```

The differences from a fixed-price product:

| Field | Rules on a `custom_amount` product |
|---|---|
| `pricingMode` | `"custom_amount"`. Immutable after create. |
| `price` | **Not allowed** (`400`). The stored `price` is `"0"` and means nothing. |
| `minAmount`, `maxAmount` | Optional, in USD, decimal strings with at most 2 decimal places and at least `0.01`; `minAmount` must not exceed `maxAmount`. These are your bounds on the amount a checkout may name. Sending either on a fixed-price product returns `400`. |
| `networks` | Rows of `{ chain, token }` with **no `price`** (`400` if one is sent). |
| `chain`, `token`, every `networks` row, and every chain in `enabledChains` | Must be a **token thru has registered for that chain and network, on a chain whose deposits thru detects automatically**, so the page only ever offers rails that confirm on their own. Otherwise `400`: `<chain> cannot be a rail on a custom_amount product: deposits on it are not detected automatically yet.` or `<token> on <chain>/<network> is not a token thru detects…` |

**The amount maps 1:1 to token units on the rail the buyer picks.** A custom-amount product charges its `amount` as that many tokens of the chosen rail, with no conversion: a $37 session on a USDC rail asks for 37 USDC, and on a BNB rail it asks for **37 BNB**. Native coins in the registry (`BNB`, `SUI`, `ETH`) pass the create check, so the API will not stop you from listing one. For USD credit, list USD stablecoins only (`USDC`, `USDT`, `USDG`, …), and only where `supported-chains` marks the pair live: `USDT` on `bnb` testnet is refused because no such token is registered there, and `USDC` on `base` is registered but not watched. A fixed-price product has none of these restrictions: a plan may still be priced in ETH.

The **effective minimum** is the larger of your `minAmount` and thru's own floor of `0.01`. There is no thru-wide or per-chain minimum above that. Whatever the effective minimum is, the API reports it in the `400` for an amount that is too small, and `GET /v1/public/products/:slug` reports it to the page, so a buyer never learns about a limit after sending funds.

**Precision.** Every amount on a custom-amount product (`minAmount`, `maxAmount`, a session's `amount`, a buyer's `amount`) is a decimal string matching `^\d{1,20}(\.\d{1,2})?$`: at most two decimal places, no sign, no exponent, no whitespace, and at least `0.01`. `"37"`, `"37.5"` and `"37.50"` are all accepted and all mean the same amount.

## The product object

Every product response is the row: `id`, `merchantId`, `slug`, `name`, `description`, `imageUrl`, `kind`, `chain`, `network`, `token`, `price`, `pricingMode`, `minAmount`, `maxAmount`, `enabledChains`, `successUrl`, `cancelUrl`, `status`, `createdAt`, `updatedAt`. **Only `GET /v1/products` and `GET /v1/products/:id` add `networks`** (rows of `{ chain, token, price }` in display order); the create and update responses return the row without it, so read the product back when you need the rails. Of note:

| Field | Meaning |
|---|---|
| `pricingMode` | `"fixed"` or `"custom_amount"` |
| `price` | The token-unit price on a fixed-price product. `"0"` and meaningless on a custom-amount one. |
| `minAmount`, `maxAmount` | Your own bounds, exactly as you set them (`null` when unset). Always `null` on a fixed-price product. The effective minimum is reported only on the public projection and in the `400`. |
| `networks[].price` | On a retrieve or list: a string on a fixed-price product; `null` on every row of a custom-amount one |

## Manage products

| Endpoint | Does |
|---|---|
| `GET /v1/products?status=active\|archived\|all` | Lists products (default `all`) |
| `GET /v1/products/:id` | One product |
| `PATCH /v1/products/:id` | Changes `name`, `description`, `imageUrl`, `status`, `networks`, `enabledChains`, `successUrl` or `cancelUrl`. On a custom-amount product it also changes `minAmount` and `maxAmount` (`null` clears one; the pair is re-checked against the stored other side) and requires replacement `networks` rows to have no `price` and, like `enabledChains`, to pass the rail rule above. `pricingMode`, the chain, network, token and base price cannot be changed; use `networks` to reprice a fixed-price product per chain, or create a new product. |
| `POST /v1/products/:id/archive` | Stops new checkouts. Payments already in progress are not affected. |
| `DELETE /v1/products/:id` | Deletes an archived product that no checkout session has ever used. Archive is the normal end state for anything that has sold. |

## The payment link: `/pay/:slug`

Share `https://thru.la/pay/<slug>` anywhere. The buyer picks a chain from the ones you enabled and pays the product's price for that chain, or, on a custom-amount product, types an amount within your bounds and pays that. The page is backed by two public endpoints:

- `GET /v1/public/products/:slug` returns the product as the page sees it, including `pricingMode` and, on a custom-amount product, the **effective** bounds:

  ```json
  {
  "pricingMode": "custom_amount",
  "minAmount": "1",
  "maxAmount": "500",
  "currency": "USD"
}
  ```

  On a fixed-price product these three bound fields are `null`. `networks[].price` is `null` on a custom-amount product.
- `POST /v1/public/products/:slug/checkout` with body `{ idempotencyKey?, chain?, amount? }` returns `{ kind, payment }`. `amount` is **required** on a custom-amount product and **refused** on a fixed-price one (`400 amount is not accepted for a fixed-price product.`). It is checked by the same rule as a session's amount, so an amount outside the bounds returns the same `400` with the bounds as fields:

  ```json
  {
  "statusCode": 400,
  "error": "Bad Request",
  "message": "amount 0.5 is below the minimum of 1 USD.",
  "minAmount": "1",
  "maxAmount": "500",
  "currency": "USD"
}
  ```

  On a custom-amount product the `idempotencyKey` is scoped by chain **and amount**, so a page that resubmits the same key with a different amount gets a new payment for the new amount, not the earlier quote.

The payment a link creates has `expectedAmount` equal to the price or the chosen amount, `token` set to the chosen rail's token, and `currency` equal to that token symbol.

**A payment link is anonymous.** The payment it creates has no reference and no metadata, only `productId`. You receive `payment.*` webhooks, but nothing in them says who the buyer is. Use it for things that need no account, such as tips or a public buy button. To know who paid, use a checkout session.

The slug is not secret: the hosted checkout-session page reads the product by its slug, so anyone who has a session link can find the product's payment link. If a product must only be bought by known users, check for payments on it that did not come through a session: their `metadata` has no `thru.sessionId`, and `GET /v1/payments/:id` returns `checkoutSession: null` for them.

## Lifecycle

`active` → `archived`. An archived product disappears from `/pay/:slug`, and new checkout sessions for it are refused with `409`.

---

Related: [checkout-sessions](https://docs.thru.la/checkout-sessions.md) · [credit-topups](https://docs.thru.la/credit-topups.md) · [payments](https://docs.thru.la/payments.md) · [supported-chains](https://docs.thru.la/supported-chains.md)
