thru

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:

pricingModeWhat it is forWhere the amount comes from
fixed (the default)A plan, a pack, a SKU: anything with a priceThe product's price, or the per-chain price in networks
custom_amountPrepaid credit, a balance top-up, a quantity the buyer decidesNamed 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"}'
FieldRules
nameRequired, up to 120 characters
kindRequired. Must be "one_off".
pricingModeOptional; "fixed" is the default. Cannot be changed after create.
chain, tokenRequired. The default chain and its token. See supported-chains.
priceRequired on a fixed-price product, in token units ("20" is 20 USDC). Sending it on a custom_amount product returns 400.
networkOptional; the default is mainnet. Every checkout of this product happens on this network. Create a separate product with "network": "testnet" for testing.
networksOptional, 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.
enabledChainsOptional. 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, imageUrlOptional, up to 2000 and 2048 characters
successUrl, cancelUrlOptional. 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:

FieldRules on a custom_amount product
pricingMode"custom_amount". Immutable after create.
priceNot allowed (400). The stored price is "0" and means nothing.
minAmount, maxAmountOptional, 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.
networksRows of { chain, token } with no price (400 if one is sent).
chain, token, every networks row, and every chain in enabledChainsMust 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:

FieldMeaning
pricingMode"fixed" or "custom_amount"
priceThe token-unit price on a fixed-price product. "0" and meaningless on a custom-amount one.
minAmount, maxAmountYour 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[].priceOn a retrieve or list: a string on a fixed-price product; null on every row of a custom-amount one

Manage products

EndpointDoes
GET /v1/products?status=active|archived|allLists products (default all)
GET /v1/products/:idOne product
PATCH /v1/products/:idChanges 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/archiveStops new checkouts. Payments already in progress are not affected.
DELETE /v1/products/:idDeletes an archived product that no checkout session has ever used. Archive is the normal end state for anything that has sold.

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"
    

}

csharp

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

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