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

# Products and delivery

> Catalogue items, the four digital delivery types, and topping up stock from your server.

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. See
  [Current limitations](/errors-and-limits#current-limitations).
</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 `true`/`false`, `1`/`0`, `yes`/`no`, `on`/`off`, case-insensitive. An **empty** value applies no filter. |

```json 200 OK theme={null}
{
  "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" }`.

<Warning>
  **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.
</Warning>

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

This is the important part of the page.

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

<Note>
  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.
</Note>

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

## The four delivery types

<AccordionGroup>
  <Accordion title="text_pool — a consumable pool of lines" icon="list">
    A pool of text units, one per sellable item: licence keys, gift-card codes,
    account credentials, e-pins. **This is the only finite type**, and the only one
    with a `stock` count.

    Units move `available` → `reserved` (when an order is created) → `delivered` (when
    it completes). An order that expires **returns its reservations to the pool**,
    which is why [expiring a stale order](/orders#expire-an-order) is worth doing.

    Stock is stored encrypted at rest and can be topped up through the API — see
    [Appending stock](#appending-stock).
  </Accordion>

  <Accordion title="text_shared — one blob for every buyer" icon="align-left">
    A single piece of text every buyer receives: a coupon code, a Discord invite, a
    set of instructions. Unlimited — there is nothing to run out of, so `stock` is
    `null`.
  </Accordion>

  <Accordion title="file_shared — one file for every buyer" icon="file-arrow-down">
    A single file, held in private storage and **never public-read**. Buyers reach it
    only through a short-lived presigned URL minted by the gated download endpoint —
    see [the download route](/checkout#get-a-delivery-download-link-orders-only).
    Unlimited; `stock` is `null`.
  </Accordion>

  <Accordion title="redirect — send the buyer somewhere" icon="up-right-from-square">
    An HTTPS URL the buyer is sent to after paying — your own fulfilment page, a
    vendor portal, a course platform. Unlimited; `stock` is `null`.
  </Accordion>
</AccordionGroup>

<Info>
  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.
</Info>

## Appending stock

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

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={null}
{ "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:

<Steps>
  <Step title="Split, trim, drop blanks, de-duplicate within the batch" icon="scissors">
    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.
  </Step>

  <Step title="Apply the 10,000-line cap" icon="ruler">
    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`.
  </Step>

  <Step title="Compare against existing stock" icon="layer-group">
    Each surviving line is compared against the units already `available` or `reserved`
    on the product. Lines that collide are skipped; the rest are inserted.
  </Step>
</Steps>

```json 200 OK theme={null}
{
  "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.

<Warning>
  **`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.
</Warning>

<Tip>
  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.
</Tip>

<Note>
  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.
</Note>

### Example

<CodeGroup>
  ```bash cURL theme={null}
  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={null}
  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={null}
  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                                         |

<Warning>
  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.
</Warning>

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

## 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={null}
  { "type": "text_pool", "lines": ["KEY-AAAA-1111", "KEY-BBBB-2222"] }
  ```

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

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

  ```json redirect theme={null}
  { "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](/checkout#get-a-delivery-download-link-orders-only)
  takes to mint a five-minute presigned URL.
* **`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.

<Warning>
  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.
</Warning>
