# API basics

> Base URLs, money, ids, errors, pagination and a headless checkout, for the Shoprocket API.

What every endpoint has in common, then a shopper's whole journey from product to payment in six steps.

## Base URLs

Storefront endpoints hang off the store's publishable key. Seller endpoints hang off the store id and take a secret key (see [Authentication](https://v3.shoprocket.io/docs/authentication.md)). The [reference](https://v3.shoprocket.io/docs/api.md) lists every path in full.

The examples on these pages call a real test-mode demo store, so you can paste them into a terminal as they are. Swap in your own publishable key when you are ready.

Base URLs

```
https://api-v3.shoprocket.io/v3/public/{publishableKey}
https://api-v3.shoprocket.io/v3/stores/{storeId}
```

## Conventions

- JSON in and out; keys are camelCase.
- A successful response wraps its payload in `data`. Lists add either `meta` or `total` alongside it; [Pagination](#pagination) says which.
- Ids carry a type prefix: `str_` store, `prod_` product, `var_` variant, `ord_` order, `cust_` customer, `cart_` cart. Treat them as opaque strings.
- Timestamps are ISO 8601 in UTC.
- Storefront reads take an optional `locale` (for example `?locale=fr`) and answer in that language when the store has it enabled.

## Money

Every amount is an integer in the currency's smallest unit, next to the currency code it is in: `1200` USD is $12.00, `1200` JPY is ¥1,200. There are no floats and no formatted strings, so format with the currency's own number of decimal places (`Intl.NumberFormat` does this for you).

Cart totals

```
"currencyCode": "USD",
"totals": {
  "subtotal": 1200,
  "discountTotal": 0,
  "shippingTotal": 450,
  "taxTotal": 0,
  "total": 1650,
  "amountDue": 1650
}
```

## Errors

Errors use the HTTP status and one body shape: a machine-readable `code`, a human `message`, and for validation failures the `issues` that caused it.

| Status | code | Means |
| --- | --- | --- |
| 401 | unauthorized | No key, or an unknown, revoked or expired one |
| 403 | forbidden | The key lacks the scope this call needs |
| 403 | plan_limit | The store's plan does not allow another one (products, for example); see below |
| 403 | two_factor_required | The signed-in person must turn on two-factor sign-in on their account first |
| 403 | claim_required | A test store an AI builder created has not been claimed yet: only its catalogue and shipping can change |
| 402 | plan_required | The store's free trial ended without a plan: reads still work, writes wait for a plan |
| 404 | not_found | No such thing, or not in this store |
| 409 | conflict | The state changed under you (sold out, already refunded) |
| 410 | gone | It existed and has expired (a download link) |
| 422 | invalid | The request is malformed; read issues |
| 429 | rate_limited | Slow down; honour Retry-After |
| 500 | internal | Something failed on our side. Safe to retry a read; check before retrying a write |
| 503 | unavailable | A feature this call needs is not available right now (address autocomplete, say) |

A `plan_limit` error carries three more fields next to `code` and `message`: `metric` (what ran out, for example `products`), `limit` (the plan's ceiling) and `current` (how many the store has), so you can tell the seller exactly what to upgrade.

Two exceptions to the body shape, both on checkout. When `POST /cart/checkout` refuses a cart it cannot sell (out of stock, a price changed, an item no longer sold), and when `POST /orders/{orderId}/pay` cannot start a payment (the order is already paid, the gateway is not connected, the card was declined), each answers `409` with `rejection` and `message` at the top level instead, so a checkout UI can branch on the reason.

422

```
{
  "error": {
    "code": "invalid",
    "message": "validation failed",
    "issues": [
      { "path": [], "message": "productId or variantId is required" }
    ]
  }
}
```

## Pagination

Lists are page-numbered and start at `page=1`, but the size parameter and the answer are not the same everywhere yet. Two shapes are in use:

- **`perPage` with `meta`**: the answer carries `meta.page`, `meta.perPage`, `meta.total` and `meta.totalPages`.
- **`limit` with `total`**: the answer carries `total`, the number of matching rows, next to `data`. Work out the page count yourself.

| List | Page size (default, max) | Answer |
| --- | --- | --- |
| Storefront products, categories | perPage (24, 100\*) | meta |
| Storefront product reviews | fixed at 10 | meta, plus rating stats |
| Seller orders, customers, carts, discounts, gift cards, embeds | limit (50, 100) | total |
| Seller products | limit (everything, 100) | total |
| Seller subscriptions | limit (25, 100) | meta |
| Seller licence keys | limit (50, 200) | meta |
| Seller reviews | perPage (25, 100\*) | meta |
| Seller contact messages | perPage (25, 100) | meta |
| Webhook deliveries | limit (50, 100), newest first, no page | data only |

Asking for more than the maximum is refused with `422`, except where it is starred: there a bigger size is cut to the maximum, and `meta.perPage` tells you what you got. Seller products is the odd one out: with neither `page` nor `limit` it answers every product in the store at once, and with either it pages at 25 by default. Send `limit` on a large catalogue.

Page 2 of products

```
curl "https://api-v3.shoprocket.io/v3/public/pk_bCz5dnUr-Mdw6bj0BcoE3CALrGA1vhYq/products?page=2&perPage=4"
```

## Rate limits

The calls a bot would hammer are rate limited per IP address: checkout, `pay` and saving a card, sign-in codes, review, newsletter, contact form and back-in-stock submissions, cart file uploads for personalised products, address autocomplete, and the Licence API. A refused call gets `429` with a `Retry-After` header. Reading the catalogue and every other cart call are not rate limited.

## A headless checkout

This is what the widget does for you. Build it yourself when you want your own checkout UI.

1. Read the product. Each variant has an `id`, a `price` and `sellableQuantity`.
2. Mint a cart token once for this shopper (a fresh UUID, never one copied from an example) and add the variant. Every cart call answers with the whole cart, totals included, so you never need a second read.
3. `PATCH /cart` with the shopper's email and shipping address. Tax and shipping are worked out from the address.
4. `GET /cart/shipping-options`, then `PATCH /cart` with the chosen `shippingRateId`, or with a `pickupLocationId` from its `pickup` list when the shopper collects in person. The list only holds what every product in the cart allows: the cart's `deliveryMethods` says which of shipping, pickup and local delivery that is, so a cart holding a pickup-only product gets no shipping rates. A location whose `availableDates` is a list asks for a day: send one of them as `pickupDate`, or checkout answers `409` with `pickup_date_required`.
5. `POST /cart/checkout` turns the cart into a pending order and reserves the stock. Send the `expectedTotal` you showed the shopper: if a price changed in the meantime the call answers `409` with the changes rather than charging a figure the shopper never saw.
6. `POST /orders/{orderId}/pay` with a `returnUrl` opens a payment with the store's gateway and answers with a `handoff`: how this shopper completes it. When they come back, poll `GET /orders/{orderId}/status` until the payment settles.

Checkout, `pay` and `status` are cart calls too: send the same `X-Cart-Token` on each of them. Without it they answer `422`, and with another shopper's token `404`, so an order can only be paid or read by the cart that placed it.

Branch on `handoff.kind`. Only some kinds send the shopper to a URL; the rest render the gateway's own form on your page:

| kind | What to do |
| --- | --- |
| `redirect` | Send the shopper to redirectUrl. What every hosted gateway answers by default. |
| `paypal_order` | Resolve the PayPal JS SDK's createOrder with paypalOrderId, then call POST /orders/{orderId}/capture once the shopper approves. |
| `stripe_elements` | Mount Stripe's payment form with clientSecret, publishableKey and stripeAccount. Only when you send embedded: true. |
| `stripe_intent` | Confirm the details already typed into a Stripe Payment Element against clientSecret. Only when you send intent: true. |
| `square_card` | Tokenise the card with Square's Web Payments SDK and post the token to /orders/{orderId}/square-pay. Only when you send embedded: true. |
| `mollie_components` | Turn Mollie Components' card fields into a token and post it to /orders/{orderId}/mollie-pay. Only when you send embedded: true. |
| `authorize_net_frame` | Show frameUrl in an iframe; redirectUrl is the full-page fallback. Only when you send embedded: true. |
| `test_gateway` | A test-mode store's test gateway: approve or decline it yourself (see Test mode). |

A client that cannot render a kind should not send the flag that asks for it: without `embedded` or `intent`, every gateway answers `redirect`, except PayPal (always `paypal_order`) and the test gateway. Even with a flag, be ready for `redirect`: an attempt already open for the order is handed back as whatever it was.

`returnUrl` (and `cancelUrl`) must be on an address the store owns: its hosted shop, its connected custom domain, the page its full-store embed lives on, or the website in its settings (with or without `www.`). Anything else is quietly replaced with the store's own page rather than refused, so a shopper sent back somewhere unexpected usually means the return address was not one of these.

Payment is always the gateway's page or form

Card details never pass through your code or ours: `pay` hands off to Stripe, PayPal, Mollie or the store's other gateway, which is also what keeps a headless checkout out of PCI scope.

1. Find a product

```
curl https://api-v3.shoprocket.io/v3/public/pk_bCz5dnUr-Mdw6bj0BcoE3CALrGA1vhYq/products/everyday-tote-bag
```

2. Add it to a cart

```
TOKEN=$(uuidgen)   # once per shopper, then keep it
curl -X POST https://api-v3.shoprocket.io/v3/public/pk_bCz5dnUr-Mdw6bj0BcoE3CALrGA1vhYq/cart/items \
  -H "X-Cart-Token: $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "variantId": "var_034oxcMjuCaVaXzlIaEcfs", "quantity": 1 }'
```

3. Say who and where

```
curl -X PATCH https://api-v3.shoprocket.io/v3/public/pk_bCz5dnUr-Mdw6bj0BcoE3CALrGA1vhYq/cart \
  -H "X-Cart-Token: $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "email": "shopper@example.com",
        "shippingAddress": { "name": "Ada Lovelace", "line1": "1 Main St",
          "city": "Portland", "region": "OR", "postalCode": "97201", "countryCode": "US" } }'
```

4. Choose shipping

```
curl https://api-v3.shoprocket.io/v3/public/pk_bCz5dnUr-Mdw6bj0BcoE3CALrGA1vhYq/cart/shipping-options \
  -H "X-Cart-Token: $TOKEN"
```

6. What pay answers

```
{
  "data": {
    "paymentId": "pay_...",
    "status": "processing",
    "gateway": "stripe",
    "handoff": {
      "kind": "redirect",
      "redirectUrl": "https://checkout.stripe.com/..."
    },
    "amount": 1650,
    "currencyCode": "USD"
  }
}
```

---

- HTML version: https://v3.shoprocket.io/docs/basics
- Language: English (en)
