> ## Documentation Index
> Fetch the complete documentation index at: https://docs.taberna.io/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Taberna is a crypto payments API. Amounts are priced in fiat (USD or EUR) and paid in crypto.
> Fiat amounts are ALWAYS decimal strings ("9.99"), never numbers. Crypto amounts are integer strings in the asset's smallest unit.
> Authenticate merchant endpoints with `Authorization: Bearer tbrn_live_...`. Every key is bound to one store and carries an explicit scope set, so never pass a store id to a /v1 endpoint.
> A 401 means the credential is bad; a 403 means the key lacks a scope. Never retry or re-authenticate on a 403 — surface it as a configuration error.
> The /v1/checkouts endpoints are deliberately unauthenticated and run in the buyer's browser. Never send an API key to a browser.
> Verify webhook signatures over the RAW request body before parsing JSON, and treat delivery as at-least-once: handlers must be idempotent.
> Never grant value based on a browser redirect to successUrl. Fulfil on a signature-verified webhook, or on a server-side read of GET /v1/orders/{id} or GET /v1/invoices/{id}.
> There is no test mode: every payment method is a live chain moving real funds.
> A merchant can run an entire shop with no code: the dashboard creates products and invoices, and every store can host a storefront at {slug}.taberna.io with its own theme, sections, branding and optional custom domain. Never assume the reader has an API integration.
> Taberna issues no refunds and performs no KYC. A refund is something the merchant sends from their own payout wallet, off-platform.

# Products API

> Read the catalogue, top up delivery stock, and know exactly what delivery never exposes.

A product is a fixed-price catalogue item. [Orders](/orders) are always for products,
and each product may carry a **delivery** — the digital good handed to the buyer once
the order completes.

<Info>
  Products are **created and edited in the dashboard**. An API key can read them and
  append delivery stock, but not create, edit or delete them. Choosing between the four
  delivery types is a merchant decision — see
  [Digital delivery](/guides/digital-delivery).
</Info>

## Product types

`productType` currently has exactly one value: **`single-purchase`**. It is present so
the field is stable as more types arrive; there is nothing to choose today.

## Reading products

`GET /v1/products` and `GET /v1/products/{id}` — both require the `products:read`
scope. Products are addressed by **UUID**, not a short id. The list is newest first.

| Query parameter | Type    | Notes                                                                                                                                                    |
| --------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `page`          | integer | Defaults to `1`                                                                                                                                          |
| `limit`         | integer | 1 to 100, defaults to `20`                                                                                                                               |
| `listed`        | boolean | Accepts the usual truthy and falsy spellings — `true`/`false`, `1`/`0`, `yes`/`no`, `on`/`off` — case-insensitive. An **empty** value applies no filter. |

```json 200 OK theme={"dark"}
{
  "data": [
    {
      "id": "0197f8a0-1111-7aaa-8ccc-3e5d7c9b1a24",
      "title": "Pro licence",
      "body": "One year of Pro, one seat.",
      "price": "50",
      "currency": "usd",
      "productType": "single-purchase",
      "listed": true,
      "image": null,
      "deliveryType": "text_pool",
      "stock": 412,
      "minQuantity": 1,
      "maxQuantity": 10,
      "createdAt": "2026-06-01T10:00:00.000Z",
      "updatedAt": "2026-07-02T14:31:00.000Z"
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 6, "totalPages": 1 }
}
```

`GET /v1/products/{id}` returns a single object in exactly that shape. An unknown
product, or one from another store, answers `404` with `{ "error": "Product not found" }`.

<Info>
  **A product must be `listed` for an order to be created against it.** An unlisted
  product is rejected with `Product is not available`. Unlisting is how you retire a
  product without deleting its order history.
</Info>

## What the API exposes about delivery — and what it never does

| Exposed                                                   | Never exposed                                    |
| --------------------------------------------------------- | ------------------------------------------------ |
| `deliveryType` — which of the four types is configured    | Pool lines (the licence keys themselves)         |
| `stock` — remaining available units                       | The shared text blob                             |
| `minQuantity` / `maxQuantity` — the order-quantity bounds | File keys or storage paths                       |
|                                                           | Redirect target URLs                             |
|                                                           | The delimiter, selection order and custom fields |

