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
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.
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/:slugreturns the product as the page sees it, includingpricingModeand, 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.