> ## 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. # Instructions for AI agents Source: https://docs.taberna.io/agents Rules for LLMs and coding agents integrating Taberna — the mistakes that cost real money. This page is written for **LLMs and coding agents** writing Taberna integrations, and for the humans reviewing what they produce. It is self-contained: everything an agent needs to avoid the expensive mistakes is stated here, without following links. Machine-readable copies of this site are published automatically at `/llms.txt` and `/llms-full.txt`, and every page is available as Markdown by appending `.md` to its URL. ## Non-negotiable rules **1. There is no test mode.** Every payment method is a live blockchain and every payment moves real funds. Never generate code that "tests the payment flow end to end" expecting a sandbox. Never suggest a `tbrn_test_` key — no such thing exists. The only key prefix is `tbrn_live_`. **2. Verify webhook signatures over the RAW request body, before parsing.** The signature is `HMAC_SHA256(secret, timestamp + "." + rawBody)` in hex. If the framework parses JSON and you re-serialize it, key order or whitespace will differ and every verification will fail. * Express: `express.raw({ type: 'application/json' })` on that route. * FastAPI / Starlette: `await request.body()`. * Next.js App Router: `await req.text()`. * Laravel: `$request->getContent()`. Compare in constant time — `crypto.timingSafeEqual`, `hmac.compare_digest`, `hash_equals`. Never `==`. **3. Never log, echo or commit a raw key or secret.** `tbrn_live_…` API keys and `tbrn_whsec_…` webhook secrets must come from environment variables or a secret manager. Do not print them in debug output, do not include them in error messages, do not write them into example files that get committed. An API key is shown exactly once at creation and cannot be recovered; a webhook signing secret, by contrast, stays readable in the dashboard, so a lost one is re-read rather than rotated. **4. Never ship an API key to a browser.** The `/v1/checkouts` endpoints exist precisely so the buyer's page needs no credential. If you find yourself putting an `Authorization` header in client-side code, the design is wrong. **5. Never grant value from a browser redirect.** `successUrl` is an unauthenticated redirect the buyer controls. Fulfil only on a signature-verified webhook, or on a server-side read of `GET /v1/orders/{id}` or `GET /v1/invoices/{id}`. ## Discover capabilities before assuming them Call **`GET /v1/store`** at startup. It requires no scope, works with any valid key, and returns the calling key's own scopes plus the store's enabled payment methods: ```bash theme={null} curl https://api.taberna.io/v1/store -H "Authorization: Bearer $TABERNA_API_KEY" ``` ```json theme={null} { "id": "0197f8a0-2222-7bbb-9ddd-4f6e8dac2b35", "name": "My Store", "image": null, "enabledPaymentMethods": ["usdc_base", "sol"], "requireCustomerEmail": true, "createdAt": "2026-07-01T09:12:44.000Z", "slug": "my-store", "storefrontEnabled": true, "scopes": ["orders:read", "orders:write"], "storefrontUrl": "https://my-store.taberna.io" } ``` Assert the scopes you need at boot and fail loudly, rather than discovering a missing scope during a customer's checkout. **Do not probe by calling an endpoint and catching the 403** — whoami exists so you never have to. The eight scopes: `orders:read`, `orders:write`, `invoices:read`, `invoices:write`, `products:read`, `products:write`, `webhooks:read`, `webhooks:write`. ## Error handling rules | Status | Correct reaction | | ------ | -------------------------------------------------------------------------------------------------------------------------- | | `400` | Fix the request or the resource state. **Do not retry unchanged.** | | `401` | The credential is bad. Surface it. **Do not retry, do not rotate automatically.** | | `403` | The key lacks a scope. Surface it as a **configuration error**. Never retry, never re-authenticate, never sign a user out. | | `404` | The resource does not exist for this key's store. Another store's resources also report as `404`. | | `413` | The body exceeded 8 MiB (encoded bytes). Split the batch. | | `429` | Invoice email only. Honour `Retry-After` / `retryAfterSeconds`. | | `502` | Invoice email only. The send did not count against the allowance — safe to retry. | The error body is `{ "error": "…" }` everywhere except `POST /v1/invoices/{id}/send`, which adds `code` and `retryAfterSeconds`. `error` is a human message, not a stable machine code — branch on status and, where documented, on `code`. ## Money formatting rules **Fiat amounts are decimal strings, never numbers.** `"9.99"`, not `9.99`. Up to 3 decimal places. Do not parse them into floats for arithmetic — use a decimal library or integer minor units. `"50"` means fifty, not fifty cents. **Crypto amounts are integer strings in the asset's smallest unit.** `"50010000"` is 50.01 USDC, because USDC has 6 decimals. Do not compare them to fiat amounts, and do not assume 18 decimals. ## Idempotency and retries Always send an `Idempotency-Key` header on `POST /v1/orders` and `POST /v1/invoices`, derived from your own purchase id. 1 to 255 characters. A replayed key returns the **original** resource, whatever the new body says — Taberna does not compare bodies. Keys never expire. Never generate a random key per HTTP attempt: that defeats the entire purpose, because the retry of a timed-out create would mint a second charge. ## Webhook handler requirements Delivery is **at-least-once**. A correct handler: Before any JSON parsing, for signature verification. Reject non-matching requests with a non-2xx and stop. Taberna does not enforce a tolerance for you. Reject when `abs(now_seconds - X-Taberna-Timestamp) > 300` — a 5-minute window. Without this, a captured request is replayable forever. **Never dedup on `X-Taberna-Delivery-Id` alone.** A manual resend re-queues the same delivery row, so it arrives with the **same** delivery id as the original — a handler that skips ids it has already seen silently drops every resend, which is precisely the traffic a merchant is replaying to fix a problem. Guard on your own state instead: has this `data.id` (or your `extraContext.purchaseId` / `metadata.purchaseId`) already been fulfilled? If yes, no-op. Record the delivery id for correlation if you like, but do not let it decide whether to act. Success means a `2xx` within about 10 seconds. Queue the real work; a handler that fulfils inline will time out under load and be replayed. Reference handler: ```javascript theme={null} import crypto from 'node:crypto'; import express from 'express'; const app = express(); const SECRET = process.env.TABERNA_WEBHOOK_SECRET; // tbrn_whsec_… never logged app.post( '/webhooks/taberna', express.raw({ type: 'application/json' }), // raw bytes, not a parsed object async (req, res) => { const timestamp = req.get('x-taberna-timestamp'); const received = req.get('x-taberna-signature'); const deliveryId = req.get('x-taberna-delivery-id'); if (!timestamp || !received) return res.sendStatus(400); // Replay defence. if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { return res.sendStatus(400); } const expected = crypto .createHmac('sha256', SECRET) .update(`${timestamp}.${req.body}`) .digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(received); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return res.sendStatus(400); } const { event, data } = JSON.parse(req.body.toString('utf8')); // Ack first, work after — the dispatcher gives up on slow endpoints. res.sendStatus(200); // The worker draining this queue does the idempotency check, against its // own state — e.g. skip if the order behind data.id is already fulfilled. // deliveryId is carried for correlation only; a resend reuses it. await enqueue({ event, data, deliveryId }); }, ); ``` ## Polling as a fallback, not a primary Webhooks are the completion signal. When a webhook did not arrive, reconcile from your **server** against the authenticated endpoints — `GET /v1/orders/{id}` or `GET /v1/invoices/{id}` — and act on the status. Do not poll `GET /v1/checkouts/{id}` from your backend: it is the buyer-facing, unauthenticated view and it omits `extraContext` and `metadata`, which is what you need to correlate. Stop polling as soon as the status leaves `pending` (orders) or `open` (invoices). Every other state is terminal: * Orders: `pending` → `completed` | `partial` | `expired` * Invoices: `open` → `paid` | `partial` | `expired` | `voided` Fulfil on `completed` / `paid` only. **Never fulfil on `partial`** unless a human has explicitly decided to honour underpayments — there is no automatic refund, and completion uses a strict `>=` comparison with no tolerance. ## Things that do not exist — do not invent them An agent's most common failure here is fabricating a plausible endpoint. None of the following exist: * A test or sandbox mode, or a `tbrn_test_` key prefix. * A refund endpoint, or any way to return funds through the API. * API routes to create or edit **products**, **stores**, **invoice templates** or **API keys**. All four are dashboard-only. * A second webhook URL, or per-event webhook subscriptions. * Currencies beyond `usd` and `eur`. * Product types beyond `single-purchase`. * Subscriptions, recurring billing or metered usage. Model these as repeated charges from your own scheduler. * A `storeId` parameter on any `/v1` endpoint — the key carries the store. The complete `/v1` surface is: `/v1/orders`, `/v1/invoices`, `/v1/invoice-templates`, `/v1/products`, `/v1/store`, `/v1/webhook-deliveries` (all API-key authenticated) and `/v1/checkouts` (unauthenticated, buyer-facing). If a route is not in that list, it does not exist — check the API Reference tab rather than guessing. ## Choosing orders versus invoices | Situation | Use | | ----------------------------------------------------------------- | -------- | | Fixed prices already modelled as catalogue items | Orders | | Amount computed at request time (credits, usage, a quoted fee) | Invoices | | You need digital delivery attached to the purchase | Orders | | You need a sequential invoice number, a memo, or a billed-to name | Invoices | | You want to email the customer a payment link | Invoices | When in doubt for a dynamic amount, use invoices — they need no product setup and carry `metadata` for correlation. ## Minimal correct integration ```javascript theme={null} // 1. Boot: assert capability, never assume it. const store = await api('/v1/store'); for (const scope of ['orders:read', 'orders:write']) { if (!store.scopes.includes(scope)) { throw new Error(`Taberna key is missing the ${scope} scope`); } } // 2. Charge: idempotent, with correlation data. const order = await api('/v1/orders', { method: 'POST', headers: { 'Idempotency-Key': purchase.id }, body: { items: [{ productId: PRODUCT_ID, quantity: 1 }], extraContext: { purchaseId: purchase.id, userId: user.id }, successUrl: 'https://yourapp.com/thanks', cancelUrl: 'https://yourapp.com/pricing', }, }); await savePendingPurchase(purchase.id, order.id); // 3. Redirect the buyer. redirect(order.checkoutUrl); // 4. Fulfil from the verified webhook (see the reference handler above), // guarded by your own business state. Reconcile with // GET /v1/orders/{id} only when no webhook arrived. ``` # Attach a receipt email Source: https://docs.taberna.io/api-reference/checkout/attach-a-receipt-email /api-reference/openapi.json put /v1/checkouts/{id}/email Records the buyer's email on a pending order so the receipt and any delivery can be sent. Order checkouts only — an invoice's recipient is set by the merchant who issued it. The address is never echoed back; the checkout payload only reports `hasCustomerEmail`. Unauthenticated: possession of the checkout id is the credential, so send no API key. The id is unguessable and every payload is redacted for a buyer audience. # Get a delivery download link Source: https://docs.taberna.io/api-reference/checkout/get-a-delivery-download-link /api-reference/openapi.json get /v1/checkouts/{id}/download/{orderItemId} Mints a short-lived presigned URL for a file delivered with a completed order item. Every failure — unknown checkout, order not completed, wrong item, a delivery that is not a file, an invoice id — answers with the same 404 and the same message, so the endpoint never reveals which check failed. Unauthenticated: possession of the checkout id is the credential, so send no API key. The id is unguessable and every payload is redacted for a buyer audience. # Retrieve a checkout Source: https://docs.taberna.io/api-reference/checkout/retrieve-a-checkout /api-reference/openapi.json get /v1/checkouts/{id} The hosted checkout payload for an order or an invoice, discriminated by `type`. This is what the payment page polls while an attempt is live. `activeAttempt` carries the locked quote, the deposit address and the confirmation progress while a quote is in flight, and is null once it lapses — the buyer picks a token again for a fresh one. An order's per-item `delivery` appears only once the order is completed. Unauthenticated: possession of the checkout id is the credential, so send no API key. The id is unguessable and every payload is redacted for a buyer audience. # Start a payment attempt Source: https://docs.taberna.io/api-reference/checkout/start-a-payment-attempt /api-reference/openapi.json post /v1/checkouts/{id}/attempts Locks a crypto quote for the chosen token and mints a fresh deposit wallet, returning the refreshed checkout to poll. Re-selecting the live attempt's token returns that same attempt; choosing a different token abandons it and locks a new quote on a new address. Refused once the order or invoice is no longer payable. Unauthenticated: possession of the checkout id is the credential, so send no API key. The id is unguessable and every payload is redacted for a buyer audience. # List invoice templates Source: https://docs.taberna.io/api-reference/invoice-templates/list-invoice-templates /api-reference/openapi.json get /v1/invoice-templates The store's saved invoice templates, newest first. Read-only on this API; templates are authored in the dashboard. Each row's `id` is what `POST /v1/invoices` accepts as `templateId`, which is why this read exists. Templates are invoice authoring material, so they read under the `invoices:read` scope rather than one of their own. Unpaginated — the set is small and merchant-curated. # Create an invoice Source: https://docs.taberna.io/api-reference/invoices/create-an-invoice /api-reference/openapi.json post /v1/invoices Issues an open invoice and returns it, including the hosted payment `url`. Requires the `invoices:write` scope. Supply `items` directly, or a `templateId` to take the items, memo and expiry from a saved template — anything sent explicitly replaces the template default wholesale. Creating an invoice never emails anyone; use the send endpoint for that. # Email an invoice Source: https://docs.taberna.io/api-reference/invoices/email-an-invoice /api-reference/openapi.json post /v1/invoices/{id}/send Emails an open invoice to the address stored on it. Requires the `invoices:write` scope. Takes no body: the recipient is always the invoice's own `customerEmail` and cannot be overridden. Sends are capped per invoice, per store and platform-wide; the invoice's `email` object reports the remaining allowance, and a rate-limited response carries a `Retry-After` header. # List invoices Source: https://docs.taberna.io/api-reference/invoices/list-invoices /api-reference/openapi.json get /v1/invoices The store's invoices, newest first, optionally filtered by status. Requires the `invoices:read` scope. # Retrieve an invoice Source: https://docs.taberna.io/api-reference/invoices/retrieve-an-invoice /api-reference/openapi.json get /v1/invoices/{id} One invoice by its short id, plus the on-chain transfers that paid it across every payment attempt. Requires the `invoices:read` scope. # Void an invoice Source: https://docs.taberna.io/api-reference/invoices/void-an-invoice /api-reference/openapi.json post /v1/invoices/{id}/void Marks an open invoice as voided so it can no longer be paid. Requires the `invoices:write` scope. Only open invoices can be voided, and an invoice with a payment in progress is refused until that attempt lapses. Voiding first settles any attempt that is still being watched, so no deposit address outlives the invoice; if funds had already landed on one, the invoice becomes `partial` instead and the call fails with `Only open invoices can be voided`. An unknown invoice is also reported here as a 400. # Create an order Source: https://docs.taberna.io/api-reference/orders/create-an-order /api-reference/openapi.json post /v1/orders Creates a pending order for a cart of listed products and returns the hosted checkout link to send the buyer. Requires the `orders:write` scope. All items must share one currency, and stock for finite products is reserved for the order's lifetime — an order that expires releases it again. # Expire an order Source: https://docs.taberna.io/api-reference/orders/expire-an-order /api-reference/openapi.json post /v1/orders/{id}/expire Cancels a pending order now instead of waiting out its window, releasing any reserved stock and firing the `order.expired` webhook. Requires the `orders:write` scope. Only pending orders can be expired, and an order with a payment in progress is refused until that attempt lapses. # List orders Source: https://docs.taberna.io/api-reference/orders/list-orders /api-reference/openapi.json get /v1/orders The store's orders, newest first, optionally filtered by status and an inclusive `createdAt` window. Requires the `orders:read` scope. # Retrieve an order Source: https://docs.taberna.io/api-reference/orders/retrieve-an-order /api-reference/openapi.json get /v1/orders/{id} One order by its short id. Requires the `orders:read` scope. The payment fields (`paymentMethod`, `cryptoAmount`, `receivedAmount`, `transactions`) all describe a single payment attempt: the one that completed the order, else the best-funded one, else the most recent. # Add stock to a product Source: https://docs.taberna.io/api-reference/products/add-stock-to-a-product /api-reference/openapi.json post /v1/products/{id}/stock Appends delivery stock to a `text_pool` product. Requires the `products:write` scope. Send the units as one `text` blob; it is split on the product's configured delimiter, trimmed, and deduplicated against the units already available or reserved. Lines that collide are reported as `skippedDuplicates` rather than rejected, so re-sending a batch is safe. Any other delivery type is a 400. # List products Source: https://docs.taberna.io/api-reference/products/list-products /api-reference/openapi.json get /v1/products The store's products, newest first, optionally filtered by `listed`. Requires the `products:read` scope. Fulfillment secrets never appear here: delivery is reported as its type plus remaining `stock`, never as pool lines, shared text, file keys or redirect targets. # Retrieve a product Source: https://docs.taberna.io/api-reference/products/retrieve-a-product /api-reference/openapi.json get /v1/products/{id} One product by id. `stock` is the live count of available units for pool delivery, and null when the product's delivery is unlimited or unconfigured. Requires the `products:read` scope. # Retrieve the current store Source: https://docs.taberna.io/api-reference/store/retrieve-the-current-store /api-reference/openapi.json get /v1/store "Whoami" for an API key: the store the key belongs to and the scopes it was granted. Any valid key may call this, whatever it was granted — `scopes` is the calling key's own, so an integration can check what it may do without provoking a 403 to find out. `storefrontUrl` is the canonical public origin (an active custom domain wins over the slug subdomain), and null while the storefront is unclaimed or switched off. # List webhook deliveries Source: https://docs.taberna.io/api-reference/webhook-deliveries/list-webhook-deliveries /api-reference/openapi.json get /v1/webhook-deliveries The delivery history for the store's webhook, newest first, optionally filtered by status. Requires the `webhooks:read` scope. A store with no webhook configured is an empty page, not an error. # Resend a webhook delivery Source: https://docs.taberna.io/api-reference/webhook-deliveries/resend-a-webhook-delivery /api-reference/openapi.json post /v1/webhook-deliveries/{deliveryId}/resend Pushes a delivery back into the dispatcher queue. Requires the `webhooks:write` scope. Only `failed` and `exhausted` deliveries can be replayed — a pending one is already queued and a successful one has nothing to replay. This scope covers replay only; it does not permit changing where a store's events are sent. # Authentication Source: https://docs.taberna.io/authentication Bearer API keys, the eight scopes, and how Taberna answers a bad credential. Every `/v1` merchant endpoint authenticates with a store API key sent as a bearer token: ```http theme={null} Authorization: Bearer tbrn_live_3f1c2b9a7d4e4f889a102c6e5b4d1a90aabbccdd001122 ``` The buyer-facing [`/v1/checkouts`](/checkout) endpoints are the exception: they are deliberately unauthenticated, because they run in the buyer's browser. **Never ship an API key to a page.** ## Key format A live key is always `tbrn_live_` followed by 48 hexadecimal characters. That is the only accepted shape — anything else fails validation before it reaches the store lookup. The `tbrn_live_` prefix exists so that a key leaked into a log, a screenshot or a public repository is recognisable both to a human and to automated secret scanners. A key is **bound to one store**. Taberna derives the store from the key, which is why no `/v1` endpoint takes a store id — `POST /v1/orders` creates an order for the key's store and nothing else. ## Scopes Scopes are chosen when the key is minted and are **fixed for its lifetime**. To change them, create a new key and revoke the old one. | Scope | What it unlocks | | ---------------- | --------------------------------------------------------------------------------- | | `orders:read` | `GET /v1/orders`, `GET /v1/orders/{id}` | | `orders:write` | `POST /v1/orders`, `POST /v1/orders/{id}/expire` | | `invoices:read` | `GET /v1/invoices`, `GET /v1/invoices/{id}`, `GET /v1/invoice-templates` | | `invoices:write` | `POST /v1/invoices`, `POST /v1/invoices/{id}/void`, `POST /v1/invoices/{id}/send` | | `products:read` | `GET /v1/products`, `GET /v1/products/{id}` | | `products:write` | `POST /v1/products/{id}/stock` | | `webhooks:read` | `GET /v1/webhook-deliveries` | | `webhooks:write` | `POST /v1/webhook-deliveries/{deliveryId}/resend` | Notes worth internalising: * **Invoice templates read under `invoices:read`**, not a scope of their own — they are invoice authoring material. * **`webhooks:write` is replay only.** It lets a key push a failed delivery back into the queue. It does **not** permit changing where a store's events are sent; that stays a dashboard action. * **Choosing scopes is mandatory.** There is no implicit full-access key. An empty scope array, or an unknown scope name, is rejected at key creation. Grant the minimum. A server that only sells one thing needs `orders:write` and `orders:read` — nothing else. Every scope you add is blast radius if the key leaks. ### The one endpoint that needs no scope `GET /v1/store` — "whoami" — is callable by any valid key, whatever it was granted. Its response includes the **calling key's own** `scopes` array: ```json theme={null} { "id": "0197f8a0-2222-7bbb-9ddd-4f6e8dac2b35", "name": "My Store", "image": null, "enabledPaymentMethods": ["usdc_base", "sol"], "requireCustomerEmail": true, "createdAt": "2026-07-01T09:12:44.000Z", "slug": "my-store", "storefrontEnabled": true, "scopes": ["orders:read", "orders:write"], "storefrontUrl": "https://my-store.taberna.io" } ``` This exists so an integration can discover what it may do **without provoking a 403 to find out**. Call it at boot, assert the scopes you need, and fail your own startup with a clear message rather than failing a customer's checkout later. `storefrontUrl` is the canonical public origin of the store's storefront (an active custom domain wins over the slug subdomain) and is `null` while the storefront is unclaimed or switched off. ## 401 versus 403 The two are not interchangeable and the distinction matters for how your client should react. Returned for **every** authentication failure: missing header, malformed header, unknown key, revoked key, expired key. ```http theme={null} HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer realm="api", error="invalid_token" Content-Type: application/json { "error": "Unauthorized" } ``` The body is always exactly `{ "error": "Unauthorized" }`. The reason is **deliberately not disclosed** — telling a caller "revoked" rather than "unknown" would confirm to whoever is guessing that a key exists. Never branch on the body. The RFC 6750 challenge in the `WWW-Authenticate` header is the only signal that distinguishes cases: | Challenge | Meaning | | --------------------------------------------- | ----------------------------------------------- | | `Bearer realm="api"` | No `Authorization` header at all | | `Bearer realm="api", error="invalid_request"` | The header was present but malformed | | `Bearer realm="api", error="invalid_token"` | A credential was supplied and failed validation | Returned when the credential is **fine** but the key was not granted the scope the route requires. There is no `WWW-Authenticate` header, because re-challenging for a credential would be the wrong instruction. ```http theme={null} HTTP/1.1 403 Forbidden Content-Type: application/json { "error": "Missing required scope: orders:write" } ``` The message names the exact scope, because it is actionable: mint a key that has it. **Retrying a 403 is pointless, and so is re-authenticating.** Treat it as a configuration error to surface to an operator — never as a signal to refresh a token, drop the credential, or sign a user out. ## Rotation and revocation Keys are minted, listed and revoked from the dashboard by a store **owner**. At creation, and only at creation. Taberna keeps a SHA-256 hash and cannot recover the original. Put it straight into your secret manager. Mint the new key first, deploy it, confirm traffic is flowing on it, then revoke the old one. Because scopes are immutable, a scope change is always a rotation. A revoked key answers `401` on the next request. So does a key past its optional `expiresAt`. Keys created without an expiry do not expire. A key's `lastUsedAt` is tracked, so the key list in the dashboard will tell you whether a key you are about to revoke is still receiving traffic. ## Handling auth in your client ```javascript theme={null} async function tabernaFetch(path, init = {}) { const res = await fetch(`https://api.taberna.io${path}`, { ...init, headers: { ...init.headers, Authorization: `Bearer ${process.env.TABERNA_API_KEY}`, }, }); if (res.status === 401) { // The credential itself is bad. Retrying will not help. throw new Error('Taberna API key is missing, revoked or expired'); } if (res.status === 403) { // Valid key, missing scope. Surface it — do not retry, do not re-auth. const { error } = await res.json(); throw new Error(`Taberna key lacks a required scope: ${error}`); } if (!res.ok) { const body = await res.json(); throw new Error(body.error ?? `Taberna request failed (${res.status})`); } return res; } ``` # Checkout Source: https://docs.taberna.io/checkout The hosted payment pages, and the unauthenticated endpoints that drive them. Taberna hosts the page the buyer pays on. You never build coin selection, exchange-rate display, deposit addresses, QR codes or confirmation progress — you redirect, and you wait for the result. | Resource | Where you get the link | Page | | -------- | ------------------------------------ | ------------------------------- | | Order | `checkoutUrl` from `POST /v1/orders` | `{HOME_URL}/checkout/{shortId}` | | Invoice | `url` from `POST /v1/invoices` | `{HOME_URL}/invoice/{shortId}` | Always use the URL Taberna returned. Do not construct it yourself — the host is environment-specific and the path may change. ## What the buyer sees The page offers the tokens in your store's `enabledPaymentMethods` and nothing else. Choosing one starts a **payment attempt**. Taberna locks a crypto quote, generates a **fresh deposit wallet for this one attempt**, and shows the exact amount and address. The quote holds for up to 30 minutes — precisely, until `min(now + 30 minutes, the order or invoice's own expiry)`, because a locked quote must never outlive its parent. Taberna watches the chain and reports progress: detected, confirming, confirmed. See [confirmation progress](#confirmation-progress). On completion the page shows a **Continue** button pointing at your `successUrl`. The dead-end states — expired, partial, and voided for invoices — link to `cancelUrl`. Without them the buyer simply stays on the hosted page, so setting at least `successUrl` is recommended. ### If the quote lapses Nothing is lost. An attempt that expires holding **zero** funds leaves the parent order or invoice untouched and still payable — the buyer picks a token again and gets a fresh quote, and can keep doing that until the parent itself expires. An attempt that expires holding a **non-zero but insufficient** amount turns the parent `partial`, which is terminal. See [Payment lifecycle](/payment-lifecycle#partial-payments). ### If the buyer switches token mid-attempt Also fine, and worth understanding precisely: * **Re-selecting the same token** returns the *same* attempt — same address, same locked quote. Reloading the page is safe. * **Selecting a different token** abandons the live attempt and locks a new quote on a newly generated address. An abandoned attempt's address **stays watched until its own window ends**. Funds sent to it after the buyer switched still complete the order or invoice. Abandonment is a UI affordance, not a lifecycle event — it emits no webhook. ## The public checkout endpoints The hosted page reads and drives payment through the `/v1/checkouts` endpoints. These are **deliberately unauthenticated**. **Possession of the short id is the credential.** It is unguessable in practice, and every payload served here is redacted for a buyer audience — no buyer email, no merchant `metadata`, no payer IP. Never send an API key from a browser. The corollary: do not treat the id as a secret beyond being unguessable, and **never grant value based on a status a browser reported**. Fulfil from a webhook or a server-side read. ### Retrieve a checkout `GET /v1/checkouts/{id}` — no auth. `{id}` accepts **either an order short id or an invoice short id**; one resource serves both hosted pages. The response is a **tagged union**: read `type` first, then the branch fields. ```json theme={null} { "type": "order", "id": "CcvnKdAXzvW8h3JaS1VJe", "storeId": "0197f8a0-2222-7bbb-9ddd-4f6e8dac2b35", "status": "pending", "priceAmount": "50", "priceCurrency": "usd", "successUrl": "https://yourapp.com/thanks", "cancelUrl": "https://yourapp.com/cart", "expiresAt": "2026-07-09T12:30:00.000Z", "completedAt": null, "items": [ { "productId": "0197f8a0-1111-7aaa-8ccc-3e5d7c9b1a24", "title": "Pro licence", "image": null, "quantity": 1, "unitPrice": "50", "delivery": null } ], "hasCustomerEmail": true, "store": { "id": "0197f8a0-2222-7bbb-9ddd-4f6e8dac2b35", "name": "My Store", "image": null, "enabledPaymentMethods": ["usdc_base", "sol"], "requireCustomerEmail": true }, "activeAttempt": null } ``` `items[].delivery` is `null` until the order is `completed` — see [Products and delivery](/products-and-delivery#what-delivery-looks-like-on-a-completed-order). Deliberately absent: `customerEmail`, `extraContext`, the payer IP. The page only learns **whether** an email is on file, via `hasCustomerEmail`. ```json theme={null} { "type": "invoice", "id": "CcvnBwRQaKY1rXSMLrbYe", "number": 42, "status": "open", "items": [ { "name": "124 credits", "description": null, "quantity": 1, "unitAmount": "124", "amount": "124" } ], "totalAmount": "124", "currency": "usd", "customerName": "Ada Lovelace", "memo": null, "successUrl": "https://yourapp.com/topup/success", "cancelUrl": "https://yourapp.com/topup", "expiresAt": "2026-07-09T13:00:00.000Z", "paidAt": null, "store": { "id": "0197f8a0-2222-7bbb-9ddd-4f6e8dac2b35", "name": "My Store", "image": null, "enabledPaymentMethods": ["usdc_base", "sol"] }, "activeAttempt": null } ``` Deliberately absent: `metadata`, `customerEmail`, `createdVia`. The invoice branch also carries no `requireCustomerEmail` on `store` — an invoice's recipient is set by the merchant who issued it. A well-formed id that resolves to nothing answers `404` with `{ "error": "Checkout not found" }` — **the same message for both resources**, so the endpoint never reveals which one you probed. **A malformed id never reaches the lookup.** Every route that takes a short id validates it against the short-id pattern first — 21 or 22 base58 characters — and an id that fails that check is rejected by the validator as `400 { "error": "Invalid request data" }`. `404` is reserved for ids that pass the pattern and then either fail to decode or decode to nothing. ### The active attempt `activeAttempt` is `null` when no quote is live, and otherwise: ```json theme={null} { "status": "processing", "paymentMethod": "usdc_base", "cryptoAmount": "50010000", "receivedAmount": "50010000", "destinationAddress": "0x…", "transactions": [ { "blockchain": "base", "txHash": "0xabc…", "fromAddress": "0xdef…", "amount": "50010000", "blockNumber": "21458822", "blockTime": "2026-07-09T12:15:02.000Z" } ], "confirmation": { "state": "confirming", "confirmations": 4, "required": 10, "stale": false }, "expiresAt": "2026-07-09T12:30:00.000Z", "completedAt": null } ``` `cryptoAmount` and `receivedAmount` are integer strings in the token's smallest unit. `transactions` is `[]` until a transfer is attributed. ### Confirmation progress `confirmation` is payer-facing progress, and nothing gates on it. | Field | Meaning | | --------------- | ------------------------------------------------------------------ | | `state` | `waiting`, `detected`, `confirming` or `confirmed` | | `confirmations` | Depth reached, clamped to at most `required`. `null` when unknown. | | `required` | Depth the chain's indexer is waiting for. `null` when unknown. | | `stale` | `true` when the counts cannot be trusted | **Do not render progress while `stale` is `true`**, and do not treat `confirmed` as "paid". A `confirmed` state says the transfers on file are buried deeply enough — it says nothing about whether they add up to the amount owed. An underpayment can sit at `confirmed` indefinitely. The authoritative signal is the order or invoice `status`. See [Payment lifecycle](/payment-lifecycle#confirmations) for how each state arises and what the per-chain depths are. ### Start a payment attempt `POST /v1/checkouts/{id}/attempts` — no auth. Driven by the checkout UI; you normally do not call this. ```json Request theme={null} { "paymentMethod": "usdc_base" } ``` Returns the refreshed checkout union — the same payload as the GET, now carrying the new `activeAttempt`. Works on both branches. | Status | Message | Cause | | ------ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `404` | `Checkout not found` | A well-formed id that resolves to nothing | | `400` | `Invalid request data` | The id is not a well-formed short id, or the body failed validation — e.g. an unrecognised `paymentMethod` | | `400` | `Payment method is not enabled for this store` | Valid token, but not in `enabledPaymentMethods` | | `400` | `Order is not payable` / `Invoice is not payable` | The parent is no longer `pending` / `open` | | `400` | `Order has expired` / `Invoice has expired` | The parent's window has passed | | `400` | `Order is no longer payable` / `Invoice is no longer payable` | The parent changed state while the attempt was being created | | `400` | `Exchange rates are temporarily unavailable, please try again` | No fresh enough rate to quote against | Taberna refuses to price an attempt on a stale exchange rate rather than risk mis-quoting it. That last error is transient — the buyer retrying a moment later normally succeeds. ### Attach a receipt email (orders only) `PUT /v1/checkouts/{id}/email` — no auth. ```json Request theme={null} { "email": "buyer@example.com" } ``` ```json 200 OK theme={null} { "hasCustomerEmail": true } ``` The address is **never echoed back** — the checkout payload only ever reports the boolean. | Status | Message | Cause | | ------ | -------------------------------------- | ------------------------------------------------------------------------------ | | `404` | `Checkout not found` | A well-formed id that resolves to nothing | | `400` | `Invalid request data` | The id is not a well-formed short id, or the body is not a valid email address | | `400` | `Order is no longer accepting changes` | The order is no longer `pending` | | `400` | `Not applicable to invoice checkouts` | The id belongs to an invoice | This route is **order-only**. An invoice's recipient is set by the merchant who issued it, so passing an invoice id is a `400` — not a `404`. ### Get a delivery download link (orders only) `GET /v1/checkouts/{id}/download/{orderItemId}` — no auth. Mints a short-lived presigned URL for a `file_shared` delivery on a **completed** order item. ```json 200 OK theme={null} { "url": "https://…presigned…", "fileName": "handbook.pdf", "expiresIn": 300 } ``` `expiresIn` is the link's lifetime in seconds — **300**, five minutes. Every substantive failure — unknown checkout, order not completed, wrong item, a delivery that is not a file, an invoice id — answers with the **same** `404` and the **same** message, `{ "error": "Download not available" }`, so the endpoint never reveals which check failed. Both path segments are short ids and both are validated first, as everywhere else: if `{id}` or `{orderItemId}` is not a well-formed short id the request is rejected as `400 { "error": "Invalid request data" }` before any lookup runs. ## Polling You may poll `GET /v1/checkouts/{id}` as a fallback, and the hosted page does exactly that while an attempt is live. But for **your** integration: **Webhooks are the recommended completion signal.** If you poll from your own server, poll the authenticated endpoints — `GET /v1/orders/{id}` or `GET /v1/invoices/{id}` — not the public checkout resource. They are the merchant view, they carry `extraContext` and `metadata`, and they are not readable by anyone holding the link. Reasonable polling shape, if you need one: ```javascript theme={null} // Reconciliation fallback — webhooks remain primary. async function reconcile(orderId, { attempts = 20, intervalMs = 15_000 } = {}) { for (let i = 0; i < attempts; i++) { const res = await fetch(`https://api.taberna.io/v1/orders/${orderId}`, { headers: { Authorization: `Bearer ${process.env.TABERNA_API_KEY}` }, }); if (!res.ok) throw new Error((await res.json()).error); const order = await res.json(); if (order.status !== 'pending') return order; // completed, partial or expired await new Promise((r) => setTimeout(r, intervalMs)); } return null; // still pending — leave it to the webhook } ``` Stop polling as soon as `status` leaves `pending` (or `open`, for invoices): every other state is terminal. # Errors and limits Source: https://docs.taberna.io/errors-and-limits The error shape, status conventions, request limits, and what Taberna does not do yet. ## The error shape Every non-2xx response on the API carries the same body: ```json theme={null} { "error": "A human-readable message" } ``` There is **one documented exception**: [`POST /v1/invoices/{id}/send`](/invoices#the-rate-limit-envelope), which adds `code` and `retryAfterSeconds` on every failure the mailer produces. `error` is a **human-readable message, not a stable machine code**. Log it, surface it to an operator, branch on the HTTP status — but do not build control flow on exact string equality unless a page here documents that string as the signal (as the `/void` and `/expire` endpoints do, where the status alone is ambiguous). ## Status conventions | Status | When | | ------ | ------------------------------------------------------------------------------------------- | | `200` | Success with a body | | `201` | A resource was created — `POST /v1/orders`, `POST /v1/invoices` | | `204` | Success with no body — `POST /v1/webhook-deliveries/{deliveryId}/resend` | | `400` | Validation failure, **or** an operation that is not allowed in the resource's current state | | `401` | Missing, malformed, unknown, revoked or expired API key | | `403` | Valid key, but it lacks the scope the route requires | | `404` | No such resource **for this API key's store**, or no such route | | `413` | The request body exceeded 8 MiB | | `429` | A send limit was hit (invoice email only) | | `500` | An unhandled server-side failure — body is always `{ "error": "Internal server error" }` | | `502` | The email provider rejected a send (invoice email only) | A request to a path the API does not serve answers `404` with `{ "error": "Not found" }` — distinct from the per-resource `404` messages below, and a useful signal that a URL is wrong rather than a resource missing. Three conventions worth stating explicitly. A body, query string or path parameter that fails schema validation answers: ```json theme={null} { "error": "Invalid request data" } ``` The specific field is deliberately not disclosed. Check your request against the [API Reference](/api-reference) schema rather than trying to parse the message. **This includes malformed short ids, on every route that takes one.** Order, invoice and checkout ids are validated against the short-id pattern — 21 or 22 base58 characters — before any lookup happens, so `/v1/orders/nope` is a `400` here, not a `404`. `404` is reserved for an id that passes the pattern and then fails to decode, or decodes to a resource that does not exist. There is no per-route exception to this. A resource that exists but belongs to a different store answers exactly like one that does not exist — `404`, same message. This is deliberate: the alternative would let anyone with a key confirm the existence of another store's ids. The same principle drives the checkout `404`: an unknown order id and an unknown invoice id both return `{ "error": "Checkout not found" }`, so the endpoint never reveals which resource was probed. **One place does not follow it.** Creating an order with a `productId` that exists but belongs to another store answers `400 { "error": "Product does not belong to this store" }`, which does confirm the id exists. A product id that exists nowhere answers `Product not found` instead, so on `POST /v1/orders` the two cases are distinguishable. Everywhere else, another store's resource is indistinguishable from a missing one. `401` is a bad credential — retrying with the same key will not help, and the body is always exactly `{ "error": "Unauthorized" }` with the reason withheld. The `WWW-Authenticate` header carries the RFC 6750 challenge. `403` is a good credential without the right scope, and names the scope: `{ "error": "Missing required scope: orders:write" }`. **Never retry it, and never re-authenticate or sign a user out on it.** See [Authentication](/authentication#401-versus-403). ### Two endpoints where the status is ambiguous On `POST /v1/invoices/{id}/void` and `POST /v1/invoices/{id}/send`, an id that is not a well-formed short id is a `400 { "error": "Invalid request data" }` like everywhere else. Past that, both reserve `404` for an id that passes the pattern but **fails to decode**, and then report a decodable id with no invoice behind it differently: * `/void` answers `400 { "error": "Invoice not found" }`. * `/send` answers `404` with the full `{ error, code, retryAfterSeconds }` envelope and `code: "not_found"`. So on `/void`, "no such invoice" can arrive as either status depending on where the id failed. Branch on the message or the `code` for these two, not on the status alone. ## Request limits | Limit | Value | | -------------------------- | -------------------------------------------------------------------------------------------------------------- | | Request body | **8 MiB** (8,388,608 bytes) — exceeded gives `413 { "error": "Payload too large" }` | | Order line items | 1 to 20 | | Order item quantity | 1 to 1,000,000 | | Invoice line items | 1 to 100 | | Invoice item quantity | 1 to 1,000,000 | | `Idempotency-Key` | 1 to 255 characters | | `successUrl` / `cancelUrl` | 2048 characters, `http` or `https` only | | `memo` | 5000 characters | | Invoice item `description` | 2000 characters | | Stock append `text` | 5,000,000 characters, and at most 10,000 lines per call — counted after repeats within the batch are collapsed | | List `limit` | 1 to 100, defaults to 20 | | Fiat decimal places | 3 | The 8 MiB cap is on **encoded bytes**, not characters. A stock-append `text` well under its 5,000,000-character limit can still `413` once non-ASCII content is UTF-8 encoded. See [Appending stock](/products-and-delivery#appending-stock). ### Invoice email limits Emailing an invoice is rate-limited at three levels — per invoice, per store over a rolling 24 hours, and platform-wide. A refusal is a `429` carrying `retryAfterSeconds` and a matching `Retry-After` header when the block is a cooldown you can wait out. Check `email.canSend` on the invoice rather than probing. Details in [Email an invoice](/invoices#email-an-invoice). ## Idempotency `POST /v1/orders` and `POST /v1/invoices` accept an optional `Idempotency-Key` header. A retried create with a key this store has already used returns the **original** resource — same `id`, same status code (`201`) — instead of minting a second one. The replay returns the original resource **no matter how the request body changed**. Taberna does not compare bodies and does not error on a mismatch. Reuse a key only for a retry of the *same* logical purchase. They are scoped to your store and kept indefinitely. A create months later that reuses an old key returns that old order or invoice. Derive keys from something naturally unique — your own purchase id — rather than a counter that could wrap. A key outside the 1–255 character range is rejected with `400 { "error": "Idempotency-Key must be between 1 and 255 characters" }`. Sending the header is cheap and the failure mode it prevents — a network timeout on a create, retried, producing two charges — is expensive. Send it on every create. ## Current limitations Stated plainly. These are the things people are most often surprised by. ### No test mode There is no sandbox and no test key prefix. **Every payment method is a live chain and every payment moves real funds.** Develop against small amounts on a low-fee chain, and expect that reaching `completed` requires an actual transfer. ### No refunds Taberna does not issue refunds. Funds settle to your own payout wallet, so returning money is something you do from that wallet, off-platform, on your own terms. This includes underpayments: an attempt that lapses holding insufficient funds settles as `partial`, terminal, with no automatic return. See [Partial payments](/payment-lifecycle#partial-payments). ### One webhook URL per store, no event filtering Every event for a store goes to a single endpoint. You cannot register a second URL, and you cannot subscribe to a subset of the ten event types — filter on `event` in your handler. See [Webhooks](/webhooks#current-limitations). Also true today: | Limitation | What to do instead | | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | **No API route to create products, stores, invoice templates or API keys** | Create them in the dashboard. API keys can read products, the store and templates, and append stock. | | **Fiat currencies limited to USD and EUR** | Price in one of the two; the buyer pays in whichever token you enabled. | | **Orders default to a 30-minute window** | Pass `expiresIn` (10 minutes to 90 days), or create orders close to checkout. | | **Invoices default to a 24-hour window** | Same `expiresIn` range. | | **Only one product type, `single-purchase`** | Model recurring or metered billing as repeated charges from your own scheduler. | | **The delivery delimiter and selection order are dashboard-only** | Configure them once in the dashboard; the API appends stock, it does not reconfigure. | ## Getting help When something is wrong and you need to report it, include: * The **resource short id** (`data.id`, the value in the checkout link), or the **product UUID**. * For a webhook problem, the **`X-Taberna-Delivery-Id`** — it is stable across retries and matches the dashboard's deliveries view exactly. * The HTTP status and the `error` message you received, verbatim. # Introduction Source: https://docs.taberna.io/introduction Taberna lets you charge a customer in crypto, price it in fiat, and settle straight to your own wallet. Taberna is a payments API for digital goods. You create a charge, send the buyer to a hosted payment page, and Taberna tells you when the money lands on-chain. Everything is **priced in fiat** — USD or EUR — and **paid in crypto**. The buyer sees `$50.00`, picks a token at checkout, and Taberna handles the exchange-rate conversion, the deposit address, and the on-chain watching. You never touch a wallet, an RPC node, or a price feed. Create a store, add a product, mint a key, `POST /v1/orders`, redirect, fulfil on the webhook. Every endpoint, generated from the running service's OpenAPI document. ## What you get **13 payment methods across 5 chains.** Native coins and stablecoins on Solana, Ethereum, Base, Polygon and BNB Chain. You choose which ones your store accepts; the buyer picks from that list. | Chain | `blockchain` | Payment methods | | --------- | ------------ | --------------------------------------- | | Solana | `solana` | `sol`, `usdc_solana` | | Ethereum | `ethereum` | `eth`, `usdc_ethereum`, `usdt_ethereum` | | Base | `base` | `eth_base`, `usdc_base`, `usdt_base` | | Polygon | `polygon` | `matic`, `usdc_polygon`, `usdt_polygon` | | BNB Chain | `bnb` | `bnb`, `usdt_bnb` | **Settlement to your own wallet.** You register a payout address per chain. Each payment lands in a fresh deposit wallet that Taberna generates for that one attempt, and is then swept on-chain to your address. There is no Taberna balance to withdraw from and no payout schedule to wait on. **One processing fee, and nothing else.** The standard rate is 3%, set per store and visible in the dashboard. It is deducted on-chain during the sweep, so you receive the payment amount minus your rate. The buyer pays their own network fee to send the payment; the sweep's gas is paid by Taberna, not out of your funds. Processors that quote a percentage and then add network or withdrawal fees on top deduct more than one line — here the processing fee is the whole deduction. See [Settlement](/payment-lifecycle#settlement). **A hosted checkout you don't have to build.** Coin selection, live exchange rates, the deposit address and QR, confirmation progress, receipts and digital delivery are all handled on Taberna's pages. ## Core concepts Your merchant account. A store owns products, invoices, API keys, payout wallets, and one webhook endpoint. Everything an API key can reach belongs to exactly one store — the key carries the store identity, so you never pass a store id to `/v1` endpoints. A fixed-price item in your catalogue, with an optional digital delivery attached (a pool of licence keys, a shared file, a redirect). Orders are always for products. See [Products and delivery](/products-and-delivery). A cart of one or more products, each with a quantity. Created through `POST /v1/orders`, it returns a `checkoutUrl` to send the buyer to. Expires 30 minutes after creation unless you widen the window. See [Orders](/orders). A bill with line items and amounts you compose per request — no product setup. The right tool whenever the amount is decided at request time. Carries a per-store sequential `number`, arbitrary `metadata`, and its own hosted page. See [Invoices](/invoices). A short-lived payment session under an order or an invoice. It is created when the buyer picks a token: Taberna locks a crypto quote for up to 30 minutes on a freshly generated deposit wallet and watches the chain. If the quote lapses unpaid, the parent order or invoice stays payable and the buyer simply picks a token again. See [Payment lifecycle](/payment-lifecycle). The buyer-facing hosted page, plus the unauthenticated `/v1/checkouts` endpoints that drive it. One resource serves both orders and invoices, keyed by the public short id. See [Checkout](/checkout). A bearer token bound to one store, carrying an explicit set of scopes — the set of `/v1` endpoints it may reach. There is no implicit full-access key. See [Authentication](/authentication). One HTTP endpoint per store that Taberna POSTs lifecycle events to, signed with HMAC-SHA256 and retried for days on failure. This is the completion signal you should build on. See [Webhooks](/webhooks). ## Two ways to charge Best when you sell a **fixed set of things** at fixed prices — plans, tiers, licences, downloads. Create the products once (in the dashboard), then reference them by id: ```json theme={null} { "items": [{ "productId": "0197f8a0-…", "quantity": 2 }] } ``` Taberna computes the total, reserves any finite delivery stock for the order's lifetime, and attaches the delivery to the completed order. Best when the **amount is decided per request** — account credits, usage top-ups, consulting fees, one-off bills. No product setup at all; you compose the line items in the create call: ```json theme={null} { "items": [{ "name": "124 credits", "quantity": 1, "unitAmount": "124" }] } ``` Invoices also carry `metadata`, a sequential `number`, a customer name and memo, and can be emailed to the customer. ## Dashboard versus API The split is deliberate: anything that **configures** the store is a human action in the dashboard; anything that **transacts** is available to your server. | In the dashboard (logged-in human) | Via API key (your server) | | ---------------------------------------------- | ---------------------------------------------------------------- | | Create the store, set its name and image | Read the store — `GET /v1/store` | | Enable payment methods, set payout wallets | — | | Create and edit products, configure delivery | Read products, append delivery stock — `/v1/products` | | Create and edit invoice templates | List templates — `GET /v1/invoice-templates` | | Mint and revoke API keys | — | | Set the webhook URL, rotate the signing secret | List and resend deliveries — `/v1/webhook-deliveries` | | Browse and manage orders and invoices | Create, read, expire, void, email — `/v1/orders`, `/v1/invoices` | There is no API-key route to create products, stores, invoice templates or API keys. That is a current limitation, stated plainly in [Errors and limits](/errors-and-limits#current-limitations). ## Conventions These hold everywhere in the API, in both directions. | Convention | Format | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Fiat amounts** | Human decimal strings, never numbers — `"50"`, `"9.99"`. Up to 3 decimal places, no trailing-zero padding, so `"50"` means \$50.00. | | **Crypto amounts** | Integer strings in the asset's smallest unit — wei, lamports, token base units. `"50010000"` is 50.01 USDC (6 decimals). | | **Timestamps** | ISO-8601, UTC — `"2026-07-09T12:30:00.000Z"`. | | **Resource ids** | Orders and invoices use a **public short id** — a short base58 string like `CcvnKdAXzvW8h3JaS1VJe`, the value that appears in checkout links. Products, stores and webhook deliveries use UUIDs. | | **Errors** | `{ "error": "…" }`, with one documented exception: the invoice-send endpoint adds `code` and `retryAfterSeconds`. See [Errors and limits](/errors-and-limits). | ## Base URL ``` https://api.taberna.io ``` Programmatic endpoints live under the versioned `/v1` prefix. The buyer-facing hosted pages live on a separate host, and Taberna returns their full URLs (`checkoutUrl` for orders, `url` for invoices) when you create the resource — never construct them yourself. # Invoices Source: https://docs.taberna.io/invoices Bill for an amount you compose per request, with no product catalogue. An invoice is a bill with line items and amounts **you supply in the create call**. No product setup, no catalogue — which makes it the right tool whenever the amount is decided at request time: account credits, usage top-ups, consulting fees, one-off bills. Every invoice gets a per-store sequential `number`, its own hosted payment page, and an arbitrary `metadata` object echoed back in every webhook for it. ## Create an invoice `POST /v1/invoices` — requires the `invoices:write` scope. ### Request body | Field | Type | Required | Notes | | --------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `items` | array | see note | Up to **100** line items. See the item shape below. | | `templateId` | string | no | UUID of a dashboard-created [invoice template](#invoice-templates). Supplies defaults for `items`, `memo` and expiry. | | `currency` | enum | no | `usd` (default) or `eur`. | | `customerName` | string | no | At most 255 characters. Shown on the hosted page as who the invoice is billed to. | | `customerEmail` | string | no | Stored and echoed in webhooks; **not** shown on the public page. Required if you want to use the [send endpoint](#email-an-invoice), and a receipt is sent here once paid. | | `memo` | string | no | At most 5000 characters. Free-text note shown on the hosted page. | | `metadata` | object | no | Arbitrary JSON, echoed back verbatim in every `invoice.*` webhook. **Use this to correlate with your own records.** | | `successUrl` | string | no | `http`/`https` URL, at most 2048 characters. A **Continue** button links here once paid. | | `cancelUrl` | string | no | `http`/`https` URL, at most 2048 characters. Shown on the dead-end states — expired, voided, partial. | | `expiresIn` | integer | no | Minutes the invoice stays payable: **10** to **129,600** (90 days). Defaults to the template's expiry, or **1440** (24 hours). | `items` and `templateId` are jointly required — supply at least one. A body with neither fails schema validation and comes back as the generic `{ "error": "Invalid request data" }`. When both are present, `items` **replaces** the template's items wholesale rather than merging with them. The same wholesale-replacement rule applies to `memo` and the expiry. Each entry in `items`: | Field | Type | Required | Notes | | ------------- | ------- | -------- | ------------------------------------------------------------------------------------- | | `name` | string | yes | 1 to 255 characters. | | `description` | string | no | At most 2000 characters. | | `quantity` | integer | yes | Positive, at most 1,000,000. | | `unitAmount` | string | yes | A fiat **decimal string** — `"1"`, `"9.99"` — up to 3 decimal places. Never a number. | The invoice total is Σ (`unitAmount` × `quantity`), and must come out greater than zero. ### The `Idempotency-Key` header Identical semantics to [orders](/orders#the-idempotency-key-header): optional, 1 to 255 characters, unique per store. A retried create with a key this store has used before returns the **original** invoice instead of minting a second one, whatever the body says. Keys never expire. ### Example ```bash cURL theme={null} curl -X POST https://api.taberna.io/v1/invoices \ -H 'Authorization: Bearer tbrn_live_…' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: purchase_8213' \ -d '{ "items": [{ "name": "124 credits", "quantity": 1, "unitAmount": "124" }], "customerEmail": "buyer@example.com", "customerName": "Ada Lovelace", "expiresIn": 60, "metadata": { "userId": "u_123", "credits": 124, "purchaseId": "p_8213" }, "successUrl": "https://yourapp.com/topup/success", "cancelUrl": "https://yourapp.com/topup" }' ``` ```javascript Node.js theme={null} const res = await fetch('https://api.taberna.io/v1/invoices', { method: 'POST', headers: { Authorization: `Bearer ${process.env.TABERNA_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': purchaseId, }, body: JSON.stringify({ items: [{ name: `${credits} credits`, quantity: 1, unitAmount: String(credits) }], customerEmail: user.email, expiresIn: 60, metadata: { userId: user.id, credits, purchaseId }, successUrl: 'https://yourapp.com/topup/success', cancelUrl: 'https://yourapp.com/topup', }), }); if (!res.ok) throw new Error((await res.json()).error); const invoice = await res.json(); // Send the customer to invoice.url ``` ```python Python theme={null} res = requests.post( "https://api.taberna.io/v1/invoices", headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Idempotency-Key": purchase_id, }, json={ "items": [{"name": f"{credits} credits", "quantity": 1, "unitAmount": str(credits)}], "customerEmail": user.email, "expiresIn": 60, "metadata": {"userId": user.id, "credits": credits, "purchaseId": purchase_id}, "successUrl": "https://yourapp.com/topup/success", "cancelUrl": "https://yourapp.com/topup", }, ) res.raise_for_status() invoice = res.json() ``` ```json 201 Created theme={null} { "id": "CcvnBwRQaKY1rXSMLrbYe", "number": 42, "status": "open", "url": "https://taberna.io/invoice/CcvnBwRQaKY1rXSMLrbYe", "items": [ { "name": "124 credits", "description": null, "quantity": 1, "unitAmount": "124", "amount": "124" } ], "totalAmount": "124", "currency": "usd", "customerName": "Ada Lovelace", "customerEmail": "buyer@example.com", "memo": null, "metadata": { "userId": "u_123", "credits": 124, "purchaseId": "p_8213" }, "successUrl": "https://yourapp.com/topup/success", "cancelUrl": "https://yourapp.com/topup", "createdVia": "api", "expiresAt": "2026-07-09T13:00:00.000Z", "paidAt": null, "voidedAt": null, "createdAt": "2026-07-09T12:00:00.000Z", "email": { "sendCount": 0, "sendsRemaining": 3, "maxSends": 3, "lastSentAt": null, "cooldownUntil": null, "canSend": true, "blockedReason": null } } ``` Redirect the customer to `url`. The hosted invoice page shows the line items, the total, your store branding, and the same pick-a-token → locked-quote payment flow as the order checkout. `createdVia` is `api` for invoices your server created and `dashboard` for ones a human issued. The `email` object is the live send allowance — see [Email an invoice](#email-an-invoice). ### Errors All `400` with a `{ "error": … }` body. Anything the request **schema** rejects — neither `items` nor `templateId`, an empty `items`, more than 100 line items, `expiresIn` outside 10 to 129,600, a `unitAmount` that is not a decimal string of at most 3 places, a `name` over 255 characters — never reaches the invoice logic. The validator answers first, always with the same generic body: ```json 400 Bad Request theme={null} { "error": "Invalid request data" } ``` The same item-count and expiry bounds are enforced on templates when they are authored, so resolving `items` or the expiry **from a template** cannot smuggle a violation past this either. The messages below are the ones the invoice logic itself produces, once the body's shape is already known to be valid. | Message | Cause | | ----------------------------------------- | --------------------------------------------------- | | `Invoice total must be greater than zero` | The computed total is zero — every line is `"0"` | | `Template not found` | Unknown `templateId`, or one from another store | | `Store not found` | The key's store no longer exists | | `Store has no payment methods enabled` | Enable at least one payment method in the dashboard | ## Retrieve an invoice `GET /v1/invoices/{id}` — requires `invoices:read`. `{id}` is the invoice's short id. Returns the same shape as the create response **plus** a `transactions` array: the on-chain transfers that paid it, across every payment attempt. ```json 200 OK theme={null} { "id": "CcvnBwRQaKY1rXSMLrbYe", "number": 42, "status": "paid", "totalAmount": "124", "currency": "usd", "paidAt": "2026-07-09T12:15:04.000Z", "transactions": [ { "blockchain": "solana", "txHash": "5Nx…", "fromAddress": "9xQ…", "amount": "124000000", "blockNumber": "298471223", "blockTime": "2026-07-09T12:15:02.000Z" } ] } ``` An id that is not a well-formed short id fails the route's validation and answers `400` with `{ "error": "Invalid request data" }`. An id that is well-formed but does not resolve — undecodable, unknown, or an invoice belonging to another store — answers `404` with `{ "error": "Invoice not found" }`. ## List invoices `GET /v1/invoices` — requires `invoices:read`. Newest first. | Query parameter | Type | Notes | | --------------- | ------- | ---------------------------------------------- | | `page` | integer | Defaults to `1` | | `limit` | integer | 1 to 100, defaults to `20` | | `status` | enum | `open`, `paid`, `partial`, `expired`, `voided` | ```json 200 OK theme={null} { "data": [ /* invoices */ ], "pagination": { "page": 1, "limit": 20, "total": 91, "totalPages": 5 } } ``` ## Void an invoice `POST /v1/invoices/{id}/void` — requires `invoices:write`. Marks an **open** invoice as `voided` so it can no longer be paid, and fires `invoice.voided`. Returns `200` with the invoice in its new state. ### What a void does to the invoice's payment attempts A **live** attempt — `processing`, quote not yet lapsed — refuses the call outright with `Invoice has a payment in progress — try again after it expires`. A buyer mid-payment does not get the invoice pulled out from under them. Past that guard, the void **retires every still-watchable attempt first**. This is the part worth understanding: the payment indexers select attempts on status alone, so an attempt whose quote window has already lapsed but which no indexer tick has reaped yet is still being watched and its deposit address is still payable. Voiding around such an attempt would leave a live address collecting funds for an invoice that can never be paid, so they are retired as part of the call. If funds had already landed on one of those attempts, retiring it makes the **invoice `partial`** — terminal, and resolved manually by you — rather than `voided`. The call then fails with `Only open invoices can be voided`, because by the time the void would apply the invoice is no longer open. Re-read the invoice to see the `partial` status. | Status | Message | Cause | | ------ | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `400` | `Invalid request data` | `{id}` is not a well-formed short id | | `404` | `Invoice not found` | Well-formed id that does not decode | | `400` | `Invoice not found` | The id decodes but there is no such invoice for this key's store | | `400` | `Only open invoices can be voided` | Already `paid`, `partial`, `expired` or `voided` — including an invoice this very call just turned `partial` | | `400` | `Invoice has a payment in progress — try again after it expires` | A live attempt blocks the void, or a fully-funded one is still settling | Note the status quirk in the table: on **this endpoint only**, a well-formed id with no matching invoice is reported as `400`, not `404` — `404` is reserved for an id that passes the short-id format check but does not decode. Do not branch on the status here; branch on the message. ## Email an invoice `POST /v1/invoices/{id}/send` — requires `invoices:write`. **Takes no request body.** The recipient is always the invoice's own stored `customerEmail` and cannot be overridden. That is what stops the endpoint from being an open relay. Creating an invoice never emails anyone; this call is the only thing that does. ```json 200 OK theme={null} { "invoiceId": "CcvnBwRQaKY1rXSMLrbYe", "sentTo": "buyer@example.com", "sentAt": "2026-07-09T12:05:00.000Z", "sendCount": 1, "sendsRemaining": 2, "maxSends": 3 } ``` ### The rate-limit envelope This is the **one endpoint whose error body differs** from the rest of the API. Every failure the mailer produces carries three fields: ```json theme={null} { "error": "This invoice was emailed less than 5 minutes ago. Try again in 4 minutes.", "code": "rate_limited", "retryAfterSeconds": 214 } ``` * `code` is one of `not_found`, `not_sendable`, `rate_limited`, `send_failed`. * `retryAfterSeconds` is **always present**, and `null` when the block is not a cooldown you can wait out (an exhausted per-invoice allowance, for example). * When `retryAfterSeconds` is set, the same value is mirrored in a **`Retry-After` header**. | Status | `code` | When | | ------ | -------------- | ----------------------------------------------------------------------------------------------------------- | | `400` | `not_sendable` | The invoice is not `open`, or has no stored `customerEmail` | | `404` | `not_found` | No such invoice for this key's store | | `429` | `rate_limited` | A per-invoice cap, a per-invoice cooldown, a per-store 24-hour cap, or a platform-wide pause | | `502` | `send_failed` | The email provider rejected the send. **The attempt does not count against the allowance — safe to retry.** | A request rejected **before** it reaches the mailer answers with the plain `{ "error": … }` shape instead, with no `code` and no `retryAfterSeconds`: a malformed `{id}` is `400 { "error": "Invalid request data" }`, and a well-formed id that does not decode is `404 { "error": "Invoice not found" }`. Read `code` defensively. The allowance is also readable without attempting a send: every invoice payload carries an `email` object. | Field | Meaning | | ---------------- | ------------------------------------------------------------------- | | `sendCount` | Sends made so far for this invoice | | `sendsRemaining` | How many are left before the per-invoice cap | | `maxSends` | The per-invoice cap | | `lastSentAt` | Timestamp of the last send, or `null` | | `cooldownUntil` | When the minimum-interval cooldown lifts, or `null` | | `canSend` | Whether a send would be accepted right now | | `blockedReason` | A human-readable reason when `canSend` is `false`, otherwise `null` | Check `email.canSend` before calling the send endpoint rather than treating a `429` as your signal. It costs nothing — the field is on the invoice you already have. ## Invoice templates `GET /v1/invoice-templates` — requires `invoices:read`. Templates are reusable presets — default items, memo and expiry — **authored in the dashboard only**. This read exists so your server can discover template ids instead of hard-coding them. The response is the store's templates, newest first, **unpaginated** (the set is small and merchant-curated). ```json 200 OK theme={null} [ { "id": "0197f8a0-4444-7ddd-8fff-6a8b0c2d4e57", "name": "Monthly retainer", "memo": "Payable on receipt.", "expiresInMinutes": 10080, "items": [ { "name": "Retainer — standard", "description": null, "quantity": 1, "unitAmount": "1500" } ], "createdAt": "2026-06-02T08:00:00.000Z" } ] ``` Pass a row's `id` as `templateId` on `POST /v1/invoices`. Remember that anything you send explicitly replaces the corresponding template default wholesale. ## Statuses | Status | Meaning | | --------- | -------------------------------------------------------------------------------------------------- | | `open` | Payable. Buyers can start and retry payment attempts. Initial state. | | `paid` | An attempt received the full amount on-chain. **Terminal — fulfil here.** | | `partial` | An attempt's window lapsed holding a non-zero but insufficient amount. Terminal; resolve manually. | | `expired` | `expiresAt` passed with nothing received. Terminal. | | `voided` | Cancelled before payment, by you through the API or by a human in the dashboard. Terminal. | ```mermaid theme={null} stateDiagram-v2 [*] --> open: POST /v1/invoices open --> paid: an attempt receives the full amount open --> partial: an attempt lapses holding partial funds open --> expired: expiresAt passes with nothing received open --> voided: the void endpoint is called paid --> [*] partial --> [*] expired --> [*] voided --> [*] ``` See [Payment lifecycle](/payment-lifecycle) for what happens underneath, and [Webhooks](/webhooks) for the five `invoice.*` events. # Orders Source: https://docs.taberna.io/orders Charge for a cart of catalogue products through a hosted checkout. An order is a purchase of one or more **listed products**, each with a quantity. Taberna computes the total from the products' own prices, reserves any finite delivery stock for the order's lifetime, and hands you a hosted checkout link. Use orders when your prices live in a catalogue. When the amount is composed per request, use [Invoices](/invoices) instead. ## Create an order `POST /v1/orders` — requires the `orders:write` scope. ### Request body | Field | Type | Required | Notes | | --------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `items` | array | yes | 1 to **20** line items, each `{ "productId": string, "quantity": integer }`. `quantity` is 1 to 1,000,000. | | `customerEmail` | string | no | Used for the receipt and any digital delivery, and echoed back in webhooks. If omitted, the hosted checkout can collect it from the buyer. | | `extraContext` | object | no | Arbitrary JSON attached to the order and **echoed back verbatim in every webhook for it**. Put your user id or purchase id here. | | `successUrl` | string | no | `http`/`https` URL, at most 2048 characters. Once payment completes the hosted page shows a **Continue** button linking here. | | `cancelUrl` | string | no | `http`/`https` URL, at most 2048 characters. Shown as a return link on the dead-end states — expired and partial. | | `expiresIn` | integer | no | How long the order stays payable, in **minutes**: 10 to 129,600 (90 days). Defaults to **30**. | About `items`: * Every product must belong to the key's store, be `listed`, and share a single currency with the rest of the cart. * A `productId` that appears more than once has its quantities **summed into one line** — a cart is deduplicated, not rejected. * The order total is Σ (product price × quantity). A longer `expiresIn` holds any **reserved delivery stock out of circulation for exactly as long**. Widen the window deliberately, not by default. ### The `Idempotency-Key` header Optional, 1 to 255 characters, unique per store. Retrying a create with a key this store has already used returns the **original** order — same `id`, same everything — instead of minting a duplicate. This holds *no matter how the request body changed*: one key, one order. Keys never expire, so a create months later that reuses an old key returns that old order. Use one key per logical purchase; your own purchase id is the natural choice. A key outside the 1–255 range is rejected with `{ "error": "Idempotency-Key must be between 1 and 255 characters" }`. ### Example ```bash cURL theme={null} curl -X POST https://api.taberna.io/v1/orders \ -H 'Authorization: Bearer tbrn_live_…' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: purchase_8213' \ -d '{ "items": [ { "productId": "0197f8a0-1111-7aaa-8ccc-3e5d7c9b1a24", "quantity": 2 }, { "productId": "0197f8a0-3333-7ccc-8eee-5f7d9e1b3a46", "quantity": 1 } ], "customerEmail": "buyer@example.com", "extraContext": { "userId": "u_123", "cartId": "c_991" }, "successUrl": "https://yourapp.com/thanks", "cancelUrl": "https://yourapp.com/cart", "expiresIn": 60 }' ``` ```javascript Node.js theme={null} const res = await fetch('https://api.taberna.io/v1/orders', { method: 'POST', headers: { Authorization: `Bearer ${process.env.TABERNA_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': purchaseId, }, body: JSON.stringify({ items: [ { productId: '0197f8a0-1111-7aaa-8ccc-3e5d7c9b1a24', quantity: 2 }, { productId: '0197f8a0-3333-7ccc-8eee-5f7d9e1b3a46', quantity: 1 }, ], customerEmail: 'buyer@example.com', extraContext: { userId: 'u_123', cartId: 'c_991' }, successUrl: 'https://yourapp.com/thanks', cancelUrl: 'https://yourapp.com/cart', expiresIn: 60, }), }); ``` ```python Python theme={null} res = requests.post( "https://api.taberna.io/v1/orders", headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Idempotency-Key": purchase_id, }, json={ "items": [ {"productId": "0197f8a0-1111-7aaa-8ccc-3e5d7c9b1a24", "quantity": 2}, {"productId": "0197f8a0-3333-7ccc-8eee-5f7d9e1b3a46", "quantity": 1}, ], "customerEmail": "buyer@example.com", "extraContext": {"userId": "u_123", "cartId": "c_991"}, "successUrl": "https://yourapp.com/thanks", "cancelUrl": "https://yourapp.com/cart", "expiresIn": 60, }, ) ``` ```json 201 Created theme={null} { "id": "CcvnKdAXzvW8h3JaS1VJe", "checkoutUrl": "https://taberna.io/checkout/CcvnKdAXzvW8h3JaS1VJe", "status": "pending", "expiresAt": "2026-07-09T13:00:00.000Z", "items": [ { "productId": "0197f8a0-1111-7aaa-8ccc-3e5d7c9b1a24", "title": "Pro licence", "quantity": 2, "unitPrice": "50", "currency": "usd" }, { "productId": "0197f8a0-3333-7ccc-8eee-5f7d9e1b3a46", "title": "Priority support", "quantity": 1, "unitPrice": "9.99", "currency": "usd" } ] } ``` `unitPrice` is a fiat decimal string with no trailing-zero padding — `"50"` is \$50.00. The create response deliberately returns only what you need to proceed; `GET /v1/orders/{id}` returns the full order. ### Errors All of these are `400` with a `{ "error": … }` body. Anything the request **schema** rejects — an empty `items`, more than 20 line items, `expiresIn` outside 10 to 129,600, a `quantity` outside 1 to 1,000,000, a `productId` that is not a UUID, a `successUrl` that is not `http(s)` or is over 2048 characters — never reaches the order logic. The validator answers first, always with the same generic body: ```json 400 Bad Request theme={null} { "error": "Invalid request data" } ``` The messages below are the ones the order logic itself produces, once the body's shape is already known to be valid. | Message | Cause | | ------------------------------------------- | ----------------------------------------------------------------------------- | | `Product not found` | A `productId` does not exist | | `Product does not belong to this store` | A product belongs to a different store than the key | | `Product is not available` | A product is not `listed` | | `All items must use the same currency` | The cart mixes USD and EUR products | | `Minimum order quantity for {title} is {n}` | Below the product's configured minimum | | `Maximum order quantity for {title} is {n}` | Above the product's configured maximum | | `Store not found` | The key's store no longer exists | | `Store has no payment methods enabled` | Enable at least one payment method in the dashboard | | `Product is out of stock` | A line item could not reserve delivery stock — **the whole order rolls back** | Quantity bounds come from the product's delivery configuration and default to a minimum of 1 and no maximum. See [Products and delivery](/products-and-delivery#quantity-bounds). `Product does not belong to this store` distinguishes "another store's id" from "no such id", where the rest of the API reports a resource you cannot see as simply missing. Treat it as a signal you have crossed keys and stores — a live key against a test store's product ids, say — not as a lookup you should depend on. ## Retrieve an order `GET /v1/orders/{id}` — requires `orders:read`. `{id}` is the order's public short id. ```json 200 OK theme={null} { "id": "CcvnKdAXzvW8h3JaS1VJe", "status": "completed", "priceAmount": "109.99", "priceCurrency": "usd", "successUrl": "https://yourapp.com/thanks", "cancelUrl": "https://yourapp.com/cart", "customerEmail": "buyer@example.com", "extraContext": { "userId": "u_123", "cartId": "c_991" }, "paymentMethod": "usdc_base", "cryptoAmount": "109990000", "receivedAmount": "109990000", "transactions": [ { "blockchain": "base", "txHash": "0xabc…", "fromAddress": "0xdef…", "amount": "109990000", "blockNumber": "21458822", "blockTime": "2026-07-09T12:15:02.000Z" } ], "items": [ { "productId": "0197f8a0-1111-7aaa-8ccc-3e5d7c9b1a24", "title": "Pro licence", "image": null, "quantity": 2, "unitPrice": "50", "amount": "100" } ], "checkoutUrl": "https://taberna.io/checkout/CcvnKdAXzvW8h3JaS1VJe", "createdAt": "2026-07-09T12:00:04.000Z", "expiresAt": "2026-07-09T13:00:00.000Z", "completedAt": "2026-07-09T12:15:04.000Z" } ``` `paymentMethod`, `cryptoAmount`, `receivedAmount` and `transactions` all describe a **single payment attempt**, chosen in this order: the attempt that **completed** the order; otherwise the **best-funded** one, holding the largest `receivedAmount`; otherwise the **most recent**. They are `null` (and `transactions` is `[]`) while no attempt has been started. The best-funded tier is not a detail. Switching token abandons an attempt but leaves it watched and payable, so the attempt that actually took the money is often **older than the last quote the buyer requested**. Picking it here is what keeps these fields agreeing with the `order.partial` webhook, which reports the attempt the indexer credited. An id that is not a well-formed short id fails the route's validation and answers `400` with `{ "error": "Invalid request data" }`. An id that is well-formed but does not resolve — undecodable, unknown, or an order belonging to another store — answers `404` with `{ "error": "Order not found" }`: an order you cannot see is reported as missing, never as forbidden. ## List orders `GET /v1/orders` — requires `orders:read`. Newest first. | Query parameter | Type | Notes | | --------------- | ------- | ----------------------------------------------------------- | | `page` | integer | Defaults to `1` | | `limit` | integer | 1 to 100, defaults to `20` | | `status` | enum | `pending`, `completed`, `partial`, `expired` | | `createdAfter` | string | ISO-8601 with a `Z` or an offset. **Inclusive** lower bound | | `createdBefore` | string | ISO-8601 with a `Z` or an offset. **Inclusive** upper bound | ```json 200 OK theme={null} { "data": [ /* orders, in the shape above */ ], "pagination": { "page": 1, "limit": 20, "total": 137, "totalPages": 7 } } ``` ## Expire an order `POST /v1/orders/{id}/expire` — requires `orders:write`. Ends a `pending` order now rather than waiting out its window. This fires the `order.expired` webhook and **returns any reserved delivery stock to the pool**, which is the main reason to call it: an abandoned cart holding the last five licence keys is worth reclaiming. Returns `200` with the full order, now `expired`. | Status | Message | Cause | | ------ | -------------------------------------------------------------- | -------------------------------------------------------------------- | | `400` | `Invalid request data` | `{id}` is not a well-formed short id | | `404` | `Order not found` | Well-formed id that does not resolve: unknown, or from another store | | `400` | `Only pending orders can be expired` | Already `completed`, `partial` or `expired` | | `400` | `Order has already been completed` | It completed while the request was in flight | | `400` | `Order has a payment in progress — try again after it expires` | A payment attempt is live; its quote must lapse first | Expiring an order that a concurrent request already expired is not an error — you get the expired order back, because your intent already holds. ## Statuses | Status | Meaning | | ----------- | -------------------------------------------------------------------------------------------------- | | `pending` | Payable. The buyer may start and retry payment attempts. Initial state. | | `completed` | An attempt received the full amount on-chain. **Terminal — fulfil here.** | | `partial` | An attempt's window lapsed holding a non-zero but insufficient amount. Terminal; resolve manually. | | `expired` | The window elapsed with nothing received, or you called the expire endpoint. Terminal. | ```mermaid theme={null} stateDiagram-v2 [*] --> pending: POST /v1/orders pending --> completed: an attempt receives the full amount pending --> partial: an attempt lapses holding partial funds pending --> expired: window elapses with nothing received pending --> expired: the expire endpoint is called completed --> [*] partial --> [*] expired --> [*] ``` Two things that are easy to get wrong: * **Picking a coin does not change the order's status.** It creates a payment attempt *under* the order, which stays `pending`. The `order.processing` webhook fires when the **first** attempt is created, as a "buyer engaged" signal — it is not a status. * **An attempt that lapses with zero funds does not affect the order.** The buyer can keep retrying with fresh quotes until the order itself expires. See [Payment lifecycle](/payment-lifecycle) for the attempt-level state machine. ## Multi-item and currency rules An order snapshots exactly one currency, taken from its products. Mixing a USD product with a EUR product is `All items must use the same currency`. If you sell in both, keep separate carts. Sending the same `productId` in two entries sums the quantities into one line item. The response's `items` array therefore may be shorter than the array you sent. `items[].title`, `unitPrice` and `image` are captured at creation. Editing the product later never rewrites a past order — which is what makes webhooks and receipts trustworthy after the fact. Every line item that needs finite stock reserves it inside one transaction. If any line cannot, the whole create fails with `Product is out of stock` and nothing is held. Reservations are released when the order expires. # Payment lifecycle Source: https://docs.taberna.io/payment-lifecycle State machines for orders, invoices and payment attempts — and what partial, confirmed and expired actually mean. Three state machines are in play, and keeping them apart is most of what there is to understand about Taberna. * The **order** or **invoice** is the thing you created. It is payable, then terminal. * A **payment attempt** is one quote-locked session under it. Several may come and go during a single order's life. * A **confirmation** is a presentation-layer read of how deeply a transfer is buried. Nothing gates on it. ## Orders ```mermaid theme={null} stateDiagram-v2 [*] --> pending: order created pending --> completed: an attempt receives the full amount pending --> partial: an attempt lapses holding partial funds pending --> expired: window elapses with nothing received pending --> expired: merchant expires it early completed --> [*] partial --> [*] expired --> [*] ``` | Status | Payable | Meaning | | ----------- | ------- | ------------------------------------------------------------------------- | | `pending` | yes | Initial state. Buyers may start and retry attempts. | | `completed` | no | Full amount received on-chain. **Terminal — fulfil here.** | | `partial` | no | An attempt lapsed holding insufficient funds. Terminal; resolve manually. | | `expired` | no | The window elapsed with nothing received, or the merchant ended it early. | ## Invoices ```mermaid theme={null} stateDiagram-v2 [*] --> open: invoice created open --> paid: an attempt receives the full amount open --> partial: an attempt lapses holding partial funds open --> expired: window elapses with nothing received open --> voided: merchant voids it paid --> [*] partial --> [*] expired --> [*] voided --> [*] ``` Same machine as orders, with `paid` in place of `completed` and one extra terminal state, `voided`, for a merchant cancelling before payment. Voiding is refused while an attempt is live — see [the void guard](/invoices#void-an-invoice). ## Payment attempts An attempt is created when the buyer picks a token. It locks a crypto quote, mints a **fresh deposit wallet used for that attempt alone**, and is watched until it resolves. ```mermaid theme={null} stateDiagram-v2 [*] --> processing: buyer picks a token processing --> abandoned: buyer picks a DIFFERENT token processing --> completed: balance reaches the quoted amount abandoned --> completed: balance reaches the quoted amount processing --> partial: window lapses holding partial funds abandoned --> partial: window lapses holding partial funds processing --> expired: window lapses holding nothing abandoned --> expired: window lapses holding nothing completed --> [*] partial --> [*] expired --> [*] ``` | Status | Meaning | | ------------ | ----------------------------------------------------------------------------------------- | | `processing` | Live. The quote is locked and the deposit address is being watched. Initial state. | | `abandoned` | The buyer switched to another token. **Still watched, still payable.** | | `completed` | Balance reached the quoted amount. Flips the parent to `completed` / `paid`. | | `partial` | Window lapsed holding a non-zero but insufficient balance. Flips the parent to `partial`. | | `expired` | Window lapsed holding nothing. **The parent is untouched and stays payable.** | Three consequences worth internalising: Only one attempt can be `processing` at a time. Re-selecting the token that is already live returns the *same* attempt — same address, same quote — so reloading the checkout page is safe and does not mint a second address. Switching tokens marks the old attempt `abandoned`, but it stays watched until its own window ends, and it can still complete. Funds a buyer broadcast just before switching are not lost. Abandonment emits **no webhook**. It is a UI affordance, not a lifecycle event. An attempt that expires holding zero leaves the parent `pending` / `open`. The buyer picks a token again, gets a fresh quote on a fresh address, and can keep doing that until the parent itself expires. ## Partial payments **There is no underpayment tolerance.** Completion is a strict comparison: the balance must be **greater than or equal to** the quoted `cryptoAmount`. A payment one base unit short does not complete. While an attempt is live, an insufficient balance simply sits there — Taberna keeps re-reading the deposit address, so **the buyer can top it up** and the attempt completes the moment the total clears the quote. Nothing is decided until the window closes. At the attempt's `expiresAt`, exactly one rule applies: | Balance at expiry | Attempt becomes | Parent becomes | | -------------------------- | --------------- | ------------------------- | | `>= cryptoAmount` | `completed` | `completed` / `paid` | | `> 0` and `< cryptoAmount` | `partial` | `partial` | | `0` or nothing received | `expired` | unchanged — still payable | **Overpayment completes normally.** The comparison is `>=`, so a buyer who sends more than quoted gets a completed order; the surplus is part of the swept amount. ### Surfacing a partial `partial` is terminal on the parent and is **not resolved automatically** — there is no automatic refund. What you get: * An `order.partial` or `invoice.partial` webhook. * `receivedAmount` and `cryptoAmount` on the order, or under `payment` on the invoice, so the shortfall is `cryptoAmount − receivedAmount` in the token's smallest unit. * `transactions[]`, so you can trace what actually arrived and from where. Deciding what to do — refund off-platform, honour the purchase anyway, ask the buyer to send the difference through a new charge — is a merchant policy call. **Do not fulfil on `partial` unless you have decided to honour underpayments.** ## Confirmations `activeAttempt.confirmation` on the [public checkout payload](/checkout#confirmation-progress) reports how deeply the inbound transfers are buried. | `state` | Means | | ------------ | -------------------------------------------------------------------- | | `waiting` | No transfers on file yet. `confirmations` and `required` are `null`. | | `detected` | A transfer is known but its depth cannot be computed yet. | | `confirming` | Depth is known and below `required`. | | `confirmed` | Depth has reached `required`. | `confirmations` is clamped to at most `required`, so it never overshoots. Both are `null` when unknown. **`stale: true` means the counts cannot be trusted** — the chain-head reading behind them is missing or older than 60 seconds. Do not render progress while it is set. And `confirmed` is **not** "paid". It says the transfers on file are buried deeply enough; it says nothing about whether they add up. An underpayment can sit at `confirmed` indefinitely. The authoritative signal is always the order or invoice `status`. ### Per-chain confirmation depths | Chain | Depth | Nature | | --------- | --------- | ------------------------------------------------------------------------ | | Ethereum | 3 blocks | Functional — balances are read at that depth | | Base | 10 blocks | Functional | | BNB Chain | 15 blocks | Functional | | Polygon | 30 blocks | Functional | | Solana | 32 slots | **Display only** — Solana settlement uses `finalized` commitment instead | These values reach the API through the chain indexers rather than being hardcoded in it, which is why `required` can legitimately be `null`: no depth has been published for that chain yet. Treat a `null` as "unknown", never as "zero". ## Expiry windows | Window | Default | Range | Set by | | ----------------------- | ---------- | -------------------- | ------------------------------------------------- | | Order | 30 minutes | 10 minutes – 90 days | `expiresIn` on `POST /v1/orders` | | Invoice | 24 hours | 10 minutes – 90 days | `expiresIn` on `POST /v1/invoices`, or a template | | Payment attempt (quote) | 30 minutes | — | Not settable | The attempt window is precisely `min(now + 30 minutes, the parent's expiresAt)`. A locked quote must never outlive its parent, so an attempt started five minutes before an order expires gets five minutes, not thirty. A wider order window holds **reserved delivery stock out of circulation for exactly as long**. If you widen `expiresIn` for finite-stock products, expire abandoned orders through [the expire endpoint](/orders#expire-an-order) rather than waiting them out. Expiry is swept by a background pass rather than evaluated on read, so a resource can sit a few seconds past its `expiresAt` before its status changes. Poll for the status change; do not compute expiry client-side. ## Supported chains and currencies Fiat pricing currencies: **`usd`** and **`eur`**. | Chain (`blockchain`) | Native coin | Stablecoins | | --------------------- | ----------- | -------------------------------- | | Solana (`solana`) | `sol` | `usdc_solana` | | Ethereum (`ethereum`) | `eth` | `usdc_ethereum`, `usdt_ethereum` | | Base (`base`) | `eth_base` | `usdc_base`, `usdt_base` | | Polygon (`polygon`) | `matic` | `usdc_polygon`, `usdt_polygon` | | BNB Chain (`bnb`) | `bnb` | `usdt_bnb` — **no USDC** | Thirteen payment methods in total. Which of them a buyer can actually pick is your store's `enabledPaymentMethods`, readable at any time from `GET /v1/store`. Stablecoins on a low-fee chain — `usdc_base`, `usdc_polygon` — are the smoothest default: the buyer's gas cost is negligible and there is no exchange-rate movement inside the quote window to explain away. ## Settlement Each payment lands in the deposit wallet Taberna generated for that attempt, and is then swept on-chain to **your store's payout wallet** for that chain — the address you registered in the dashboard. There is no Taberna balance to withdraw from and no payout schedule. ### The processing fee The sweep splits the swept balance: **the processing fee goes to Taberna, the remainder to your payout wallet**. The standard rate is 3%; it is set per store, so check the dashboard for the rate that applies to yours. The deduction happens on-chain, in the sweep transaction itself, so what arrives at your address is already net of it. That is the only deduction Taberna makes: | Cost | Paid by | | ------------------------------- | ----------------------------------------------------- | | Network fee to send the payment | The buyer, from their own wallet | | Processing fee | You — 3% as standard, taken on-chain during the sweep | | Gas for the sweep transaction | Taberna | Because sweep gas is covered by Taberna, the processing fee is the whole cost of settlement. Processors that charge a percentage and then bill network or withdrawal fees separately deduct on more than one line. ### What gets swept, and when A funded deposit wallet is swept once its attempt has **settled** — the sweepers pick up attempts in `completed`, `expired` or `partial`. `expired` and `partial` are included so that funds which arrived too late, or fell short of the quote, still reach your payout wallet rather than being stranded. An `abandoned` attempt is **not** swept while it is abandoned. It stays watched to the end of its own window and then settles into `completed`, `partial` or `expired` like any other attempt; the sweep follows from that settled status, not from the abandonment. Sweep state is internal plumbing. It appears **nowhere on the public API** — not on the buyer-facing checkout payload and not on `GET /v1/orders/{id}` or `GET /v1/invoices/{id}`. Settlement progress is visible only in the dashboard. Your completion signal is the order or invoice `status`, never the sweep. # Products and delivery Source: https://docs.taberna.io/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. 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). ## 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" }`. **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. ## 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 | 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`. ## The four delivery types 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). 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`. 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`. An HTTPS URL the buyer is sent to after paying — your own fulfilment page, a vendor portal, a course platform. Unlimited; `stock` is `null`. 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**. 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: 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. 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`. 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={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. **`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. 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 ```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']}") ``` ### 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`: ```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/…" } ``` 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. 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. # Quickstart Source: https://docs.taberna.io/quickstart Take your first crypto payment — from an empty store to a fulfilled order. This walks the **order** flow end to end. If your amounts are decided per request rather than drawn from a catalogue, read [Invoices](/invoices) instead — steps 1 to 3 are identical and step 4 becomes a single `POST /v1/invoices` call with no product setup. **There is no test mode yet.** Every payment method is a live chain and every payment moves real funds. Develop against small amounts, and expect a real transfer to be needed before an order reaches `completed`. In the dashboard: 1. **Create a store.** Give it a name and an image — both appear on the hosted checkout page the buyer sees. 2. **Enable payment methods.** Pick from the 13 supported methods. A store with none enabled cannot create orders: `POST /v1/orders` fails with `Store has no payment methods enabled`. 3. **Set a payout wallet per chain.** This is where swept funds go. Register an address for every chain whose methods you enabled. Starting with a single stablecoin on a cheap chain — `usdc_base` — keeps the buyer's gas cost low and removes exchange-rate volatility from the payment window. Still in the dashboard, create a product with a `title`, a `price` (a decimal string like `"50.00"`), a `currency` (`usd` or `eur`), and — importantly — **`listed` turned on**. An unlisted product is rejected at order creation with `Product is not available`. Note the product's `id`, a UUID: that is what you pass as `productId`. Optionally attach a delivery — a pool of licence keys, a shared file, a redirect. See [Products and delivery](/products-and-delivery). In the dashboard's developer settings, create a key. You **must** choose scopes; there is no implicit full-access key. For this quickstart: * `orders:write` — to create and expire orders * `orders:read` — to read them back The raw key is shown **once**, at creation. Taberna stores only a SHA-256 hash, so if you lose it, revoke and mint a new one. Verify it works with the whoami call, which needs no scope at all: ```bash cURL theme={null} curl https://api.taberna.io/v1/store \ -H 'Authorization: Bearer tbrn_live_3f1c2b9a7d4e4f889a102c6e5b4d1a90aabbccdd001122' ``` ```javascript Node.js theme={null} const res = await fetch('https://api.taberna.io/v1/store', { headers: { Authorization: `Bearer ${process.env.TABERNA_API_KEY}` }, }); const store = await res.json(); console.log(store.name, store.scopes, store.enabledPaymentMethods); ``` ```python Python theme={null} import os, requests res = requests.get( "https://api.taberna.io/v1/store", headers={"Authorization": f"Bearer {os.environ['TABERNA_API_KEY']}"}, ) store = res.json() print(store["name"], store["scopes"], store["enabledPaymentMethods"]) ``` ```json Response theme={null} { "id": "0197f8a0-2222-7bbb-9ddd-4f6e8dac2b35", "name": "My Store", "image": null, "enabledPaymentMethods": ["usdc_base", "sol"], "requireCustomerEmail": true, "createdAt": "2026-07-01T09:12:44.000Z", "slug": "my-store", "storefrontEnabled": true, "scopes": ["orders:read", "orders:write"], "storefrontUrl": "https://my-store.taberna.io" } ``` The `scopes` array is the **calling key's own** — so an integration can discover what it may do without provoking a 403 to find out. `enabledPaymentMethods` confirms step 1 landed. ```bash cURL theme={null} curl -X POST https://api.taberna.io/v1/orders \ -H 'Authorization: Bearer tbrn_live_3f1c2b9a7d4e4f889a102c6e5b4d1a90aabbccdd001122' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: purchase_8213' \ -d '{ "items": [{ "productId": "0197f8a0-1111-7aaa-8ccc-3e5d7c9b1a24", "quantity": 1 }], "customerEmail": "buyer@example.com", "extraContext": { "userId": "u_123" }, "successUrl": "https://yourapp.com/thanks", "cancelUrl": "https://yourapp.com/pricing" }' ``` ```javascript Node.js theme={null} const res = await fetch('https://api.taberna.io/v1/orders', { method: 'POST', headers: { Authorization: `Bearer ${process.env.TABERNA_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': purchaseId, }, body: JSON.stringify({ items: [{ productId: PRODUCT_ID, quantity: 1 }], customerEmail: user.email, extraContext: { userId: user.id }, successUrl: 'https://yourapp.com/thanks', cancelUrl: 'https://yourapp.com/pricing', }), }); if (!res.ok) throw new Error((await res.json()).error); const order = await res.json(); ``` ```python Python theme={null} import os, requests res = requests.post( "https://api.taberna.io/v1/orders", headers={ "Authorization": f"Bearer {os.environ['TABERNA_API_KEY']}", "Content-Type": "application/json", "Idempotency-Key": purchase_id, }, json={ "items": [{"productId": PRODUCT_ID, "quantity": 1}], "customerEmail": user.email, "extraContext": {"userId": user.id}, "successUrl": "https://yourapp.com/thanks", "cancelUrl": "https://yourapp.com/pricing", }, ) if not res.ok: raise RuntimeError(res.json()["error"]) order = res.json() ``` ```json 201 Created theme={null} { "id": "CcvnKdAXzvW8h3JaS1VJe", "checkoutUrl": "https://taberna.io/checkout/CcvnKdAXzvW8h3JaS1VJe", "status": "pending", "expiresAt": "2026-07-09T12:30:00.000Z", "items": [ { "productId": "0197f8a0-1111-7aaa-8ccc-3e5d7c9b1a24", "title": "Pro licence", "quantity": 1, "unitPrice": "50", "currency": "usd" } ] } ``` Three things to do with that response: * **Persist `id`.** It is the order's public short id and the value every webhook for this order carries as `data.id`. * **Note `expiresAt`.** 30 minutes from creation by default; pass `expiresIn` (10 minutes to 90 days) to widen it. * **Use `extraContext`** to carry your own correlation data. It is echoed back verbatim in every webhook for this order, which is how you find the user again. The `Idempotency-Key` header makes the create safe to retry. A repeat with the same key returns the **original** order rather than minting a second one — no matter how the body changed. Send the customer to `checkoutUrl`. Everything from there is Taberna's hosted page: the buyer picks a token from your enabled methods, gets a locked quote and a deposit address, pays, and watches confirmations tick up. When the payment completes, the page shows a **Continue** button pointing at your `successUrl`. The dead-end states — expired, partial — link to `cancelUrl`. Neither is required, but without `successUrl` the buyer simply stays on the hosted page. Never grant anything based on the buyer arriving at your `successUrl`. It is a browser redirect with no authentication. Fulfil on the webhook, or on a server-side read of `GET /v1/orders/:id`. Two mechanisms, and you want the first. **Webhooks (recommended).** Set a webhook URL in the dashboard, save the returned `tbrn_whsec_…` secret, and handle `order.completed`: ```javascript Node.js (Express) theme={null} import crypto from 'node:crypto'; import express from 'express'; const app = express(); // The raw body is required — a re-serialized object will not match the signature. app.post( '/webhooks/taberna', express.raw({ type: 'application/json' }), (req, res) => { const timestamp = req.get('x-taberna-timestamp'); const received = req.get('x-taberna-signature'); if (!timestamp || !received) return res.sendStatus(400); const expected = crypto .createHmac('sha256', process.env.TABERNA_WEBHOOK_SECRET) .update(`${timestamp}.${req.body}`) .digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(received); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return res.sendStatus(400); } // Reject stale timestamps to blunt replays (5-minute tolerance). if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { return res.sendStatus(400); } const { event, data } = JSON.parse(req.body.toString('utf8')); if (event === 'order.completed') { // Idempotent: this may arrive more than once. fulfil(data.extraContext.userId, data.id); } res.sendStatus(200); // Respond fast; do heavy work asynchronously. }, ); ``` **Polling (fallback).** Read the order back from your server whenever a webhook did not arrive: ```bash theme={null} curl https://api.taberna.io/v1/orders/CcvnKdAXzvW8h3JaS1VJe \ -H 'Authorization: Bearer tbrn_live_…' ``` Act on `status === "completed"`. See [Webhooks](/webhooks) for the full event list, signature scheme and retry schedule. ## What to read next The eight scopes, what each unlocks, and 401 versus 403. Payload shapes, signature verification in three languages, retries. State machines, partial payments, confirmation depths. Status conventions, body limits, and what Taberna does not do yet. # Webhooks Source: https://docs.taberna.io/webhooks Signed, retried lifecycle events — the signal you should build fulfilment on. Taberna delivers order and invoice lifecycle events to **one HTTP endpoint per store**, through a durable outbox and a retrying dispatcher. This — not a browser redirect, not a polling loop — is what you should fulfil on. ## Setting up Webhooks are configured in the dashboard, by a store owner. Point it at an HTTPS endpoint you control. A **signing secret is generated on first setup** and stays readable in the dashboard — unlike an API key, which is shown once and never again, you can come back for the signing secret whenever you need it. Changing the URL later **keeps the existing secret**, so you can move infrastructure without a secret rollout. Rotating the secret is a separate, explicit action. The secret is always `tbrn_whsec_` followed by 48 hexadecimal characters. **The whole string, prefix included, is the HMAC key** — do not strip the prefix. ## Events Ten event names, five per resource. | Event | Fires when | | ------------------ | ------------------------------------------------------------------------ | | `order.created` | An order is created | | `order.processing` | The **first** payment attempt for the order is started ("buyer engaged") | | `order.completed` | An attempt received the full amount on-chain. **Fulfil here.** | | `order.partial` | An attempt lapsed holding a non-zero but insufficient amount | | `order.expired` | The order's window elapsed, or it was expired through the API | | `invoice.created` | An invoice is created | | `invoice.paid` | An attempt received the full amount on-chain. **Fulfil here.** | | `invoice.partial` | An attempt lapsed holding a non-zero but insufficient amount | | `invoice.expired` | The invoice's window elapsed with nothing received | | `invoice.voided` | The invoice was voided before payment | `order.processing` fires only for the **first** attempt on an order. A buyer who lets a quote lapse and picks a token again does not re-fire it, and there is no `invoice.processing` counterpart. Abandoning an attempt by switching tokens emits nothing at all — it is a UI affordance, not a lifecycle event. ## Request format Taberna sends `POST` with these headers: | Header | Value | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | `application/json` | | `User-Agent` | `Taberna Dispatcher/1.0 (+https://taberna.io)` | | `X-Taberna-Delivery-Id` | Id of the delivery row, **stable across automatic retries and manual resends alike** — a resend re-queues the same row. Matches the dashboard's deliveries view. | | `X-Taberna-Timestamp` | Unix epoch **seconds**, as a string. Part of the signature. | | `X-Taberna-Signature` | Hex HMAC-SHA256 signature. See [Verifying the signature](#verifying-the-signature). | ### Order payload Identical shape for every `order.*` event. ```json theme={null} { "event": "order.completed", "timestamp": "2026-07-09T12:15:04.123Z", "data": { "id": "CcvnKdAXzvW8h3JaS1VJe", "status": "completed", "priceAmount": "50", "priceCurrency": "usd", "paymentMethod": "usdc_base", "cryptoAmount": "50010000", "receivedAmount": "50010000", "transactions": [ { "blockchain": "base", "txHash": "0xabc…", "fromAddress": "0xdef…", "amount": "50010000", "blockNumber": "21458822", "blockTime": "2026-07-09T12:15:02.000Z" } ], "customerEmail": "buyer@example.com", "extraContext": { "userId": "u_123", "cartId": "c_991" }, "ipAddress": "203.0.113.7", "createdAt": "2026-07-09T12:00:04.000Z", "items": [ { "productId": "0197f8a0-1111-7aaa-8ccc-3e5d7c9b1a24", "title": "Pro licence", "image": null, "quantity": 1, "unitPrice": "50", "amount": "50" } ] } } ``` * `data.id` is the order's public short id — the same value `POST /v1/orders` returned. * `data.extraContext` is **exactly** what you passed at creation. This is how you find your user. * `data.items` is what was bought, snapshotted at purchase time, so later product edits never rewrite a past order. `amount` is the line total, `unitPrice` × `quantity`. * `priceAmount`, `items[].unitPrice` and `items[].amount` are **fiat decimal strings**; `cryptoAmount` and `receivedAmount` are **crypto smallest-unit strings**. * `paymentMethod`, `cryptoAmount`, `receivedAmount` and `ipAddress` come from the payment attempt that triggered the event and are **`null` on events with no attempt yet** — `order.created`, for instance, where `transactions` is `[]` too. ### Invoice payload Identical shape for every `invoice.*` event. ```json theme={null} { "event": "invoice.paid", "timestamp": "2026-07-09T12:15:04.123Z", "data": { "id": "CcvnBwRQaKY1rXSMLrbYe", "number": 42, "status": "paid", "items": [ { "name": "124 credits", "description": null, "quantity": 1, "unitAmount": "124", "amount": "124" } ], "totalAmount": "124", "currency": "usd", "customerName": null, "customerEmail": "buyer@example.com", "memo": null, "metadata": { "userId": "u_123", "credits": 124, "purchaseId": "p_8213" }, "payment": { "method": "usdc_solana", "cryptoAmount": "124000000", "receivedAmount": "124000000", "transactions": [ { "blockchain": "solana", "txHash": "5Nx…", "fromAddress": "9xQ…", "amount": "124000000", "blockNumber": "298471223", "blockTime": "2026-07-09T12:15:02.000Z" } ] }, "expiresAt": "2026-07-09T13:00:00.000Z", "paidAt": "2026-07-09T12:15:04.000Z", "createdAt": "2026-07-09T12:00:00.000Z" } } ``` * `data.metadata` is **exactly** what you passed at creation. * `payment` is **`null` on events with no attempt involved** — `invoice.created` and `invoice.voided`, for instance. Note the different nesting from the order payload, where the same facts are flat on `data`. `transactions` is **best effort**. An empty array means Taberna could not attribute a transfer — an RPC gap, or a native transfer made from inside a contract — **not that no money moved**. Never treat an empty array as evidence of non-payment. The authoritative fact is `status`. ## Verifying the signature The signature is: ``` HMAC_SHA256(secret, "{timestamp}.{rawBody}") ``` in lowercase hex, where `timestamp` is the `X-Taberna-Timestamp` header (unix seconds) and `rawBody` is the **exact raw request body**. This is the same scheme Stripe uses, so Stripe-shaped verification code ports directly. **Verify against the raw bytes, before JSON parsing.** Almost every failed integration is a framework that parsed and re-serialized the body first: a single whitespace or key-order difference changes the hash and every signature check fails. In Express, mount `express.raw({ type: 'application/json' })` on the webhook route. In FastAPI, use `await request.body()`. In Laravel, use `$request->getContent()`. In Next.js App Router, use `await req.text()`. ```javascript Node.js theme={null} import crypto from 'node:crypto'; import express from 'express'; const app = express(); // The raw body — a parsed-and-re-serialized object will not match. app.post( '/webhooks/taberna', express.raw({ type: 'application/json' }), (req, res) => { if (!verify(req.body, req.headers, process.env.TABERNA_WEBHOOK_SECRET)) { return res.sendStatus(400); } const { event, data } = JSON.parse(req.body.toString('utf8')); handle(event, data, req.get('x-taberna-delivery-id')); // Respond fast; do heavy work asynchronously. res.sendStatus(200); }, ); function verify(rawBody, headers, secret) { const timestamp = headers['x-taberna-timestamp']; const received = headers['x-taberna-signature']; if (!timestamp || !received) return false; // Replay defence: reject anything outside a 5-minute window. if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(received); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` ```python Python theme={null} import hashlib import hmac import os import time from fastapi import FastAPI, Request, Response app = FastAPI() SECRET = os.environ["TABERNA_WEBHOOK_SECRET"].encode() def verify(raw_body: bytes, headers) -> bool: timestamp = headers.get("x-taberna-timestamp") received = headers.get("x-taberna-signature") if not timestamp or not received: return False # Replay defence: reject anything outside a 5-minute window. if abs(time.time() - int(timestamp)) > 300: return False signed = f"{timestamp}.".encode() + raw_body expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, received) @app.post("/webhooks/taberna") async def taberna_webhook(request: Request): raw_body = await request.body() # raw bytes, before any parsing if not verify(raw_body, request.headers): return Response(status_code=400) import json payload = json.loads(raw_body) handle(payload["event"], payload["data"], request.headers.get("x-taberna-delivery-id")) # Respond fast; do heavy work asynchronously. return Response(status_code=200) ``` ```php PHP theme={null} 300) { http_response_code(400); exit; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); if (!hash_equals($expected, $received)) { http_response_code(400); exit; } $payload = json_decode($rawBody, true); handle($payload['event'], $payload['data'], $_SERVER['HTTP_X_TABERNA_DELIVERY_ID'] ?? null); // Respond fast; do heavy work asynchronously. http_response_code(200); ``` Two rules the samples encode: 1. **Constant-time comparison.** `timingSafeEqual`, `hmac.compare_digest`, `hash_equals` — never `==`. 2. **A timestamp tolerance.** Taberna does not enforce one for you; reject stale timestamps yourself. **Five minutes** is the recommended window, matching what Stripe and Slack document. Without it, a captured request stays replayable forever. If verification fails, respond non-2xx (or simply ignore the request). Do not process the payload. ## Retries and delivery semantics A delivery succeeds **only** if your endpoint returns a `2xx` within the request timeout — 10 seconds by default. Anything else, non-2xx or network error, is retried. Respond fast and queue the real work. A handler that does fulfilment inline is a handler that times out under load and gets replayed. Exponential, capped: ``` delay = min(baseDelay × multiplier^(attempt − 1), cap) ``` With the defaults — base **15 s**, multiplier **2**, cap **8 hours**, up to **20 attempts** — the wait doubles from 15 seconds until it reaches the 8-hour cap after about a dozen attempts, then stays there. That gives a total horizon of roughly **three days**: | After attempt | Next retry in | | ------------- | ------------- | | 1 | 15 seconds | | 5 | 4 minutes | | 11 | 4.3 hours | | 12 – 19 | 8 hours (cap) | These are server-configurable, so treat the exact numbers as current defaults rather than a contract. The shape — fast at first, then hours apart, for days — is the part to design against. After the last attempt the delivery is marked **`exhausted`** and is **not retried automatically**. The store owner is emailed, at most once per webhook per 24 hours, so a broken endpoint produces one alert rather than a flood. Once your endpoint is healthy again, replay exhausted deliveries — from the dashboard, or through [the resend endpoint](#listing-and-resending-deliveries). **Resending an exhausted delivery buys exactly one more attempt.** The attempt counter is not reset: the delivery is pushed back into the queue at its existing count, one request is made, and if that request does not return a `2xx` the delivery is immediately `exhausted` again. It does **not** restart the multi-day retry schedule. So fix the endpoint first, verify it, and only then resend. If the resend fails you are back where you started and must resend again. The same event **can arrive more than once** — an automatic retry after your endpoint was slow but actually succeeded, or a manual resend. Your handler must be idempotent. **A manual resend does not mint a new delivery id.** It re-queues the same delivery row, so the resent request arrives with the **same** `X-Taberna-Delivery-Id` as the original. A handler that dedups purely on delivery id will therefore silently drop every resend — exactly the deliveries a merchant is replaying because something went wrong. **Idempotency has to live in your business state.** Before acting, ask whether the state change has already been applied — *is this order already fulfilled?* Look up `data.id`, or your own `data.extraContext.purchaseId` / `data.metadata.purchaseId`, and no-op if the work is done. That check is correct whether the duplicate came from a retry, a resend, or two workers racing. `X-Taberna-Delivery-Id` is still worth recording — for correlation with the dashboard, and as a cheap short-circuit for automatic retries piling onto an expensive handler — but it is **not sufficient on its own**. A handler that records the id and then crashes before applying the change has poisoned itself: the resend that would have fixed it gets skipped as a duplicate. ### An idempotent handler Verify, parse, then apply the change against your own state — never gate on the delivery id. ```javascript theme={null} async function handle(event, data, deliveryId) { // Signature already verified against the raw body by the caller above. if (event !== 'order.completed') return; // The guard is business state, not the delivery id: a resend carries the // SAME delivery id, so id-based dedup would drop it. const purchase = await findPurchaseByOrderId(data.id); if (!purchase) return; if (purchase.fulfilledAt) return; // already applied — nothing to do await grantEntitlement(data.extraContext.userId, purchase); // Record the delivery id alongside the state change, for correlation with // the dashboard. It is an audit trail, not the idempotency key. await markFulfilled(purchase.id, { deliveryId }); } ``` Best done in one transaction, or with a unique constraint on `purchase.id` in whatever table records the fulfilment, so two concurrent deliveries cannot both pass the `fulfilledAt` check. ## Listing and resending deliveries Two endpoints let your server monitor and replay without a dashboard session. ### List deliveries `GET /v1/webhook-deliveries` — requires the `webhooks:read` scope. Newest first. | Query parameter | Type | Notes | | --------------- | ------- | ------------------------------------------- | | `page` | integer | Defaults to `1` | | `limit` | integer | 1 to 100, defaults to `20` | | `status` | enum | `pending`, `success`, `failed`, `exhausted` | ```json 200 OK theme={null} { "data": [ { "id": "0197f8a0-5555-7eee-9aaa-7b9c1d3e5f68", "event": "order.completed", "orderId": "0197f8a0-6666-7fff-9bbb-8c0d2e4f6a79", "invoiceId": null, "status": "exhausted", "attempts": 20, "lastAttemptAt": "2026-07-12T18:02:11.000Z", "lastResponseStatus": 502, "createdAt": "2026-07-09T12:15:04.000Z" } ], "pagination": { "page": 1, "limit": 20, "total": 1, "totalPages": 1 } } ``` `lastResponseStatus` is the HTTP status your endpoint last returned, or `null` if the attempt failed at the network level. Exactly one of `orderId` / `invoiceId` is set. A store with no webhook configured returns an **empty page**, not an error. ### Resend a delivery `POST /v1/webhook-deliveries/{deliveryId}/resend` — requires the `webhooks:write` scope. Returns `204` with no body. | Status | Message | Cause | | ------ | -------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | `400` | `Only failed or exhausted deliveries can be resent — this one is {status}` | A `pending` delivery is already queued; a `success` has nothing to replay | | `404` | `Delivery not found` | Unknown delivery, or one belonging to another store | `webhooks:write` covers **replay only**. It does not permit changing where a store's events are sent — repointing the webhook URL stays a dashboard action, deliberately. A resend re-queues the existing delivery row. It reuses the original `X-Taberna-Delivery-Id`, and it does not reset the attempt counter — an exhausted delivery gets **one** further attempt, not a fresh retry schedule. Resend after the endpoint is fixed, not before. ```javascript theme={null} // Replay everything that gave up, once your endpoint is healthy again. const res = await fetch( 'https://api.taberna.io/v1/webhook-deliveries?status=exhausted&limit=100', { headers: { Authorization: `Bearer ${process.env.TABERNA_API_KEY}` } }, ); const { data } = await res.json(); for (const delivery of data) { await fetch( `https://api.taberna.io/v1/webhook-deliveries/${delivery.id}/resend`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.TABERNA_API_KEY}` }, }, ); } ``` ## Current limitations Stated plainly, because they shape your design: **One webhook URL per store.** Every event for the store goes to that single endpoint. If you need to fan out to several consumers, receive once and dispatch yourself. **No per-event subscription filtering.** You cannot subscribe to `order.completed` alone; you receive all ten event types and filter on `event` in your handler. Both are tracked in [Current limitations](/errors-and-limits#current-limitations).