Skip to main content
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 instead.

Create an order

POST /v1/orders — requires the orders:write scope.

Request body

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

201 Created
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:
400 Bad Request
The messages below are the ones the order logic itself produces, once the body’s shape is already known to be valid.
Quantity bounds come from the product’s delivery configuration and default to a minimum of 1 and no maximum. See Products and delivery.
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.
200 OK
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.
200 OK

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

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