Fulfilment secrets are simply not part of the merchant read model. The serializer reads
only the type and the quantity bounds from the delivery configuration — the columns
holding secrets are never selected, so there is no field to accidentally leak. The buyer
receives the actual goods on the completed checkout page, and nowhere else.

`stock` is the live count of **available** units, and is meaningful **only for
`text_pool`** — the one finite delivery type. For every other type, and for a product
with no delivery configured at all, `stock` is `null`.

### Quantity bounds

`minQuantity` defaults to `1` and `maxQuantity` to `null` (no maximum) when a product
has no delivery configuration. Both are enforced at order creation:

* Below the minimum: `Minimum order quantity for {title} is {n}`
* Above the maximum: `Maximum order quantity for {title} is {n}`

Both are `400`s on `POST /v1/orders`.

A product with **no** delivery configuration is perfectly valid. The order simply
completes as a receipt — useful when fulfilment happens entirely on your side, off the
back of the `order.completed` webhook.

## Appending stock

`POST /v1/products/{id}/stock` — requires the `products:write` scope. Works on
**`text_pool` products only**, the one finite delivery type.

This is the one write an API key has over the catalogue, and it exists so a key
generator can top up a pool without a human logging in.

```json Request theme={"dark"}
{ "text": "KEY-AAAA-1111\nKEY-BBBB-2222\nKEY-CCCC-3333" }
```

`text` is one blob, 1 to **5,000,000** characters. It is processed in a fixed order:

1. **Split, trim, drop blanks, de-duplicate within the batch.** The blob is split on the
   product's configured delimiter (the default is a newline, and CRLF line endings are
   handled), each line is trimmed, empty lines are dropped, and **repeats within the
   submitted text collapse to one line**. Three identical keys in one paste become one
   line here, silently.
2. **Apply the 10,000-line cap.** The cap is checked **after** that de-duplication, on
   the surviving line count. A blob of 12,000 lines that is only 9,000 distinct lines is
   accepted; 10,001 distinct lines is
   `400 { "error": "Cannot add more than 10000 lines at once" }`. An empty result —
   nothing survived splitting and trimming — is `400 No stock lines found`.
3. **Compare against existing stock.** Each surviving line is compared against the units
   already `available` or `reserved` on the product. Lines that collide are skipped; the
   rest are inserted.

```json 200 OK theme={"dark"}
{
  "added": 2,
  "skippedDuplicates": 1,
  "available": 414
}
```

* `added` — units newly inserted.
* `skippedDuplicates` — **only** the surviving lines that collided with existing
  non-delivered stock on the product. Collisions are **reported, not rejected**, which is
  what makes re-sending a batch safe.
* `available` — the pool's available count after the append.

<Info>
  **`added + skippedDuplicates` can be less than the number of lines you sent.**
  Repeats within the submitted text are collapsed in step 1 and are never counted
  anywhere — not in `added`, not in `skippedDuplicates`. The two counters describe the
  de-duplicated batch, not the raw input. If you need to know how many of your own
  lines were redundant, count distinct lines before sending.
</Info>

Because duplicates are skipped rather than erroring, appending is naturally idempotent
for whole batches: re-POST the same blob after a timeout and you get `added: 0` with
everything counted as a duplicate. A unit that has already been **delivered** is not part
of the duplicate check — the same string can be re-added later. Dedup guards against
double-loading a batch, not against reselling a code you deliberately reissue.

### Example

