# REST API reference

The Shoprocket API. **v3 beta**: the shape is settling before launch, and breaking changes are listed in the changelog.

Two surfaces, one API:

- **Storefront** (`/v3/public/{pk}/...`) is safe to call from a browser. The store's publishable key (`pk_...`) sits in the path; it identifies the store and grants nothing. Carts are addressed by an `X-Cart-Token` header you mint, and a signed-in shopper by `X-Customer-Token`.
- **Seller** (`/v3/stores/{storeId}/...`) is for your own server. Send a secret key as `Authorization: Bearer sk_...`. Create keys in the dashboard under Settings > API keys, and give each only the scopes it needs.

Money is always an integer in the currency's minor unit (cents for USD, whole yen for JPY). Ids carry a type prefix (`prod_`, `ord_`, `cust_`...).

- Full machine-readable document: [https://api-v3.shoprocket.io/openapi.json](https://api-v3.shoprocket.io/openapi.json)
- Interactive reference: [https://api-v3.shoprocket.io/docs](https://api-v3.shoprocket.io/docs)
- Guides: [https://api-v3.shoprocket.io/docs](https://api-v3.shoprocket.io/docs)
- Server: `https://api-v3.shoprocket.io` (This environment (test data))

## Store

The public view of a store: name, currencies, languages and checkout options. Every storefront call starts with the store's publishable key in the path.

### GET /v3/public/{pk}

Bootstrap a storefront: the public view of a store

The first call a storefront makes. Returns only what a client needs to render correctly - currency, locale, store mode and checkout settings - never the seller-facing store shape.

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `surface` | query | no | "shop" \| "website" \| "preview" | Where the storefront runs: `shop` on the hosted Shoprocket shop, `website` (the default) on the seller's own site, `preview` in the Shoprocket dashboard's own embed preview (answered like `website`). Decides `chat`, which is null where the seller has switched live chat off. |
| `locale` | query | no | string | The shopper's language. The words Shoprocket writes for a banner or pop-up (when the seller left them blank) read in it when the store sells in it, else in the store's own language; `promos.items[].words` carries them in every other language the store sells in. |

- `200` The store (edge-cacheable): `object { data }`
- `404` Store not found: `Error`

## Catalogue

Active products, categories and approved reviews.

### POST /v3/public/{pk}/back-in-stock

Ask to be emailed when a sold-out variant is back

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

Request body: `object { variantId, email, marketing, locale }`

- `200` Recorded (the same answer for every address): `object { data }`
- `404` No such store or variant, or the store is not taking requests: `Error`
- `429` Too many requests from this caller, or for this store, just now: `Error`

### GET /v3/public/{pk}/categories

List a store's active categories

Flat list; the client builds the tree from `parentId`. Only categories with at least one active product are returned - an empty category is a dead end in a storefront filter.

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `page` | query | no | integer | Page number to return, starting at 1. Defaults to 1. |
| `perPage` | query | no | integer | Items per page. Defaults to 24; values above 100 are reduced to 100 (see `meta.perPage`). |
| `locale` | query | no | string | The shopper's language, e.g. `de`. Product names and other translated text come back in it when the store has that language switched on; otherwise the store's own language is used. |

- `200` Active categories (edge-cacheable): `StorefrontCategoryListResponse`
- `404` Store not found: `Error`

### GET /v3/public/{pk}/products

List a store's active products by publishable key

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `page` | query | no | integer | Page number to return, starting at 1. Defaults to 1. |
| `perPage` | query | no | integer | Items per page. Defaults to 24; values above 100 are reduced to 100 (see `meta.perPage`). |
| `locale` | query | no | string | Serve names and descriptions in this language, e.g. `de`. Ignored unless the store has that language switched on. |
| `q` | query | no | string | Search name, summary and description. Every word must match; the last word matches as a prefix. |
| `category` | query | no | string | A category slug or id. A comma list matches ANY of them: `category=tea,coffee`. |
| `ids` | query | no | string | Comma-separated product ids or slugs, for a hand-picked set. |
| `priceMin` | query | no | string | Lowest price, in integer cents (`1500` = 15.00). Matched against variant prices. |
| `priceMax` | query | no | string | Highest price, in integer cents. Matched against variant prices. |
| `inStock` | query | no | "true" \| "false" | `true`: only products with at least one variant that can be bought now. |
| `attr` | query | no | string | Filter by attributes and variant options, including your own custom attributes. Format: `handle:value`, with `\|` between alternatives and `,` between attributes: `attr=colour:red\|blue,brand:fender`. A number attribute takes a range: `length:100..250` (either end may be blank, `length:100..`). Values within one attribute match ANY (red or blue); separate attributes must ALL match (a blue Fender). Handles and value slugs are never translated; get them from `facets=true` (each facet's `handle`, each value's `slug`). Matching ignores case. Up to 8 attributes count; a malformed segment is ignored, not rejected. |
| `facets` | query | no | "true" \| "false" | `true` adds `meta.facets`: every filterable attribute with its `handle`, its values (`slug`, label, count) and, for numbers, its min and max. Build a filter sidebar from one `facets=true` read, then page without it (it costs extra). |
| `sort` | query | no | "featured" \| "name" \| "-name" \| "price" \| "-price" \| "newest" \| "oldest" | Order of the list. `-name` and `-price` reverse their order (`-price` is most expensive first). `featured` is the seller's own order (unplaced products newest first); with `q` it ranks by relevance. |

- `200` Active products (edge-cacheable): `StorefrontProductListResponse`
- `404` Store not found: `Error`

### GET /v3/public/{pk}/products/{idOrSlug}

Get one active product by slug or public id

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `idOrSlug` | path | yes | string |  |
| `locale` | query | no | string | The shopper's language, e.g. `de`. Product names and other translated text come back in it when the store has that language switched on; otherwise the store's own language is used. |

- `200` The product and its related products (edge-cacheable): `StorefrontProductResponse`
- `404` Store or product not found: `Error`

### GET /v3/public/{pk}/products/{idOrSlug}/reviews

A product's approved reviews with rating stats

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `idOrSlug` | path | yes | string |  |
| `page` | query | no | integer | Page of reviews to return, starting at 1. Each page holds up to 10 reviews. |
| `locale` | query | no | string | The shopper's language, e.g. `de`. Product names and other translated text come back in it when the store has that language switched on; otherwise the store's own language is used. |

- `200` Approved reviews, newest first, with rating stats (edge-cacheable, 60s): `StorefrontReviewsResponse`
- `404` Store or product not found: `Error`

### POST /v3/public/{pk}/products/{idOrSlug}/reviews

Submit a review for a product (send X-Customer-Token to attribute it to a signed-in shopper, otherwise authorName + authorEmail are required)

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `idOrSlug` | path | yes | string |  |

Request body: `object { rating, title, content, authorName, authorEmail }`

- `201` Review received, awaiting moderation: `StorefrontReviewSubmittedResponse`
- `400` Missing author identity: `Error`
- `404` Store or product not found: `Error`
- `409` This email has already reviewed this product: `Error`
- `429` Too many submissions: `Error`

### GET /v3/public/{pk}/reviews

A store's most recent approved reviews, across all products

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `limit` | query | no | integer | How many reviews to return, 1 to 12. Defaults to 3. |
| `locale` | query | no | string | The shopper's language, e.g. `de`. Product names and other translated text come back in it when the store has that language switched on; otherwise the store's own language is used. |

- `200` Approved reviews across the store, newest first (edge-cacheable, 60s): `StorefrontStoreReviewsResponse`
- `404` Store not found: `Error`

## Cart

A cart is addressed by an `X-Cart-Token` header you mint once per shopper (any random string up to 64 characters). There is no cart id and no session: the same token always reaches the same cart.

### GET /v3/public/{pk}/cart

Get the current cart for the caller's cart token

Auth: `cartToken` (header `X-Cart-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `locale` | query | no | string | The shopper's language, e.g. `de`. Product names and other translated text come back in it when the store has that language switched on; otherwise the store's own language is used. |

- `200` The cart (empty when the token has none yet): `object { data }`
- `404` Store not found: `Error`

### PATCH /v3/public/{pk}/cart

Write progressive checkout data (email, addresses, shipping selection) to the cart

Auth: `cartToken` (header `X-Cart-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `locale` | query | no | string | The shopper's language, e.g. `de`. Product names and other translated text come back in it when the store has that language switched on; otherwise the store's own language is used. |

Request body: `object { email, customerName, taxNumber, shippingAddress, billingAddress, shippingRateId, pickupLocationId, pickupDate, ... }`

- `200` The updated cart, with totals recomputed: `object { data }`
- `404` Store or cart not found: `Error`
- `409` The cart pays a quote, whose items and delivery the store set (#1583): `Error`
- `422` Invalid input, a cart token is required, or an unavailable rate: `Error`

### GET /v3/public/{pk}/cart/shipping-options

Shipping rates available for the cart's stored shipping address

Auth: `cartToken` (header `X-Cart-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `locale` | query | no | string | The shopper's language, e.g. `de`. Product names and other translated text come back in it when the store has that language switched on; otherwise the store's own language is used. |

- `200` Available options (empty if undeliverable): `object { data }`
- `404` Store not found: `Error`

### GET /v3/public/{pk}/cart/suggestions

Products to suggest in the cart

Auth: `cartToken` (header `X-Cart-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `locale` | query | no | string | The shopper's language, e.g. `de`. Product names and other translated text come back in it when the store has that language switched on; otherwise the store's own language is used. |
| `gap` | query | no | string | How far the cart is short of free shipping, in the store's base currency's minor unit (cents for USD), as an integer. When sent, the cheapest suggestion that closes the gap comes first. |

- `200` The suggestions, best first: `object { data }`
- `404` Store not found: `Error`

### POST /v3/public/{pk}/cart/items

Add an item to the cart (merges into an existing line for the same variant, plan and answers)

Auth: `cartToken` (header `X-Cart-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `locale` | query | no | string | The shopper's language, e.g. `de`. Product names and other translated text come back in it when the store has that language switched on; otherwise the store's own language is used. |

Request body: `object { productId, variantId, quantity, subscriptionPlanId, upsellSource, sourceUrl, properties, bundleSelections }`

- `200` The updated cart: `object { data }`
- `404` Store, product, or variant not found: `Error`
- `409` The store is paused and is not taking orders, or the item is sold out or has fewer left than asked for: `Error`
- `422` Invalid input, or a cart token is required: `Error`

### POST /v3/public/{pk}/cart/uploads

Upload a file a personalisation field asks for (photo or PDF, 10MB max)

Stores the file privately against this cart and answers with a `token`. Send that token as the file field's answer in `POST /cart/items` `properties`. Only the seller can open the file; it is deleted with the cart unless the cart becomes an order.

Auth: `cartToken` (header `X-Cart-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `name` | query | no | string | The file's original name, shown to the seller. Cleaned of unsafe characters; omitted = `upload.<ext>`. |

Request body: `string (binary)`

- `201` The stored file's token: `object { data }`
- `404` Store not found: `Error`
- `409` The store is paused and is not taking orders: `Error`
- `413` File too large: `Error`
- `422` Not a photo or PDF, or a cart token is required: `Error`
- `429` Too many files on this cart: `Error`

### PATCH /v3/public/{pk}/cart/items/{itemId}

Set a line's quantity outright (0 removes the line)

Raising a line past what is in stock (a variant that does not allow overselling) sets it to what is available instead, never below what the line already holds. Compare the returned line's `quantity` with the one you sent to tell the shopper.

Auth: `cartToken` (header `X-Cart-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `itemId` | path | yes | string |  |
| `locale` | query | no | string | The shopper's language, e.g. `de`. Product names and other translated text come back in it when the store has that language switched on; otherwise the store's own language is used. |

Request body: `object { quantity }`

- `200` The updated cart: `object { data }`
- `404` Store, cart, or line not found: `Error`
- `409` The store is paused and is not taking orders: `Error`
- `422` Invalid input, or a cart token is required: `Error`

### DELETE /v3/public/{pk}/cart/items/{itemId}

Remove a line from the cart

Auth: `cartToken` (header `X-Cart-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `itemId` | path | yes | string |  |
| `locale` | query | no | string | The shopper's language, e.g. `de`. Product names and other translated text come back in it when the store has that language switched on; otherwise the store's own language is used. |

- `200` The updated cart: `object { data }`
- `404` Store, cart, or line not found: `Error`
- `409` The cart pays a quote, whose items and delivery the store set (#1583): `Error`
- `422` A cart token is required: `Error`

### POST /v3/public/{pk}/cart/reopen

Reopen a checked-out cart whose order was never paid

Auth: `cartToken` (header `X-Cart-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `locale` | query | no | string | The shopper's language, e.g. `de`. Product names and other translated text come back in it when the store has that language switched on; otherwise the store's own language is used. |

- `200` The cart, editable again: `object { data }`
- `404` Store or cart not found: `Error`
- `409` The order is paid or a payment is moving: `object { rejection, message }`
- `422` A cart token is required: `Error`

### POST /v3/public/{pk}/cart/paypal-express

Raise a PayPal order for this cart (the express button, before any order exists)

Auth: `cartToken` (header `X-Cart-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

Request body: `object { amount, currencyCode, ships, options }`

- `201` The PayPal order id for createOrder: `object { data }`
- `404` Store or cart not found: `Error`
- `409` PayPal refused, or PayPal is not set up: `object { rejection, message }`
- `422` Invalid input, or a cart token is required: `Error`

### GET /v3/public/{pk}/cart/paypal-express/{paypalOrderId}

Who approved this cart's PayPal order, and where it ships

Auth: `cartToken` (header `X-Cart-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `paypalOrderId` | path | yes | string |  |

- `200` PayPal's payer and shipping address: `object { data }`
- `404` Store, cart or PayPal order not found: `Error`
- `409` PayPal could not be read: `object { rejection, message }`
- `422` A cart token is required: `Error`

### PATCH /v3/public/{pk}/cart/paypal-express/{paypalOrderId}

Bring this cart's PayPal order in line with the page (address or delivery changed in PayPal)

Auth: `cartToken` (header `X-Cart-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `paypalOrderId` | path | yes | string |  |

Request body: `object { amount, currencyCode, ships, options }`

- `200` Updated: `object { data }`
- `404` Store, cart or PayPal order not found: `Error`
- `409` PayPal refused the change: `object { rejection, message }`
- `422` Invalid input, or a cart token is required: `Error`

## Checkout

Turn a cart into an order, open a payment session, and read the result. Also the lookups a checkout form needs: countries, tax quotes, address autocomplete.

### POST /v3/public/{pk}/pay-links/{token}/open

Open a draft order's payment link: the cart that pays it, or why it can no longer be paid

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `token` | path | yes | string |  |

- `200` The quote's state, with the cart token to pay it when payable: `object { data }`
- `404` Store or payment link not found: `Error`

### GET /v3/public/{pk}/places/autocomplete

Address suggestions for what a shopper has typed so far

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `query` | query | yes | string | What the shopper has typed so far (at least 3 characters). |
| `sessionToken` | query | yes | string | A random string (8-64 characters) the client makes up when the shopper starts typing an address. Send the same value on every autocomplete call and on the details call that ends the lookup. |
| `pass` | query | yes | string | The checkout pass from `POST /places/pass`, which allows address lookups for this cart. |
| `country` | query | no | string | Limit suggestions to one country, as an ISO 3166-1 alpha-2 code such as `US`. Omitted = any country. |
| `locale` | query | no | string | Language for the suggestions, e.g. `en` or `de`. Omitted = Google's default. |

- `200` Up to five suggestions: `object { data }`
- `403` No valid checkout pass: `Error`
- `429` This cart's or today's lookup budget is spent: `Error`
- `502` Google did not answer: `Error`
- `503` Address autocomplete is not configured here: `Error`

### GET /v3/public/{pk}/places/details

The full address for a suggestion the shopper picked

Ends the Google billing session the autocomplete calls opened, so send the same `sessionToken`.

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `placeId` | query | yes | string | The `placeId` of the suggestion the shopper picked. |
| `sessionToken` | query | yes | string | A random string (8-64 characters) the client makes up when the shopper starts typing an address. Send the same value on every autocomplete call and on the details call that ends the lookup. |
| `pass` | query | yes | string | The checkout pass from `POST /places/pass`, which allows address lookups for this cart. |
| `locale` | query | no | string | Language for the address, e.g. `en` or `de`. Omitted = Google's default. |

- `200` The address, in the widget's field names: `object { data }`
- `403` No valid checkout pass: `Error`
- `404` No such place: `Error`
- `429` This cart's or today's lookup budget is spent: `Error`
- `502` Google did not answer: `Error`
- `503` Address autocomplete is not configured here: `Error`

### POST /v3/public/{pk}/places/pass

A pass for address suggestions during checkout

Minted for the caller's cart (`X-Cart-Token`) when it holds at least one line. Send it as `pass` on `places/autocomplete` and `places/details`. Valid for an hour; minting another for the same cart does not reset that cart's lookup budget.

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

- `200` The pass: `object { data }`
- `404` No such store, or no cart with anything in it: `Error`
- `422` No X-Cart-Token: `Error`
- `429` Too many requests: `Error`
- `503` Address autocomplete is not configured here: `Error`

### GET /v3/public/{pk}/countries

Countries an address form can offer, optionally only those the store ships to

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `shippable` | query | no | string | `1` to return only the countries the store ships to. Omitted = every country. |

- `200` Countries, ordered by name: `object { data }`
- `404` Store not found: `Error`

### GET /v3/public/{pk}/payment-methods

The payment methods a shopper can choose from at checkout

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `currency` | query | no | string | The currency the shopper will pay in (the cart's presentment currency). Methods that cannot charge it are left out. Omitted = the store's base currency. |

- `200` Pickable methods, in display order: `object { data }`
- `404` Store not found: `Error`

### GET /v3/public/{pk}/free-shipping

The subtotal at which delivery to a country becomes free

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `country` | query | no | string | The destination country, as an ISO 3166-1 alpha-2 code such as `US`. Omitted = no destination, so the threshold is null. |

- `200` The threshold, or null when nothing ships free: `object { data }`
- `404` Store not found: `Error`

### POST /v3/public/{pk}/tax/quote

Quote cart tax (pure, cent-exact kernel)

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

Request body: `TaxQuoteInput`

- `200` The tax breakdown: `TaxQuoteResult`
- `422` Invalid input: `Error`

### POST /v3/public/{pk}/cart/checkout

Convert the cart into a pending order and reserve its stock (send X-Customer-Token to attach the order to a signed-in shopper)

Auth: `cartToken` (header `X-Cart-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

Request body: `object { email, customerName, taxNumber, expectedTotal, acceptPriceChanges, paymentMethod, notes, agreeToTerms, ... }`

- `201` The created order: `object { data }`
- `404` Store or cart not found: `Error`
- `409` Checkout refused (price drift, stock, or state): `object { rejection, message, priceChanges, stockIssues }`
- `422` Invalid input, or a cart token is required: `Error`

### GET /v3/public/{pk}/orders/{orderId}

The shopper-safe receipt for an order placed with this cart token

Auth: `cartToken` (header `X-Cart-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `orderId` | path | yes | string |  |

- `200` The receipt: `object { data }`
- `404` Store or order not found: `Error`
- `422` A cart token is required: `Error`

### GET /v3/public/{pk}/orders/{orderId}/status

Poll an order's payment status after returning from a hosted payment page

Auth: `cartToken` (header `X-Cart-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `orderId` | path | yes | string |  |

- `200` The order's current state: `object { data }`
- `404` Store or order not found: `Error`
- `422` A cart token is required: `Error`

### POST /v3/public/{pk}/orders/{orderId}/pay

Open a hosted payment session for a pending order

Auth: `cartToken` (header `X-Cart-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `orderId` | path | yes | string |  |

Request body: `object { returnUrl, cancelUrl, embedded, intent, paypalOrderId }`

- `201` The hosted payment session: `object { data }`
- `404` Store or order not found: `Error`
- `409` Payment refused: `object { rejection, message }`
- `422` Invalid input, or a cart token is required: `Error`

### POST /v3/public/{pk}/cart/card-setup

Start saving a card for a subscription with nothing to pay today (a Stripe SetupIntent)

Auth: `cartToken` (header `X-Cart-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

- `201` The SetupIntent to confirm with Stripe.js: `object { data }`
- `404` Store or cart not found: `Error`
- `409` Cards are not set up, or the cart has no subscription: `object { rejection, message }`
- `422` A cart token or an email is required: `Error`

### POST /v3/public/{pk}/orders/{orderId}/capture

Capture an approved PayPal order (called from the SDK's onApprove)

Auth: `cartToken` (header `X-Cart-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `orderId` | path | yes | string |  |

Request body: `object { paypalOrderId }`

- `200` The captured order: `object { data }`
- `404` Store or order not found: `Error`
- `409` Capture refused: `object { rejection, message }`
- `422` Invalid input, or a cart token is required: `Error`

### POST /v3/public/{pk}/orders/{orderId}/square-pay

Pay a Square order with a card token from the Web Payments SDK

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `orderId` | path | yes | string |  |

Request body: `object { paymentId, sourceId, verificationToken }`

- `200` The order after the charge. `paymentStatus` short of `paid` means the verdict is still coming: poll.: `object { data }`
- `404` Store, order or payment attempt not found: `Error`
- `409` Declined, or the payment could not be taken: `object { rejection, message }`
- `422` Invalid input, or a cart token is required: `Error`

### POST /v3/public/{pk}/orders/{orderId}/mollie-pay

Pay a Mollie order with a card token from Mollie Components

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `orderId` | path | yes | string |  |

Request body: `object { paymentId, cardToken }`

- `200` The order after the charge. A `redirectUrl` is the card's 3-D Secure page: send the shopper there. Otherwise `paymentStatus` short of `paid` means the verdict is still coming: poll.: `object { data }`
- `404` Store, order or payment attempt not found: `Error`
- `409` Declined, or the payment could not be taken: `object { rejection, message }`
- `422` Invalid input, or a cart token is required: `Error`

### POST /v3/public/{pk}/test-gateway/{paymentId}

Approve or decline a Shoprocket test-gateway payment

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `paymentId` | path | yes | string |  |

Request body: `object { outcome }`

- `200` The settled test payment: `object { paymentStatus, orderId }`
- `403` Not a valid merchant preview session: `Error`
- `404` Payment not found: `Error`

## Customer accounts

Passwordless sign-in by a one-time code, then the signed-in shopper's orders, downloads, licence keys and subscriptions. Send the session from `otp/verify` as `X-Customer-Token`.

### POST /v3/public/{pk}/customer/otp

Request a one-time login code

Emails a 6-digit code if the address has shopped here. Reveals whether it has (see D32: a conscious, rate-limited enumeration trade, matching Shopify) so a storefront can greet a returning shopper instead of demanding a code from someone with no history.

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

Request body: `object { email }`

- `200` Whether a code was sent: `object { data }`
- `404` Store not found: `Error`
- `429` Too many requests: `Error`

### POST /v3/public/{pk}/customer/otp/verify

Exchange a login code for a customer session

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

Request body: `object { email, code }`

- `200` The session token: `object { data }`
- `401` The code is wrong, expired, or already used: `Error`
- `404` Store not found: `Error`
- `429` Too many attempts: `Error`

### GET /v3/public/{pk}/customer/me

The authenticated customer

Auth: `customerToken` (header `X-Customer-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

- `200` The customer: `object { data }`
- `401` No valid customer session: `Error`
- `404` Store not found: `Error`

### PATCH /v3/public/{pk}/customer/me

Update the authenticated customer's name and phone

The account page's details form (#374). Only the signed-in customer's own row, only their name and phone: the email is the sign-in identity and cannot be changed here. An absent field is left as stored; an empty string clears it.

Auth: `customerToken` (header `X-Customer-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

Request body: `object { firstName, lastName, phone }`

- `200` The customer, as stored after the change: `object { data }`
- `401` No valid customer session: `Error`
- `404` Store not found: `Error`

### GET /v3/public/{pk}/customer/chat-identity

A signed-in shopper's identity for the store's live chat

A short-lived signed token (five minutes) that tells the store's live chat which signed-in customer is writing, so the seller sees their name and orders beside the conversation. Opaque: hand `it` to the chat as it is. `data` is null when the store's chat is off or not ready. Never cached; ask again on each page.

Auth: `customerToken` (header `X-Customer-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

- `200` The signed identity, or null: `object { data }`
- `401` No valid customer session: `Error`
- `404` Store not found: `Error`

### GET /v3/public/{pk}/customer/addresses

The authenticated customer's saved addresses

The address BOOK (mutable, reusable), never an order's frozen shipping snapshot. This is the read D32's auth rule exists to protect.

Auth: `customerToken` (header `X-Customer-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

- `200` Saved addresses: `object { data }`
- `401` No valid customer session: `Error`
- `404` Store not found: `Error`

### GET /v3/public/{pk}/customer/orders

The authenticated customer's order history

Auth: `customerToken` (header `X-Customer-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

- `200` Past orders, newest first: `object { data }`
- `401` No valid customer session: `Error`
- `404` Store not found: `Error`

### GET /v3/public/{pk}/customer/subscriptions

The authenticated customer's subscriptions

Every recurring purchase this shopper holds (D163), live ones first. This is the screen that decides whether a shopper who wants a break pauses or cancels, so it carries the state every self-service action needs: when the next charge lands, how many payments are left on a commitment, and why the last one failed.

Auth: `customerToken` (header `X-Customer-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

- `200` Subscriptions: `object { data }`
- `401` No valid customer session: `Error`
- `404` Store not found: `Error`

### POST /v3/public/{pk}/customer/subscriptions/{subscriptionId}

Pause, resume, skip, reschedule or cancel a subscription

One endpoint, one `action` (D163), because these are five moves on the same object and a route each would put the state machine in five places. Pause and skip exist for the same reason cancel does: a shopper who cannot do the small thing does the large one.

Auth: `customerToken` (header `X-Customer-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |
| `subscriptionId` | path | yes | string |  |

Request body: `object { action, planId }`

- `200` The updated subscription: `object { data }`
- `401` No valid customer session: `Error`
- `404` Store or subscription not found: `Error`
- `409` The action is not available on this subscription: `Error`

### GET /v3/public/{pk}/customer/licence-keys

The authenticated customer's licence keys

Auth: `customerToken` (header `X-Customer-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

- `200` Licence keys, newest first: `object { data }`
- `401` No valid customer session: `Error`
- `404` Store not found: `Error`

### GET /v3/public/{pk}/customer/library

The authenticated customer's downloadable files

Auth: `customerToken` (header `X-Customer-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

- `200` Downloadable files, newest first: `object { data }`
- `401` No valid customer session: `Error`
- `404` Store not found: `Error`

### POST /v3/public/{pk}/customer/signout

Revoke the presented customer session

Auth: `customerToken` (header `X-Customer-Token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

- `204` Signed out
- `404` Store not found: `Error`

## Newsletter

Marketing sign-ups with recorded consent.

### POST /v3/public/{pk}/newsletter

Ask to subscribe an email address to a store's marketing list (confirmed by email)

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

Request body: `object { email, name }`

- `200` Recorded. The same answer whether the address was new or already subscribed; a new address is mailed a confirmation link and is not subscribed until it is clicked.: `NewsletterSignupResponse`
- `404` Store not found: `Error`
- `429` Too many sign-ups from this caller just now: `Error`

## Contact form

A shopper's message to the store, stored and emailed to the seller.

### POST /v3/public/{pk}/contact

Send the store a message from a page's contact form

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

Request body: `object { name, email, phone, orderNumber, message, consent, sectionId, pagePath, ... }`

- `200` Received. The same answer for every accepted request.: `ContactMessageResponse`
- `404` Store not found: `Error`
- `413` Body too large: `Error`
- `422` A field is missing, malformed or too long: `Error`
- `429` Too many messages from this caller, or to this store, just now: `Error`

### POST /v3/public/{pk}/quote-requests

Ask the store for a price: one product's enquiry, or a quote-mode cart

Lines name variants only; the store's catalogue supplies every name and price. The shopper gets a confirmation email in `locale` (else the store's language). The answer is the same `{ ok: true }` for every accepted request.

Auth: none

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `pk` | path | yes | string |  |

Request body: `object { name, email, phone, message, lines, source, pagePath, locale, ... }`

- `200` Received.: `QuoteRequestResponse`
- `404` Store not found: `Error`
- `413` Body too large: `Error`
- `422` A field is missing or malformed, or a product isn't available: `Error`
- `429` Too many requests from this caller, or to this store, just now: `Error`

## Licence API

For the software you sell: activate a licence key on a device, validate it on every launch, deactivate it to free the seat. The same shape as Lemon Squeezy's License API, plus the product's secret (from the product's Licence keys card) on every call. Validate is answered at the edge nearest the caller.

### POST /v3/public/licenses/validate

Check a licence key (and optionally one activation of it)

Call on every launch. `valid` is false, with `error` saying why, when the key is suspended, expired or disabled. Pass `instance_id` to also confirm this device is still activated: an id the key no longer has answers 404, and the app should treat the device as deactivated. Answered at the edge nearest the caller. Offline: cache the last answer and its `expires_at`, and allow a grace period of your choosing (Keygen suggests days, not hours) before refusing.

Auth: none

Request body: `object { product_secret, license_key, instance_id, instance_name }`

- `200` The key exists; `valid` says whether it works: `object { valid, error, license_key, instance, meta }`
- `400` Missing fields: `object { valid, error, license_key, instance, meta }`
- `404` No such key for this product secret, or no such instance: `object { valid, error, license_key, instance, meta }`
- `429` Too many requests from this address: `object`

### POST /v3/public/licenses/activate

Activate a licence key on one device

Counts against the key's device limit. Store the returned `instance.id` on the device: validate and deactivate both take it. A key that is suspended, expired or disabled, or that has no seats left, answers `activated: false` with the reason.

Auth: none

Request body: `object { product_secret, license_key, instance_id, instance_name }`

- `200` Activated: `object { activated, error, license_key, instance, meta }`
- `400` Not activated: the reason is in `error`: `object { activated, error, license_key, instance, meta }`
- `404` No such key for this product secret: `object { activated, error, license_key, instance, meta }`
- `429` Too many requests from this address: `object`

### POST /v3/public/licenses/deactivate

Deactivate a licence key on one device, freeing the seat

Works on a suspended or expired key too, so a buyer can always free a seat.

Auth: none

Request body: `object { product_secret, license_key, instance_id, instance_name }`

- `200` Deactivated: `object { deactivated, error, license_key, meta }`
- `400` Missing fields: `object { deactivated, error, license_key, meta }`
- `404` No such key for this product secret, or no such instance on it: `object { deactivated, error, license_key, meta }`
- `429` Too many requests from this address: `object`

## Test stores

For an AI code builder adding a shop to a site for someone with no Shoprocket account yet: create a test-mode store with no authentication, add products with the catalogue-scoped secret key it returns, and give the person the claim URL. Signing up there makes the store theirs. Unclaimed stores are deleted after 7 days.

### POST /v3/agent-stores

Create a test store with no account, for an AI builder agent

For a code builder (Lovable, Bolt, Claude Code, Cursor) adding a shop to a site for someone who has no Shoprocket account yet. No authentication. Answers with a working store in test mode: its publishable key for the site, a secret key (scoped to the catalogue, for adding products and images through the seller API from your own environment, never browser code), and a claim URL to give the person. Opening it and signing up makes the store theirs, with everything in it. Until claimed: test checkout only (the built-in test gateway), no email is sent, at most 25 products and 50 MB of images, the hosted shop is not indexed, and every other setting answers `claim_required`. An unclaimed store is deleted at `expiresAt` (7 days). Limited to 10 per IP address per day.

Auth: none

Request body: `object { name, baseCurrencyCode }`

- `201` The new test store, its keys and its claim URL: `object { data }`
- `422` Invalid input: `Error`
- `429` Too many test stores created today: `Error`

## Store settings

The store record, its presentment currencies and its tax settings.

### GET /v3/stores/{storeId}

Get a store (General settings)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

- `200` The store: `StoreResponse`
- `401` Not authenticated: `Error`
- `403` Not a member of the store: `Error`
- `404` Store not found: `Error`

### PUT /v3/stores/{storeId}

Update a store's General settings

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { name, baseCurrencyCode, logoMediaId, logoDarkMediaId, logoIsLight, iconMediaId, timezone, weightUnit, ... }`

- `200` The updated store: `StoreResponse`
- `401` Not authenticated: `Error`
- `403` Not a member of the store, over the plan's language allowance, or removing the Shoprocket badge without the white-label entitlement: `Error | PlanLimitError`
- `404` Store not found: `Error`
- `422` Invalid input: `Error`

### GET /v3/stores/{storeId}/tax-settings

Get the store's tax settings, registrations, and nexus-threshold progress

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

- `200` The store's tax settings: `TaxSettingsResponse`
- `401` Not authenticated: `Error`
- `403` Not a member of the store: `Error`
- `404` Store not found: `Error`

### PUT /v3/stores/{storeId}/tax-settings

Update the store's tax settings (registrations fully replace when provided)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { taxEnabled, taxPricingType, taxAddressBasis, collectTaxNumber, taxNumberRequired, reverseChargeEnabled, priceDisplay, exTaxDisplayTagIds, ... }`

- `200` The updated tax settings: `TaxSettingsResponse`
- `401` Not authenticated: `Error`
- `403` Not a member of the store: `Error`
- `404` Store not found: `Error`
- `422` Invalid input: `Error`

### GET /v3/stores/{storeId}/quote-settings

Get the store's quote mode and catalogue mode

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

- `200` The store's quote settings: `QuoteSettingsResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### PUT /v3/stores/{storeId}/quote-settings

Turn quote mode and catalogue mode on or off

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { quoteMode, hidePrices }`

- `200` The updated quote settings: `QuoteSettingsResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`
- `422` Invalid input: `Error`

### GET /v3/stores/{storeId}/invoice-settings

Get the store's invoice numbering, legal footer and small-business exemption

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

- `200` The store's invoice settings: `object { data }`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### PUT /v3/stores/{storeId}/invoice-settings

Update the store's invoice numbering, legal footer and small-business exemption

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { invoicePrefix, creditNotePrefix, nextInvoiceNumber, nextCreditNoteNumber, footerText, smallBusinessExemption, smallBusinessStatement, einvoiceAttachEmail }`

- `200` The updated invoice settings: `object { data }`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`
- `409` A series has already issued a document, so its next number is fixed: `Error`
- `422` Invalid input: `Error`

### GET /v3/stores/{storeId}/verifactu

Get the state of the store's Veri*Factu billing records (Spain)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

- `200` The store's Veri*Factu record state: `object { data }`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### GET /v3/stores/{storeId}/currencies

The store's presentment currencies, with each one's live rate and sellability

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

- `200` The store's currencies: `StoreCurrencyListResponse`
- `401` Not authenticated: `Error`
- `403` Not a member of the store: `Error`
- `404` Store not found: `Error`

### POST /v3/stores/{storeId}/currencies

Offer another presentment currency

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { code, manualUnitsPerUsd, roundingRule, bufferPercent }`

- `201` The updated list: `StoreCurrencyListResponse`
- `401` Not authenticated: `Error`
- `403` Not allowed to change settings: `Error`
- `404` Store not found: `Error`
- `409` Already offered, or the same as the base currency: `Error`
- `422` Unknown currency, or the plan's limit is reached: `Error`

### PUT /v3/stores/{storeId}/currencies

Apply the whole Currencies screen in one atomic save

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { currencies }`

- `200` The updated list: `StoreCurrencyListResponse`
- `401` Not authenticated: `Error`
- `403` Not allowed to change settings: `Error`
- `404` Store not found: `Error`
- `409` A duplicate currency, or the base currency: `Error`
- `422` Unknown currency, or the plan's limit is reached: `Error`

### PUT /v3/stores/{storeId}/currencies/{currencyId}

Change a presentment currency's rate pin, rounding or buffer

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `currencyId` | path | yes | string |  |

Request body: `object { manualUnitsPerUsd, roundingRule, bufferPercent, position }`

- `200` The updated list: `StoreCurrencyListResponse`
- `401` Not authenticated: `Error`
- `403` Not allowed to change settings: `Error`
- `404` Store or currency not found: `Error`

### DELETE /v3/stores/{storeId}/currencies/{currencyId}

Stop offering a presentment currency

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `currencyId` | path | yes | string |  |

- `200` The updated list: `StoreCurrencyListResponse`
- `401` Not authenticated: `Error`
- `403` Not allowed to change settings: `Error`
- `404` Store or currency not found: `Error`

## Products

Products, variants, media, digital files, licence keys and attribute definitions.

### GET /v3/stores/{storeId}/products

List a store's products (filter by status/search/date; paginate when page/limit given)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `status` | query | no | "draft" \| "active" \| "archived" | Only products in this status: `draft`, `active` or `archived`. |
| `q` | query | no | string | Search text, matched against the product's name and summary. |
| `from` | query | no | string | Only products created on or after this date (`YYYY-MM-DD` or an ISO 8601 timestamp). |
| `to` | query | no | string | Only products created on or before this date (`YYYY-MM-DD` or an ISO 8601 timestamp). |
| `ids` | query | no | string | Only these products: a comma-separated list of product ids (`prod_...`), at most 100. An id that is not valid matches nothing. |
| `stock` | query | no | "low" \| "out" | Only products with a stock-tracked variant that is `out` of stock (0 or below) or `low` (1 to 5 left). |
| `category` | query | no | string | Only products in this category (`cat_...`). |
| `page` | query | no | integer | Page number, starting at 1. Send `page` or `limit` to paginate (and get `total`); with neither, every matching product is returned. |
| `limit` | query | no | integer | Results per page, at most 100. Send `page` or `limit` to paginate; with neither, every matching product is returned. |
| `sort` | query | no | string | Field to sort by: `name`, `status`, `createdAt`, `minPrice`, `variantCount`, `stockTotal`. Any other value is ignored. |
| `dir` | query | no | "asc" \| "desc" | Sort direction: `asc` (ascending) or `desc` (descending). |

- `200` The store's products: `ProductListResponse`
- `401` Not authenticated: `Error`
- `403` Not a member of the store: `Error`
- `404` Store not found: `Error`

### POST /v3/stores/{storeId}/products

Create a product

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { name, summary, description, status, kind, taxClass, seoTitle, seoDescription, ... }`

- `201` The created product: `ProductResponse`
- `401` Not authenticated: `Error`
- `403` Over the plan's product limit: `PlanLimitError`
- `404` Store not found: `Error`
- `422` Invalid input: `Error`

### GET /v3/stores/{storeId}/products/{id}

Get a product

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` The product: `ProductResponse`
- `401` Not authenticated: `Error`
- `404` Product not found: `Error`

### PUT /v3/stores/{storeId}/products/{id}

Update a product

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { name, slug, summary, description, status, taxClass, seoTitle, seoDescription, ... }`

- `200` The updated product: `ProductResponse`
- `401` Not authenticated: `Error`
- `404` Product not found: `Error`
- `422` Invalid input: `Error`

### DELETE /v3/stores/{storeId}/products/{id}

Delete a product

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `204` Deleted
- `401` Not authenticated: `Error`
- `404` Product not found: `Error`

### PUT /v3/stores/{storeId}/products/{id}/variants/{variantId}

Update a variant's commercial fields

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |
| `variantId` | path | yes | string |  |

Request body: `object { sku, barcode, price, giftCardValue, compareAtPrice, costPrice, inventoryQuantity, inventoryPolicy, ... }`

- `200` The updated variant: `VariantResponse`
- `401` Not authenticated: `Error`
- `404` Variant not found: `Error`
- `422` Invalid input: `Error`

### POST /v3/stores/{storeId}/products/changes

Change several products at once, all or nothing

Writes every row, or none: each row names the fields it sets and the values they must still hold, checked inside one transaction with the rows locked. A 409 lists the rows that no longer hold them (by index), and then nothing was written. Used by the store assistant's product proposals.

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { rows }`

- `200` Every row was written: `object { data }`
- `401` Not authenticated: `Error`
- `409` A row no longer holds its expected values; nothing was written: `object { error }`
- `422` Invalid input: `Error`

### POST /v3/stores/{storeId}/products/bulk

Set the status of, or delete, many products at once

Acts on either `ids` (the rows the seller ticked) or `filter` (the product list's own filters, for "select all matching"). A filter matching more than 1,000 products is refused with a 422 rather than cut short. Deleting also needs the delete-products permission.

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { action, ids, filter }`

- `200` How many products the action changed: `object { data }`
- `401` Not authenticated: `Error`
- `403` Not allowed to delete products: `Error`
- `422` Invalid input, or the filter matches too many products: `Error`

### POST /v3/stores/{storeId}/products/{id}/media

Attach an uploaded media object to a product

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { mediaId, variantId, position }`

- `201` The product with the attached image: `ProductResponse`
- `401` Not authenticated: `Error`
- `404` Product, media, or variant not found: `Error`
- `409` Media already attached: `Error`
- `422` Invalid input: `Error`

### DELETE /v3/stores/{storeId}/products/{id}/media/{mediaId}

Detach a media object from a product (the media asset itself is kept)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |
| `mediaId` | path | yes | string |  |

- `204` Detached
- `401` Not authenticated: `Error`
- `404` Not found: `Error`

### PUT /v3/stores/{storeId}/products/{id}/media/order

Reorder a product's attached media (position = index in the given list)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { mediaIds }`

- `200` The product with reordered images: `ProductResponse`
- `401` Not authenticated: `Error`
- `404` Product not found: `Error`
- `422` Invalid input: `Error`

### GET /v3/stores/{storeId}/products/{id}/files

List a product's digital-download files

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` The product's file pool: `ProductFileListResponse`
- `401` Not authenticated: `Error`
- `404` Product not found: `Error`

### POST /v3/stores/{storeId}/products/{id}/files

Upload a digital-download file to a product's pool (interim through-Worker; D66)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |
| `filename` | query | yes | string | The file's name, as the buyer will see it when downloading. |

Request body: `string (binary)`

- `201` The stored file: `ProductFileResponse`
- `401` Not authenticated: `Error`
- `404` Product not found: `Error`
- `413` File too large: `Error`
- `422` Invalid upload: `Error`

### GET /v3/stores/{storeId}/products/{id}/licence-keys

The product's licence key pool, with per-status counts

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |
| `status` | query | no | "available" \| "assigned" \| "suspended" \| "expired" \| "disabled" | Only keys in this status. |
| `q` | query | no | string | Only keys containing this text. |
| `page` | query | no | integer | Page number, starting at 1. |
| `limit` | query | no | integer | Results per page, at most 200 (default 50). |

- `200` A page of keys plus the pool summary: `LicenseKeyListResponse`
- `401` Not authenticated: `Error`
- `404` Product not found: `Error`

### POST /v3/stores/{storeId}/products/{id}/licence-keys

Add keys to the pool (paste one per line or comma-separated)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { keys, variantId }`

- `201` What landed, and what was skipped: `AddLicenseKeysResponse`
- `401` Not authenticated: `Error`
- `404` Product not found: `Error`
- `422` Nothing usable in the paste: `Error`

### POST /v3/stores/{storeId}/products/{id}/licence-keys/{keyId}/revoke

Disable a key so it is never issued again

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |
| `keyId` | path | yes | string |  |

- `200` The disabled key: `LicenseKeyResponse`
- `401` Not authenticated: `Error`
- `404` Key not found: `Error`

### GET /v3/stores/{storeId}/products/{id}/licence-keys/endpoint-secret

Read the signing secret for this product's key server

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` The secret, or null: `LicenseKeyEndpointSecretResponse`
- `401` Not authenticated: `Error`
- `404` Product not found: `Error`

### POST /v3/stores/{storeId}/products/{id}/licence-keys/endpoint-secret/rotate

Replace the key server's signing secret

Requests signed with the old secret stop verifying immediately; update your server first.

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` The new secret: `LicenseKeyEndpointSecretResponse`
- `401` Not authenticated: `Error`
- `404` Product not found: `Error`

### GET /v3/stores/{storeId}/products/{id}/licence-keys/api-secret

Read the product secret your software sends to the licence API

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` The secret, or null while the licence API is off: `LicenseApiSecretResponse`
- `401` Not authenticated: `Error`
- `404` Product not found: `Error`

### POST /v3/stores/{storeId}/products/{id}/licence-keys/api-secret/rotate

Replace the product secret your software sends to the licence API

Copies of your software built with the old secret stop getting answers within a minute. Ship an update carrying the new one first.

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` The new secret: `LicenseApiSecretResponse`
- `401` Not authenticated: `Error`
- `404` Product not found: `Error`
- `422` The licence API is off for this product: `Error`

### GET /v3/stores/{storeId}/products/{id}/licence-keys/{keyId}/instances

List the devices a licence key is activated on

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |
| `keyId` | path | yes | string |  |

- `200` Activations, oldest first: `LicenseKeyInstanceList`
- `401` Not authenticated: `Error`
- `404` Key not found: `Error`

### POST /v3/stores/{storeId}/products/{id}/licence-keys/{keyId}/instances/{instanceId}/deactivate

Deactivate one device on a licence key

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |
| `keyId` | path | yes | string |  |
| `instanceId` | path | yes | string |  |

- `200` The key's remaining activations: `LicenseKeyInstanceList`
- `401` Not authenticated: `Error`
- `404` Key or activation not found: `Error`

### POST /v3/stores/{storeId}/products/{id}/licence-keys/endpoint-test

Send a test request to this product's key server

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` What the server answered: `LicenseKeyEndpointTestResponse`
- `401` Not authenticated: `Error`
- `404` Product not found: `Error`
- `422` No key server saved: `Error`

### DELETE /v3/stores/{storeId}/products/{id}/files/{fileId}

Remove a file from a product's download pool

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |
| `fileId` | path | yes | string |  |

- `204` File removed
- `401` Not authenticated: `Error`
- `404` File not found: `Error`

### GET /v3/stores/{storeId}/product-order

Every product in the shop's custom order (placed first, then newest): what the featured sort shows

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

- `200` The shop's product order: `object { data }`
- `401` Not authenticated: `Error`

### PUT /v3/stores/{storeId}/product-order

Set the shop's custom order (productSort is accepted but no longer changes any listing)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { productSort, productOrder }`

- `200` The shop's product order: `object { data }`
- `401` Not authenticated: `Error`
- `422` Invalid input: `Error`

### GET /v3/stores/{storeId}/attributes

List the store's attribute definitions (archived included, in display order)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

- `200` The store's attributes: `StoreAttributeListResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### POST /v3/stores/{storeId}/attributes

Define an attribute

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { name, handle, type, unit, filterable, visible, status, values }`

- `201` The created attribute: `StoreAttributeResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`
- `422` Invalid input: `Error`

### PUT /v3/stores/{storeId}/attributes/order

Reorder the library (position = index in the list you send)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { ids }`

- `200` The reordered attributes: `StoreAttributeListResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`
- `422` Invalid input (unknown id): `Error`

### PUT /v3/stores/{storeId}/attributes/{id}

Update a definition (edits reach every product carrying it)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { name, handle, type, unit, filterable, visible, status, values }`

- `200` The updated attribute: `StoreAttributeResponse`
- `401` Not authenticated: `Error`
- `404` Attribute not found: `Error`
- `422` Invalid input: `Error`

### DELETE /v3/stores/{storeId}/attributes/{id}

Delete a definition (it disappears from every product and from the filters)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `204` Deleted
- `401` Not authenticated: `Error`
- `404` Attribute not found: `Error`

## Categories

The category tree and the products in each category.

### GET /v3/stores/{storeId}/categories

List a store's categories (flat, ordered; build the tree from parentId)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

- `200` The store's categories: `CategoryListResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### POST /v3/stores/{storeId}/categories

Create a category

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { name, slug, parentId, status, imageMediaId, productSort }`

- `201` The created category: `CategoryResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`
- `422` Invalid input: `Error`

### PUT /v3/stores/{storeId}/categories/order

Reorder + reparent categories (position = index among same-parent siblings)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { items }`

- `200` The reordered categories: `CategoryListResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`
- `422` Invalid input (unknown id or cycle): `Error`

### GET /v3/stores/{storeId}/categories/{id}/products

The products in a category, in its manual order (placed first, then newest)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` The category's products: `object { data }`
- `401` Not authenticated: `Error`
- `404` Category not found: `Error`

### PUT /v3/stores/{storeId}/categories/{id}

Update a category

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { name, slug, parentId, status, imageMediaId, productSort, productOrder }`

- `200` The updated category: `CategoryResponse`
- `401` Not authenticated: `Error`
- `404` Category not found: `Error`
- `422` Invalid input: `Error`

### DELETE /v3/stores/{storeId}/categories/{id}

Delete a category (children reparent to its parent; product links are removed)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `204` Deleted
- `401` Not authenticated: `Error`
- `404` Category not found: `Error`

## Media

Upload an image once, then attach it to products.

### POST /v3/stores/{storeId}/media

Upload an image (interim through-Worker; presigned PUT + normalize deferred, D16)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `alt` | query | no | string | Alt text for the image, read by screen readers and search engines. |
| `width` | query | no | integer | The image's width in pixels, if the uploader already knows it. |
| `height` | query | no | integer | The image's height in pixels, if the uploader already knows it. |
| `intakeLabel` | query | no | "catalogue" \| "logo" \| "product-photo" \| "lifestyle-photo" \| "inspiration" \| "document" \| "unknown" | Used only by the store set-up flow to say what kind of picture this is. API callers leave it out. |

Request body: `string (binary)`

- `201` The stored media object: `MediaResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`
- `413` File too large: `Error`
- `422` Invalid upload: `Error`

### GET /v3/stores/{storeId}/media/{mediaId}

One media object, with its processing state

What an uploader polls after sending a video: `processing` until the box has transcoded it, then `ready` with its playable files, or `failed` with the reason.

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `mediaId` | path | yes | string |  |

- `200` The media object: `MediaResponse`
- `401` Not authenticated: `Error`
- `404` Not found: `Error`

## Orders

Orders, fulfilments, refunds, returns and tax documents.

### POST /v3/stores/{storeId}/orders/{id}/licence-key-requests/{requestId}/retry

Retry fetching a licence key from the seller's key server

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |
| `requestId` | path | yes | string |  |

- `200` The order after the attempt: `OrderResponse`
- `401` Not authenticated: `Error`
- `404` Request not found: `Error`

### POST /v3/stores/{storeId}/orders/{id}/licence-key-requests/{requestId}/resolve

Finish a licence key request with a key typed in by hand

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |
| `requestId` | path | yes | string |  |

Request body: `object { key }`

- `200` The order with the key: `OrderResponse`
- `401` Not authenticated: `Error`
- `404` Request not found: `Error`

### GET /v3/stores/{storeId}/orders

List a store's orders (filter by status/payment, search, paginate)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `status` | query | no | "draft" \| "open" \| "archived" \| "cancelled" \| "incomplete" | Only orders in this status. `incomplete` lists abandoned payment attempts where nothing was paid, and `draft` lists drafts; with no value both are left out. |
| `paymentStatus` | query | no | "pending" \| "processing" \| "paid" \| "partially_paid" \| "partially_refunded" \| "refunded" \| "failed" | Only orders with this payment status. |
| `fulfillmentStatus` | query | no | "unfulfilled" \| "partially_fulfilled" \| "fulfilled" | Only orders with this fulfillment status: `unfulfilled`, `partially_fulfilled` or `fulfilled`. |
| `q` | query | no | string | Search text, matched against the order number, customer name and email. |
| `customer` | query | no | string | Only this customer's orders (`cust_...`), matched on the linked customer rather than the email text. An unknown id matches nothing. |
| `mode` | query | no | "live" \| "test" | `live` for real orders only, `test` for test-mode orders only. Omit for both. |
| `from` | query | no | string | Only orders placed on or after this date (`YYYY-MM-DD` or an ISO 8601 timestamp). |
| `to` | query | no | string | Only orders placed on or before this date (`YYYY-MM-DD` or an ISO 8601 timestamp). |
| `pickupFrom` | query | no | string | Only pickup orders to be collected on or after this date (`YYYY-MM-DD`). |
| `pickupTo` | query | no | string | Only pickup orders to be collected on or before this date (`YYYY-MM-DD`). |
| `page` | query | no | integer | Page number, starting at 1. |
| `limit` | query | no | integer | Results per page, at most 100 (default 50). |
| `sort` | query | no | string | Field to sort by: `orderNumber`, `placedAt`, `paymentStatus`, `fulfillmentStatus`, `status`, `total`, `itemCount`, `customerName`, `pickupDate` (orders without one last). Any other value is ignored. |
| `dir` | query | no | "asc" \| "desc" | Sort direction: `asc` (ascending) or `desc` (descending). |

- `200` The store's orders (a page of summaries + total): `OrderListResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### POST /v3/stores/{storeId}/orders

Create a manual order (server computes totals + allocates the order number)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { customerName, email, taxNumber, status, paymentStatus, fulfillmentStatus, cancelReason, cancelNote, ... }`

- `201` The created order: `OrderResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`
- `422` Invalid input: `Error`

### GET /v3/stores/{storeId}/orders/{id}

Get one order with its items, taxes, and addresses

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` The order: `OrderResponse`
- `401` Not authenticated: `Error`
- `404` Order not found: `Error`

### PUT /v3/stores/{storeId}/orders/{id}

Update an order (items/taxes/addresses fully replace when provided; totals recompute)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { customerName, email, taxNumber, status, paymentStatus, cancelReason, cancelNote, notifyCustomer, ... }`

- `200` The updated order: `OrderResponse`
- `401` Not authenticated: `Error`
- `404` Order not found: `Error`
- `409` A draft whose quote the customer is paying right now (#1583): `Error`
- `422` Invalid input, or the refund a cancel asked for (`refund: true`) was refused (nothing changed): `Error | object { rejection, message, manualTenders }`

### DELETE /v3/stores/{storeId}/orders/{id}

Delete an order (soft-delete)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `204` Deleted
- `401` Not authenticated: `Error`
- `404` Order not found: `Error`
- `409` A draft whose quote the customer is paying right now: `Error`

### POST /v3/stores/{storeId}/orders/{id}/comments

Leave a team comment on an order's timeline

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { body }`

- `201` The comment: `object { data }`
- `401` Not authenticated: `Error`
- `403` An API key cannot comment: `Error`
- `404` Order not found: `Error`
- `422` Invalid input: `Error`

### DELETE /v3/stores/{storeId}/orders/{id}/comments/{commentId}

Remove your own comment from an order's timeline

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |
| `commentId` | path | yes | string |  |

- `204` Removed
- `401` Not authenticated: `Error`
- `404` Comment not found: `Error`

### PUT /v3/stores/{storeId}/orders/{id}/tags

Replace an order's tags

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { tags }`

- `200` The order's tags now: `object { data }`
- `401` Not authenticated: `Error`
- `404` Order not found: `Error`
- `422` Invalid input: `Error`

### GET /v3/stores/{storeId}/order-tags

Every order tag the store has used, most used first

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

- `200` The tags: `object { data }`
- `401` Not authenticated: `Error`

### POST /v3/stores/{storeId}/orders/{id}/payment-link

Get a draft order's payment link to copy, minting it the first time (restarts its 30 days)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` The payment link: `object { data }`
- `401` Not authenticated: `Error`
- `404` Draft not found: `Error`
- `409` The order is not a draft: `Error`

### POST /v3/stores/{storeId}/orders/{id}/payment-link/send

Email a draft order to its customer, with the link that pays it

Emails the draft's customer (its `email`) the draft, as a quote when it was made from a quote request and as an order otherwise: the items at the draft's prices, every figure the total includes, an optional note, and a link that opens checkout for exactly this draft. Re-sending sends the same link again and restarts its 30 days. Paying it completes the draft.

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { message }`

- `200` The payment link, as sent: `object { data }`
- `401` Not authenticated: `Error`
- `404` Draft not found: `Error`
- `409` The order is not a draft: `Error`
- `422` The draft has no customer email, no lines, or the email could not be sent: `Error`

### POST /v3/stores/{storeId}/test-orders/delete

Delete test orders and what they produced (never a live order)

Deletes the named test orders, or every test order in the store when `orderIds` is omitted. Licence keys they took from the pool go back to it; keys that were generated for them or supplied by your own key server are deleted. Their gift cards, download links, discount usage, stock holds and test customers go with them. A named order that is live or not there refuses the whole request with 409 and nothing is deleted.

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { orderIds }`

- `200` What was deleted: `object { data }`
- `401` Not authenticated: `Error`
- `403` Missing delete-orders: `Error`
- `409` A named order is not a test order: `Error`

### POST /v3/stores/{storeId}/orders/{id}/fulfillments

Record a shipment (partial or full) and move the stock

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { items, trackingCompany, trackingNumber, trackingUrl, note, notifyCustomer }`

- `201` The order, with the new shipment: `OrderResponse`
- `401` Not authenticated: `Error`
- `404` Order not found: `Error`
- `422` The shipment was refused: `Error`

### POST /v3/stores/{storeId}/orders/{id}/pickup-ready

Mark a pickup order ready to collect and email the customer

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { notifyCustomer }`

- `200` The order, with the pickup marked ready: `OrderResponse`
- `401` Not authenticated: `Error`
- `404` Order not found: `Error`
- `422` Not a pickup order, or not in a state to collect: `Error`

### POST /v3/stores/{storeId}/orders/{id}/local-delivery

Mark a local delivery order out for delivery, or delivered

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { step }`

- `200` The order, with the step stamped (and fulfilled, for delivered): `OrderResponse`
- `401` Not authenticated: `Error`
- `404` Order not found: `Error`
- `422` Not a local delivery order, or not in a state to deliver: `Error`

### POST /v3/stores/{storeId}/orders/{id}/fulfillments/{fulfillmentId}/cancel

Cancel a shipment and return its units to the order

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |
| `fulfillmentId` | path | yes | string |  |

- `200` The order, with the shipment cancelled: `OrderResponse`
- `401` Not authenticated: `Error`
- `404` Order or shipment not found: `Error`
- `422` The cancellation was refused: `Error`

### POST /v3/stores/{storeId}/orders/{id}/returns

Open a return: fulfilled units are expected back

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { items, note }`

- `201` The order, with the new return: `OrderResponse`
- `401` Not authenticated: `Error`
- `404` Order not found: `Error`
- `422` The return was refused: `Error`

### POST /v3/stores/{storeId}/orders/{id}/returns/{returnId}/close

Close a return: record what arrived and restock or discard it

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |
| `returnId` | path | yes | string |  |

Request body: `object { items }`

- `200` The order, with the return closed: `OrderResponse`
- `401` Not authenticated: `Error`
- `404` Order or return not found: `Error`
- `422` The close was refused: `Error`

### POST /v3/stores/{storeId}/orders/{id}/returns/{returnId}/cancel

Cancel an open return: the units are no longer expected back

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |
| `returnId` | path | yes | string |  |

- `200` The order, with the return cancelled: `OrderResponse`
- `401` Not authenticated: `Error`
- `404` Order or return not found: `Error`
- `422` The cancellation was refused: `Error`

### GET /v3/stores/{storeId}/orders/{id}/invoices

The tax documents issued for this order

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` Invoices and credit notes, newest first: `OrderInvoiceListResponse`
- `401` Not authenticated: `Error`
- `404` Order not found: `Error`

### POST /v3/stores/{storeId}/orders/{id}/invoices

Issue the tax invoice for this order (idempotent), optionally emailing it

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { send }`

- `200` Every document on the order after issuing: `OrderInvoiceListResponse`
- `401` Not authenticated: `Error`
- `404` Order not found: `Error`
- `422` The order cannot be invoiced yet: `Error`

### POST /v3/stores/{storeId}/orders/{id}/refunds

Refund an order (allocates across tenders, gift cards first)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { amount, reason, restock, idempotencyKey, acknowledgeManualRefund, notifyCustomer }`

- `201` What each tender refunded: `object { orderId, paymentStatus, refunds }`
- `401` Not authenticated: `Error`
- `404` Order not found: `Error`
- `422` The refund was refused: `object { rejection, message, manualTenders }`

### GET /v3/stores/{storeId}/orders/{id}/journey

The visit that produced an order: source, campaign, device and pages seen

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` The journey, or found:false when there is none: `OrderJourneyResponse`
- `401` Not authenticated: `Error`
- `403` Missing view-orders: `Error`
- `404` Order not found: `Error`

## Carts

Live carts that have not become orders yet.

### GET /v3/stores/{storeId}/carts

List a store's live (unconverted) carts, newest activity first

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `q` | query | no | string | Search text, matched against the shopper's name and email. |
| `from` | query | no | string | Only carts last active on or after this date (`YYYY-MM-DD` or an ISO 8601 timestamp). |
| `to` | query | no | string | Only carts last active on or before this date (`YYYY-MM-DD` or an ISO 8601 timestamp). |
| `page` | query | no | integer | Page number, starting at 1. |
| `limit` | query | no | integer | Results per page, at most 100 (default 50). |
| `sort` | query | no | string | Field to sort by: `updatedAt`, `itemCount`, `total`, `customerName`. Any other value is ignored. |
| `dir` | query | no | "asc" \| "desc" | Sort direction: `asc` (ascending) or `desc` (descending). |

- `200` The store's live carts (a page of summaries + total): `SellerCartListResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### GET /v3/stores/{storeId}/carts/{id}

A live cart with its lines (the detail behind the list's drawer)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` The cart: `SellerCartResponse`
- `401` Not authenticated: `Error`
- `404` Store or cart not found: `Error`

## Customers

Customer records and tags.

### GET /v3/stores/{storeId}/customers

List a store's customers (filter by status, search, paginate)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `status` | query | no | "active" \| "archived" | Only customers in this status: `active` or `archived`. |
| `q` | query | no | string | Search text, matched against first name, last name and email. |
| `from` | query | no | string | Only customers created on or after this date (`YYYY-MM-DD` or an ISO 8601 timestamp). |
| `to` | query | no | string | Only customers created on or before this date (`YYYY-MM-DD` or an ISO 8601 timestamp). |
| `tag` | query | no | string | Only customers with this tag (`ctag_...`). An unknown id matches nobody. |
| `ids` | query | no | string | Only these customers: a comma-separated list of customer ids (`cust_...`), at most 100. An id that is not valid matches nothing. |
| `mode` | query | no | "live" \| "test" | `live` for real customers only, `test` for customers created in test mode only. Omit for both. |
| `sort` | query | no | string | Field to sort by: `name`, `status`, `createdAt`, `ordersCount`, `totalSpent`, `lastOrderAt`. Any other value is ignored. |
| `dir` | query | no | "asc" \| "desc" | Sort direction: `asc` (ascending) or `desc` (descending). |
| `page` | query | no | integer | Page number, starting at 1. |
| `limit` | query | no | integer | Results per page, at most 100 (default 50). |

- `200` The store's customers (a page of summaries + total): `CustomerListResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### POST /v3/stores/{storeId}/customers

Create a customer (email unique per store; tags auto-created)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { firstName, lastName, email, phone, taxNumber, status, acceptsMarketing, note, ... }`

- `201` The created customer: `CustomerResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`
- `409` A customer with that email already exists: `Error`
- `422` Invalid input: `Error`

### GET /v3/stores/{storeId}/customers/{id}

Get one customer with addresses, tags, and recent order history

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` The customer: `CustomerResponse`
- `401` Not authenticated: `Error`
- `404` Customer not found: `Error`

### PUT /v3/stores/{storeId}/customers/{id}

Update a customer (tags/addresses fully replace when provided)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { firstName, lastName, email, phone, taxNumber, status, acceptsMarketing, note, ... }`

- `200` The updated customer: `CustomerResponse`
- `401` Not authenticated: `Error`
- `404` Customer not found: `Error`
- `409` A customer with that email already exists: `Error`
- `422` Invalid input: `Error`

### DELETE /v3/stores/{storeId}/customers/{id}

Delete a customer (soft-delete)

A customer who has placed an order cannot be deleted (Shopify's rule, D302): the order keeps pointing at them. Archive them instead.

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `204` Deleted
- `401` Not authenticated: `Error`
- `404` Customer not found: `Error`
- `409` The customer has orders, so they are archived rather than deleted: `Error`

### POST /v3/stores/{storeId}/customers/bulk

Delete many customers at once (customers with orders are kept)

Acts on either `ids` (the rows the seller ticked) or `filter` (the customer list's own filters, for "select all matching"). A filter matching more than 1,000 customers is refused with a 422 rather than cut short. Customers who have placed orders are not deleted; `skippedWithOrders` says how many.

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { action, ids, filter }`

- `200` How many customers were deleted, and how many kept: `object { data }`
- `401` Not authenticated: `Error`
- `403` Not allowed to delete customers: `Error`
- `422` Invalid input, or the filter matches too many customers: `Error`

### GET /v3/stores/{storeId}/customer-tags

List the store's customer tags with how many customers carry each

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

- `200` The store's customer tags: `CustomerTagListResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### POST /v3/stores/{storeId}/customer-tags

Create a customer tag (name unique per store, case-insensitively)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { name }`

- `201` The created tag: `CustomerTagResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`
- `409` A tag with that name already exists: `Error`
- `422` Invalid input: `Error`

### POST /v3/stores/{storeId}/customer-tags/assignments

Put one tag on, or take it off, many customers at once (creates the tag if it is new)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { name, add, remove, matching }`

- `200` The tag and what moved: `CustomerTagAssignResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`
- `422` Invalid input: `Error`

### PUT /v3/stores/{storeId}/customer-tags/{id}

Rename a customer tag; renaming onto an existing name MERGES the two

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { name }`

- `200` The surviving tag, and whether a merge happened: `CustomerTagSaveResponse`
- `401` Not authenticated: `Error`
- `404` Tag not found: `Error`
- `422` Invalid input: `Error`

### DELETE /v3/stores/{storeId}/customer-tags/{id}

Delete a customer tag (removes it from every customer carrying it)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `204` Deleted
- `401` Not authenticated: `Error`
- `404` Tag not found: `Error`

### GET /v3/stores/{storeId}/contact-messages

List the messages shoppers sent through the shop's contact forms, newest first

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `page` | query | no | integer | Page number, starting at 1. |
| `perPage` | query | no | integer | Results per page, at most 100 (default 25). |
| `kind` | query | no | "message" \| "quote_request" | `message` for contact-form messages only, `quote_request` for quote requests only. Omit for both. |

- `200` One page of messages: `ContactMessageListResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### POST /v3/stores/{storeId}/contact-messages/{id}/quote

Make a draft order (the quote) from a quote request, or get the one already made

Creates a draft order with the request's lines at today's prices and the shopper's email, for the seller to price, ship and discount. Idempotent: a request that already has a draft answers with it (`created: false`).

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` The draft this request already had: `MakeQuoteResponse`
- `201` The draft, just made: `MakeQuoteResponse`
- `401` Not authenticated: `Error`
- `404` No such quote request: `Error`

### DELETE /v3/stores/{storeId}/contact-messages/{id}

Delete one contact message (spam, or dealt with)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `204` Deleted
- `401` Not authenticated: `Error`
- `404` Message not found: `Error`

## Subscriptions

Recurring orders: pause, resume, skip, reschedule or cancel.

### GET /v3/stores/{storeId}/subscriptions

List a store's subscriptions (D163)

Recurring purchase CONTRACTS, not orders - each one produces orders. `live` is the default filter because the useful question is what is running, and it deliberately includes `past_due`: a contract whose card just failed is the one the seller can still save.

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `status` | query | no | "live" \| "active" \| "past_due" \| "paused" \| "cancelled" \| "expired" \| "all" | Which subscriptions to list: `live` (default: active, past due and paused), `active`, `past_due`, `paused`, `cancelled`, `expired` or `all`. |
| `page` | query | no | integer | Page number, starting at 1. |
| `limit` | query | no | integer | Results per page, at most 100 (default 25). |

- `200` A page of subscriptions: `object { data, meta }`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### POST /v3/stores/{storeId}/subscriptions/{subscriptionId}

Pause, resume, skip, reschedule or cancel a subscription on the customer's behalf

The same five moves the shopper has (D163), run by staff. The one difference is that a MINIMUM COMMITMENT does not bind the seller: it is a promise the shopper made, and a store must always be able to end a contract it can no longer honour.

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `subscriptionId` | path | yes | string |  |

Request body: `object { action, planId }`

- `200` The updated subscription: `object { data }`
- `401` Not authenticated: `Error`
- `404` Store or subscription not found: `Error`
- `409` The action is not available on this subscription: `Error`

## Licence keys

Find any sold or unsold key in the store by the key itself, its order, its buyer, its subscription or its status. A subscription's keys follow it: suspended while it is past due or paused, expired once it ends.

### GET /v3/stores/{storeId}/licence-keys

Find licence keys across the store by key, order, customer, subscription or status

`key` is an exact match. `customerId` resolves to that customer's email, so it also finds keys bought on guest orders placed with the same address; pass `email` to search an address that has no customer record.

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `key` | query | no | string | Exact licence key to find. |
| `orderId` | query | no | string | Only keys sold on this order (`ord_...`). |
| `customerId` | query | no | string | Only keys sold to this customer (`cust_...`). |
| `email` | query | no | string | Only keys sold to this email address (case-insensitive). Finds guest orders too. |
| `productId` | query | no | string | Only keys of this product (`prod_...`). |
| `subscriptionId` | query | no | string | Only keys sold by this subscription. |
| `status` | query | no | "available" \| "assigned" \| "suspended" \| "expired" \| "disabled" | Only keys with this status: `available`, `assigned`, `suspended`, `expired` or `disabled`. |
| `page` | query | no | integer | Page number, starting at 1. |
| `limit` | query | no | integer | Keys per page (1 to 200, default 50). |

- `200` A page of keys, most recently assigned first: `StoreLicenseKeyList`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

## Discounts

Discount codes and automatic discounts.

### GET /v3/stores/{storeId}/discounts

List a store's discounts

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `status` | query | no | "live" \| "scheduled" \| "expired" \| "spent" \| "inactive" | Only discounts in this state: `live` (on, started, not ended, uses left), `scheduled` (starts later), `expired` (end date passed), `spent` (usage limit reached) or `inactive` (switched off). |
| `q` | query | no | string | Search text, matched against the discount's name and code. |
| `method` | query | no | "code" \| "automatic" | `code` for discounts a shopper enters a code for, `automatic` for ones applied without a code. |
| `page` | query | no | integer | Page number, starting at 1. |
| `limit` | query | no | integer | Results per page, at most 100 (default 50). |
| `sort` | query | no | string | Field to sort by: `name`, `code`, `status`, `value`, `usageCount`, `startsAt`, `endsAt`, `createdAt`. Any other value is ignored. |
| `dir` | query | no | "asc" \| "desc" | Sort direction: `asc` (ascending) or `desc` (descending). |

- `200` The store's discounts: `DiscountListResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### POST /v3/stores/{storeId}/discounts

Create a discount

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { name, code, type, amountType, value, appliesTo, description, conditions, ... }`

- `201` The created discount: `DiscountResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`
- `409` That code is already in use: `Error`
- `422` Invalid input: `Error`

### GET /v3/stores/{storeId}/discounts/{id}

Get one discount

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` The discount: `DiscountResponse`
- `401` Not authenticated: `Error`
- `404` Discount not found: `Error`

### PUT /v3/stores/{storeId}/discounts/{id}

Update a discount

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { name, code, type, amountType, value, appliesTo, description, conditions, ... }`

- `200` The updated discount: `DiscountResponse`
- `401` Not authenticated: `Error`
- `404` Discount not found: `Error`
- `409` That code is already in use, or the discount belongs to an upsell: `Error`
- `422` Invalid input: `Error`

### DELETE /v3/stores/{storeId}/discounts/{id}

Delete a discount (soft; redemption history is preserved)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `204` Deleted
- `401` Not authenticated: `Error`
- `404` Discount not found: `Error`
- `409` The discount belongs to an upsell: `Error`

### POST /v3/stores/{storeId}/discounts/bulk

Enable, disable or delete many discounts at once

Acts on either `ids` (the rows the seller ticked) or `filter` (the discount list's own filters, for "select all matching"). A filter matching more than 1,000 discounts is refused with a 422 rather than cut short. Delete is soft, as the single DELETE is: orders that redeemed a code keep pointing at it.

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { action, ids, filter }`

- `200` How many discounts the action changed: `object { data }`
- `401` Not authenticated: `Error`
- `403` Not allowed to manage discounts: `Error`
- `422` Invalid input, or the filter matches too many discounts: `Error`

## Upsells

One-sentence rules for what the cart and product page suggest, with an optional price cut.

### GET /v3/stores/{storeId}/upsells

The Upsells page: the cart switch, every rule, and their counts

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

- `200` The page: `object { data }`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### POST /v3/stores/{storeId}/upsells

Add an upsell

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { triggerType, triggerProductId, triggerCategoryId, productIds, percentOff, placement }`

- `201` The created upsell: `object { data }`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`
- `409` That trigger already has an upsell: `Error`
- `422` Invalid input: `Error`

### PUT /v3/stores/{storeId}/upsells/settings

Turn cart suggestions on or off

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { cartSuggestions }`

- `200` The saved setting: `object { data }`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### PUT /v3/stores/{storeId}/upsells/{id}

Replace an upsell's rule (its discount follows)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { triggerType, triggerProductId, triggerCategoryId, productIds, percentOff, placement }`

- `200` The updated upsell: `object { data }`
- `401` Not authenticated: `Error`
- `404` Upsell not found: `Error`
- `409` That trigger already has an upsell: `Error`
- `422` Invalid input: `Error`

### DELETE /v3/stores/{storeId}/upsells/{id}

Delete an upsell (its discount is retired; orders keep theirs)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `204` Deleted
- `401` Not authenticated: `Error`
- `404` Upsell not found: `Error`

## Gift cards

Gift cards and their balance ledger.

### GET /v3/stores/{storeId}/gift-cards

List a store's gift cards

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `status` | query | no | "active" \| "spent" \| "expired" \| "inactive" | Only cards in this state: `active` (usable, with a balance), `spent` (balance is zero), `expired` (past its expiry date) or `inactive` (disabled). |
| `q` | query | no | string | Search text, matched against the code's last four characters and the recipient's email. |
| `page` | query | no | integer | Page number, starting at 1. |
| `limit` | query | no | integer | Results per page, at most 100 (default 50). |
| `sort` | query | no | string | Field to sort by: `last4`, `balance`, `initialBalance`, `recipientEmail`, `expiresAt`, `createdAt`, `status`. Any other value is ignored. |
| `dir` | query | no | "asc" \| "desc" | Sort direction: `asc` (ascending) or `desc` (descending). |

- `200` The store's gift cards: `GiftCardListResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### POST /v3/stores/{storeId}/gift-cards

Issue a gift card (the code is returned ONCE and never again)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { initialBalance, currencyCode, customerId, recipientEmail, message, expiresAt, note, status, ... }`

- `201` The issued card, with its one-time code: `GiftCardIssuedResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`
- `422` Invalid input: `Error`

### GET /v3/stores/{storeId}/gift-cards/{id}

Get a gift card and its ledger

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` The card and its transactions: `GiftCardDetailResponse`
- `401` Not authenticated: `Error`
- `404` Gift card not found: `Error`

### PUT /v3/stores/{storeId}/gift-cards/{id}

Update a gift card's details (never its balance)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { customerId, recipientEmail, message, expiresAt, note, status }`

- `200` The updated card: `GiftCardResponse`
- `401` Not authenticated: `Error`
- `404` Gift card not found: `Error`
- `422` Invalid input: `Error`

### DELETE /v3/stores/{storeId}/gift-cards/{id}

Delete a gift card (soft; its ledger is preserved)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `204` Deleted
- `401` Not authenticated: `Error`
- `404` Gift card not found: `Error`

### POST /v3/stores/{storeId}/gift-cards/{id}/adjust

Adjust a gift card's balance by hand, with a reason

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { amount, note }`

- `200` The adjusted card and its ledger: `GiftCardDetailResponse`
- `401` Not authenticated: `Error`
- `404` Gift card not found: `Error`
- `422` The adjustment would take the balance negative: `Error`

### POST /v3/stores/{storeId}/gift-cards/bulk

Disable many gift cards at once

Acts on either `ids` (the rows the seller ticked) or `filter` (the gift card list's own filters, for "select all matching"). A filter matching more than 1,000 cards is refused with a 422 rather than cut short. A disabled card keeps its balance and is refused at checkout until it is enabled again.

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { action, ids, filter }`

- `200` How many cards were disabled: `object { data }`
- `401` Not authenticated: `Error`
- `403` Not allowed to manage gift cards: `Error`
- `422` Invalid input, or the filter matches too many gift cards: `Error`

## Reviews

Product reviews and moderation.

### GET /v3/stores/{storeId}/reviews

List a store's reviews (filterable, paginated)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `page` | query | no | integer | Page number, starting at 1. |
| `perPage` | query | no | integer | Reviews per page. |
| `status` | query | no | "pending" \| "approved" \| "rejected" | Only reviews with this status: `pending`, `approved` or `rejected`. |
| `rating` | query | no | integer | Only reviews with this star rating (1 to 5). |
| `source` | query | no | "organic" \| "requested" \| "imported" \| "manual" | Only reviews from this source: `organic`, `requested`, `imported` or `manual`. |
| `productId` | query | no | string | Only reviews of this product (`prod_...`). |
| `search` | query | no | string | Text to match against the author's name and email, the title and the content. |
| `sort` | query | no | string | Field to sort by. |
| `dir` | query | no | "asc" \| "desc" | Sort direction: `asc` or `desc`. |

- `200` A page of reviews, newest first: `ReviewListResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### POST /v3/stores/{storeId}/reviews/requests

Ask a customer to review a product (emails them a link to write it)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { productId, name, email }`

- `201` The request email was sent: `ReviewRequestSentResponse`
- `401` Not authenticated: `Error`
- `404` Store or product not found: `Error`
- `409` This email has already reviewed this product: `Error`
- `422` Invalid input, or the email could not be sent: `Error`
- `429` The store's daily allowance of review requests is used up: `Error`

### POST /v3/stores/{storeId}/reviews/{id}/edit-requests

Ask the reviewer to edit their own review (emails them a link)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `201` The email was sent: `ReviewEditRequestSentResponse`
- `401` Not authenticated: `Error`
- `404` Review not found: `Error`
- `422` Not a customer's review, no email address, or the email could not be sent: `Error`
- `429` Already asked today, or the store's daily allowance of review requests is used up: `Error`

### GET /v3/stores/{storeId}/reviews/{id}

Get one review

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` The review: `ReviewResponse`
- `401` Not authenticated: `Error`
- `404` Review not found: `Error`

### PUT /v3/stores/{storeId}/reviews/{id}

Update a review (partial; a status change re-publishes or unpublishes it)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { rating, title, content, authorName, authorEmail, status, moderationNotes, reply, ... }`

- `200` The updated review: `ReviewResponse`
- `401` Not authenticated: `Error`
- `404` Review not found: `Error`
- `409` This email has already reviewed this product: `Error`
- `422` Invalid input: `Error`

### DELETE /v3/stores/{storeId}/reviews/{id}

Delete a review (soft)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `204` Deleted
- `401` Not authenticated: `Error`
- `404` Review not found: `Error`

### POST /v3/stores/{storeId}/reviews/bulk

Bulk approve, reject or delete reviews

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { action, ids }`

- `200` How many reviews the action touched: `BulkReviewResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`
- `422` Invalid input: `Error`

## Shipping

Shipping methods: your own zones and rates, or live carrier rates quoted at checkout for the carrier services you pick.

### GET /v3/stores/{storeId}/shipping-methods

List a store's shipping methods with their full zone graphs

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

- `200` The store's shipping methods: `ShippingMethodListResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### POST /v3/stores/{storeId}/shipping-methods

Create a shipping method (with its zones, destinations, and rates)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { name, description, taxExempt, status, type, zones, carrierServices, markupPercent, ... }`

- `201` The created method: `ShippingMethodResponse`
- `401` Not authenticated: `Error`
- `403` A carrier method on an unclaimed test store: `Error`
- `404` Store not found: `Error`
- `422` Invalid input: `Error`

### GET /v3/stores/{storeId}/shipping-methods/{id}

Get one shipping method with its zone graph

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` The method: `ShippingMethodResponse`
- `401` Not authenticated: `Error`
- `404` Method not found: `Error`

### PUT /v3/stores/{storeId}/shipping-methods/{id}

Update a shipping method (the zone graph fully replaces when provided)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { name, description, taxExempt, status, zones, carrierServices, markupPercent, markupFixed, ... }`

- `200` The updated method: `ShippingMethodResponse`
- `401` Not authenticated: `Error`
- `404` Method not found: `Error`
- `422` Invalid input: `Error`

### DELETE /v3/stores/{storeId}/shipping-methods/{id}

Delete a shipping method (soft-delete; its zone graph stays with it)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `204` Deleted
- `401` Not authenticated: `Error`
- `404` Method not found: `Error`

### GET /v3/stores/{storeId}/pickup-locations

List a store's pickup locations

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

- `200` The store's pickup locations: `object { data }`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### POST /v3/stores/{storeId}/pickup-locations

Add a pickup location

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { name, address, readyTime, instructions, cost, status, offersPickup, pickupDates, ... }`

- `201` The created location: `object { data }`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`
- `422` Invalid input: `Error`

### GET /v3/stores/{storeId}/pickup-locations/{id}

Get one pickup location

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` The location: `object { data }`
- `401` Not authenticated: `Error`
- `404` Location not found: `Error`

### PUT /v3/stores/{storeId}/pickup-locations/{id}

Update a pickup location

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { name, address, readyTime, instructions, cost, status, offersPickup, pickupDates, ... }`

- `200` The updated location: `object { data }`
- `401` Not authenticated: `Error`
- `404` Location not found: `Error`
- `422` Invalid input: `Error`

### DELETE /v3/stores/{storeId}/pickup-locations/{id}

Delete a pickup location (soft-delete; orders keep their own copy)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `204` Deleted
- `401` Not authenticated: `Error`
- `404` Location not found: `Error`

### GET /v3/stores/{storeId}/shipping-carriers

The carriers and services a live carrier rates method can offer

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

- `200` Carriers and services: `object { data }`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### POST /v3/stores/{storeId}/shipping/preview

Test what checkout would offer for a cart and an address (optionally with an unsaved method)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { lines, manual, destination, draft }`

- `200` The options, and why each other method offered nothing: `object { data }`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`
- `422` Invalid input: `Error`

## Embeds

Saved widget embeds and their paste-ready code.

### GET /v3/stores/{storeId}/embeds

List a store's embeds

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `status` | query | no | "active" \| "inactive" | Only embeds in this status: `active` or `inactive`. |
| `widgetType` | query | no | "catalog" \| "product" \| "product-view" \| "buy-button" \| "product-card" \| "cart" \| "categories" \| "account" \| "search" | Only embeds of this widget type. |
| `q` | query | no | string | Search text, matched against the embed's name. |
| `page` | query | no | integer | Page number, starting at 1. |
| `limit` | query | no | integer | Results per page, at most 100 (default 50). |
| `sort` | query | no | string | Field to sort by: `name`, `widgetType`, `status`, `createdAt`, `updatedAt`. Any other value is ignored. |
| `dir` | query | no | "asc" \| "desc" | Sort direction: `asc` (ascending) or `desc` (descending). |

- `200` The store's embeds: `EmbedListResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`

### POST /v3/stores/{storeId}/embeds

Create an embed

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { name, widgetType, theme, configuration }`

- `201` The created embed (embedCode is paste-ready): `EmbedResponse`
- `401` Not authenticated: `Error`
- `404` Store not found: `Error`
- `422` Invalid input: `Error`

### GET /v3/stores/{storeId}/embeds/{id}

Get one embed

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `200` The embed: `EmbedResponse`
- `401` Not authenticated: `Error`
- `404` Embed not found: `Error`

### PUT /v3/stores/{storeId}/embeds/{id}

Update an embed (partial; live placements pick the change up on next mount)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

Request body: `object { name, widgetType, theme, configuration, status }`

- `200` The updated embed: `EmbedResponse`
- `401` Not authenticated: `Error`
- `404` Embed not found: `Error`
- `422` Invalid input: `Error`

### DELETE /v3/stores/{storeId}/embeds/{id}

Delete an embed (soft; its live placements show the widget's error state)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `id` | path | yes | string |  |

- `204` Deleted
- `401` Not authenticated: `Error`
- `404` Embed not found: `Error`

## Webhooks

Endpoints we POST events to, and the delivery ledger. See the webhooks guide for signatures and retries.

### GET /v3/stores/{storeId}/webhooks

List the store's outbound webhook endpoints

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

- `200` The store's endpoints: `WebhookEndpointListResponse`
- `401` Not authenticated: `Error`
- `403` Not allowed to manage integrations: `Error`
- `404` Store not found: `Error`

### POST /v3/stores/{storeId}/webhooks

Add an outbound webhook endpoint

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { name, url, events, isTest }`

- `201` The new endpoint, including its signing secret: `WebhookEndpointResponse`
- `401` Not authenticated: `Error`
- `403` Not allowed to manage integrations, or `plan_limit`: the plan does not include webhooks: `Error`
- `404` Store not found: `Error`

### PATCH /v3/stores/{storeId}/webhooks/{webhookId}

Edit an endpoint, or re-enable one that auto-disabled

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `webhookId` | path | yes | string |  |

Request body: `object { name, url, events, status }`

- `200` The updated endpoint: `WebhookEndpointResponse`
- `401` Not authenticated: `Error`
- `403` Not allowed to manage integrations: `Error`
- `404` Endpoint not found: `Error`

### DELETE /v3/stores/{storeId}/webhooks/{webhookId}

Delete an endpoint (its delivery history is kept)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `webhookId` | path | yes | string |  |

- `204` Endpoint deleted
- `401` Not authenticated: `Error`
- `403` Not allowed to manage integrations: `Error`
- `404` Endpoint not found: `Error`

### POST /v3/stores/{storeId}/webhooks/{webhookId}/test

Send a test event now and report what the endpoint said

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `webhookId` | path | yes | string |  |

- `200` The receiver's response: `WebhookTestResponse`
- `401` Not authenticated: `Error`
- `403` Not allowed to manage integrations: `Error`
- `404` Endpoint not found: `Error`

### GET /v3/stores/{storeId}/webhooks-deliveries

The delivery ledger - what we sent, and what the endpoint said

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `webhookId` | query | no | string | Only deliveries to this webhook endpoint (`whk_...`). |
| `event` | query | no | "order.created" \| "order.updated" \| "order.paid" \| "order.refunded" \| "order.fulfilled" \| "order.partially_fulfilled" \| "order.deleted" \| "customer.created" \| "customer.updated" \| "customer.deleted" \| "product.created" \| "product.updated" \| "product.deleted" \| "inventory.updated" \| "discount.created" \| "discount.updated" \| "discount.deleted" \| "category.created" \| "category.updated" \| "category.deleted" \| "subscription.created" \| "subscription.renewed" \| "subscription.renewal_failed" \| "subscription.paused" \| "subscription.resumed" \| "subscription.cancelled" \| "subscription.expired" \| "license_key.assigned" \| "license_key.suspended" \| "license_key.reinstated" \| "license_key.expired" \| "license_key.revoked" | Only deliveries of this event type, e.g. `order.created`. |
| `status` | query | no | "pending" \| "succeeded" \| "abandoned" | Only deliveries in this state: `pending` (not yet delivered, may be retrying), `succeeded` (the endpoint answered 2xx) or `abandoned` (retries ran out). |
| `subjectId` | query | no | string | Only deliveries about this record: an order, customer or product id (e.g. `ord_...`). |
| `limit` | query | no | integer | How many deliveries to return, newest first: at most 100 (default 50). |

- `200` Deliveries, newest first: `WebhookDeliveryListResponse`
- `401` Not authenticated: `Error`
- `403` Not allowed to manage integrations: `Error`
- `404` Store not found: `Error`

### GET /v3/stores/{storeId}/webhooks-deliveries/{deliveryId}

One delivery, including the exact body we sent

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `deliveryId` | path | yes | string |  |

- `200` The delivery and its payload: `WebhookDeliveryResponse`
- `401` Not authenticated: `Error`
- `403` Not allowed to manage integrations: `Error`
- `404` Delivery not found: `Error`

### POST /v3/stores/{storeId}/webhooks-deliveries/{deliveryId}/retry

Re-queue an abandoned delivery after fixing the endpoint

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `deliveryId` | path | yes | string |  |

- `200` The re-queued delivery: `WebhookDeliveryResponse`
- `401` Not authenticated: `Error`
- `403` Not allowed to manage integrations: `Error`
- `404` Delivery not found: `Error`

## API keys

Secret keys for your own server. A key's scopes are the same permissions a team member holds.

### GET /v3/stores/{storeId}/api-keys

List the store's API keys (never their secrets)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

- `200` The store's API keys: `ApiKeyListResponse`
- `401` Not authenticated: `Error`
- `403` Not allowed to manage API keys: `Error`
- `404` Store not found: `Error`

### POST /v3/stores/{storeId}/api-keys

Issue a new API key — the secret is returned ONCE and never again

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |

Request body: `object { name, scopes, expiresInDays }`

- `201` The new key, including its one-time secret: `CreatedApiKeyResponse`
- `401` Not authenticated: `Error`
- `403` Not allowed to manage API keys: `Error`
- `404` Store not found: `Error`
- `422` Scopes exceed what the caller may delegate: `Error`

### PATCH /v3/stores/{storeId}/api-keys/{keyId}

Rename or re-scope a key (its secret cannot be changed — issue a new key instead)

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `keyId` | path | yes | string |  |

Request body: `object { name, scopes }`

- `200` The updated key: `ApiKeyResponse`
- `401` Not authenticated: `Error`
- `403` Not allowed to manage API keys: `Error`
- `404` Key not found: `Error`

### DELETE /v3/stores/{storeId}/api-keys/{keyId}

Revoke a key — immediate, permanent, and kept for audit

Auth: `secretKey` (bearer) or `dashboardSession` (cookie `better-auth.session_token`)

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `storeId` | path | yes | string |  |
| `keyId` | path | yes | string |  |

- `204` Key revoked
- `401` Not authenticated: `Error`
- `403` Not allowed to manage API keys: `Error`
- `404` Key not found: `Error`

Schemas (73) are defined in [https://api-v3.shoprocket.io/openapi.json](https://api-v3.shoprocket.io/openapi.json) under `components.schemas`.

---

- HTML version: https://v3.shoprocket.io/docs/api
- Language: English (en)