<CodeGroup>
  ```bash cURL theme={"dark"}
  curl -X POST https://api.taberna.io/v1/products/0197f8a0-1111-7aaa-8ccc-3e5d7c9b1a24/stock \
    -H 'Authorization: Bearer tbrn_live_…' \
    -H 'Content-Type: application/json' \
    -d '{ "text": "KEY-AAAA-1111\nKEY-BBBB-2222" }'
  ```

  ```javascript Node.js theme={"dark"}
  const keys = await mintLicenceKeys(500);

  const res = await fetch(
  	`https://api.taberna.io/v1/products/${productId}/stock`,
  	{
  		method: 'POST',
  		headers: {
  			Authorization: `Bearer ${process.env.TABERNA_API_KEY}`,
  			'Content-Type': 'application/json',
  		},
  		body: JSON.stringify({ text: keys.join('\n') }),
  	},
  );
  if (!res.ok) throw new Error((await res.json()).error);

  const { added, skippedDuplicates, available } = await res.json();
  console.log(`+${added} (${skippedDuplicates} dupes), pool now ${available}`);
  ```

  ```python Python theme={"dark"}
  keys = mint_licence_keys(500)

  res = requests.post(
  	f"https://api.taberna.io/v1/products/{product_id}/stock",
  	headers={
  		"Authorization": f"Bearer {api_key}",
  		"Content-Type": "application/json",
  	},
  	json={"text": "\n".join(keys)},
  )
  if not res.ok:
  	raise RuntimeError(res.json()["error"])

  body = res.json()
  print(f"+{body['added']} ({body['skippedDuplicates']} dupes), pool now {body['available']}")
  ```
</CodeGroup>

### Errors

| Status | Message                                         | Cause                                                                   |
| ------ | ----------------------------------------------- | ----------------------------------------------------------------------- |
| `404`  | `Product not found`                             | Unknown product, or one from another store                              |
| `400`  | `Invalid request data`                          | `text` missing, empty, or over 5,000,000 characters                     |
| `400`  | `Delivery is not configured for this product`   | The product has no delivery at all                                      |
| `400`  | `Stock can only be added to text_pool delivery` | The product's delivery is a different type                              |
| `400`  | `No stock lines found`                          | Nothing survived splitting and trimming                                 |
| `400`  | `Cannot add more than 10000 lines at once`      | More than 10,000 lines **after** within-batch de-duplication — split it |
| `413`  | `Payload too large`                             | The encoded body exceeded 8 MiB                                         |

The 8 MiB limit is on **encoded bytes**, not characters, so a `text` of well under
5,000,000 characters can still hit `413` once non-ASCII content is UTF-8 encoded. Batch
in chunks of a few thousand lines and neither limit is close.

The delimiter, selection order (FIFO or LIFO) and custom checkout fields are configured
in the dashboard. They are not settable through the public API.

## What delivery looks like on a completed order

Delivery is exposed to the **buyer**, on the public checkout payload, and only once the
order's `status` is `completed`. Until then every item's `delivery` is `null`.

Each shape is discriminated by `type`:

<CodeGroup>
  ```json text_pool theme={"dark"}
  { "type": "text_pool", "lines": ["KEY-AAAA-1111", "KEY-BBBB-2222"] }
  ```

  ```json text_shared theme={"dark"}
  { "type": "text_shared", "text": "Join at https://discord.gg/…" }
  ```

  ```json file_shared theme={"dark"}
  { "type": "file_shared", "fileName": "handbook.pdf", "orderItemId": "CcvnR7dMz4qKfWnp2sTBx" }
  ```

  ```json redirect theme={"dark"}
  { "type": "redirect", "url": "https://vendor.example.com/claim/…" }
  ```
</CodeGroup>

Notes that matter:

* **`file_shared` never exposes a storage key.** You get a `fileName` and an
  `orderItemId`, which is what the [download endpoint](/build/checkout-api#get-a-delivery-download-link-orders-only)
  takes to mint a five-minute presigned URL (a temporary, signed link to private storage).
* **`text_pool.lines` can be shorter than the item's `quantity`** if the pool ran short
  at fulfilment time. Do not assume a one-to-one match.
* The delivered payload is **snapshotted at completion**, in the same transaction that
  flips the order to `completed`. Later edits to the product never change what a past
  buyer received.

<Info>
  Delivery content is deliberately absent from the merchant API. `GET /v1/orders/{id}`
  returns line items and payment facts, not the codes that were handed over. If your
  own system needs to know what was delivered, generate the units yourself and push
  them with [the stock endpoint](#appending-stock) so you keep your own record.
</Info>
