Spree API v3
Spree 6 has three REST surfaces under
, and they share one set of conventions:
| Surface | Path | Caller | Auth | Authorization |
|---|
| Store API | | Storefronts, mobile apps, shoppers | + optional Authorization: Bearer <jwt>
() + guest | Ownership (see ) |
| Admin API | | Integrations, back-office apps, the dashboard | or Authorization: Bearer <jwt>
() | Key scopes / role permission keys |
| Seller API | | Marketplace seller panel | Seller JWT () + | Seller role keys + scoping (see ) |
A token issued for one audience is rejected on the other surfaces. The v2 Storefront/Platform APIs don't exist in Spree 6. If you're migrating an old integration, see
node_modules/@spree/docs/dist/api-reference/store-api/migrating-from-storefront-api-v2.md
.
Store API
- Anonymous browsing needs only . Publishable keys are safe in client code.
- Guest cart/checkout/order: also send
X-Spree-Token: <cart.token>
.
- Signed-in customer: also send
Authorization: Bearer <jwt>
from POST /api/v3/store/auth/login
. The response body contains , and . Rotate with and .
- Context headers:
- resolves the market, which sets the default locale and currency.
- and override those defaults.
- selects the channel; the store default is used if it's absent. A publishable key bound to a channel decides the channel itself, and a conflicting header returns .
- Responses are customer-safe: no cost prices, no internal notes. On channels that hide prices from guests, money fields come back , so type them .
- The Store API is read-only by default. Writes are limited to carts, checkout, the customer's own account (), wishlists, returns/claims on the customer's own orders, and newsletter/company self-service.
bash
curl -H "X-Spree-Api-Key: pk_xxx" -H "X-Spree-Country: DE" \
"https://shop.example.com/api/v3/store/products?q[name_cont]=shirt&expand=default_variant"
Carts and orders are different resources
A
cart (
,
) is mutable and has no status. Completing it creates an immutable
order (
).
POST /carts → cart (token in response)
POST /carts/{id}/items { variant_id, quantity }
PATCH /carts/{id} { email, shipping_address, billing_address, use_shipping, … }
PATCH /carts/{id}/fulfillments/{id} { selected_delivery_rate_id }
POST /carts/{id}/payment_sessions … gateway flow …
POST /carts/{id}/complete → the Order (or an OrderGroup for split marketplace checkouts)
GET /orders/{id} → order by or_ id, OR by the original cart_ id
- After completion, returns 404 (), because completed carts drop out of cart lookups. Read the result with . The order endpoint accepts the cart's prefixed ID and resolves it to the order created from it (). Guests keep using the same .
POST /carts/{id}/complete
is safe to retry: if a payment webhook already completed the cart, the retry returns the existing order.
- on the cart lists what still blocks completion, as
{ step, field, code, message }
(e.g. { "step": "delivery", "field": "delivery_method", "code": "delivery_method_required" }
). Steps are only advisory; completion is the one hard gate. Drive the checkout UI from , not from a state name. See .
- Coupons:
POST /carts/{id}/discount_codes { code }
. Both carts and orders expose .
- A signed-in customer's history is at .
Order fields you'll use
- (Admin API): | | . Filter with .
- : , , , , , , , .
- : , , , , , (legacy / / can appear on migrated orders).
- Totals: , , , (/), , , , , , each paired with .
withdrawal_period_ends_at
/ (EU right of withdrawal).
There is no
,
or
, and no
/
/
rows. Money is itemized as tax lines, discounts and fees (see
).
Admin API
Secret key (integrations):
. The key selects its store.
JWT (people):
POST /api/v3/admin/auth/login { email, password }
(or
for SSO). The response body is
. The refresh token is set as an
HttpOnly cookie scoped to
, and
takes no body. Admin JWTs expire after 5 minutes by default. For multi-store hosts, send
.
If a request carries both credentials, the JWT wins.
Permissions
Every Admin controller maps its actions to one key:
/
→
, everything else (including member actions like
) →
.
implies
.
- A secret key is checked against its scopes. Failure is 403 with .
- JWT staff are checked against their roles' permission keys on this store. Failure is 403 with
details.required_permission
. A 403 without that detail means a record-level rule refused — those rules come from (swappable via Spree::Dependencies.ability_class
; secret keys use instead). See .
- Keys never decide which rows you see: every endpoint scopes through the current store.
- Secret-key scopes are fixed at creation. To change them, mint a new key and revoke the old one.
The full scope list with groups and covered resources is in
references/scopes.md. At runtime,
GET /api/v3/admin/permissions
returns the same list, and
Spree::ApiKey.known_scopes
gives it in Ruby. Common picks:
Order sync out ............ read_orders (+ read_payments, read_fulfillments)
Warehouse / 3PL ........... read_orders, write_fulfillments, write_stock
ERP product feed .......... write_products (+ write_categories / write_collections)
Refund automation ......... read_orders, write_refunds
Staff provisioning ........ write_staff
Everything (avoid) ........ write_all
Staff roles are data (
). See
.
Admin-only response traits
- Timestamps, internal notes, cost prices, , and custom fields that aren't storefront-visible.
- Actor pairs. Records that remember who acted (, , , , …) expose plus . The ID is for staff or for a secret key, and is or . Treat as an open list. returns .
Shared conventions
Prefixed IDs
IDs are always Stripe-style prefixed strings, on both reads and writes (
"variant_id": "variant_k5nR8xLq"
). Never send or expect integer IDs. Common prefixes:
| Prefix | Resource | Prefix | Resource |
|---|
| Product | | Variant |
| Cart | | Order |
| OrderGroup (split checkout) | | LineItem |
| Payment | | Refund |
| Fulfillment | | Return |
| TaxLine | | Discount |
| Fee | | Customer |
| AdminUser | | Address |
| Category | | Collection |
| StockLevel | | StockLocation |
| Market | | Channel |
| Seller | | Media |
| CustomField | | Promotion |
| GiftCard | | Role |
| ApiKey record (not the / token) | | Store |
The prefix identifies the resource type, so there is no
field on the envelope. Verify any others with
grep -rn has_prefix_id spree/core/app/models
. There is no
, because Adjustment doesn't exist in Spree 6.
Envelope, pagination, sorting
Lists return
{ data: [...], meta: { page, limit, count, pages, from, to, in, previous, next } }
. A single record is returned as a bare object. Pagination:
(default 25, max 100). Sort:
?sort=-completed_at,number
.
Expand and fields
?expand=default_variant,media,variants.prices
sideloads associations; dot notation reaches nested ones. Allowed keys vary per resource (see the OpenAPI spec).
- Admin API expansions are permission-checked per segment: needs , / , , , (so needs both). A path the caller can't cover is silently dropped, not a 403. Gift card is masked (all but the last four characters) unless the caller holds .
- returns a sparse response ( is always included).
Filtering (Ransack)
q[<attr>_<predicate>]=value
, e.g.
,
,
q[completed_at_gteq]=2026-09-01
. Predicates include
,
,
,
,
,
,
,
,
,
,
. Attributes outside the model's ransack allowlist are
silently ignored: you get 200 with that filter dropped. Allowlist them with
Spree.ransack.add_attribute(Spree::Product, :brand_id)
.
The allowlists are narrowed per audience (Ransack
:
for Admin,
,
):
- Store API follows only a model's
storefront_ransackable_associations
(products: , , ; empty for every other model) and drops the model's private_ransackable_attributes[:store]
(e.g. order , , and the / scopes; variant ; store credit ).
- Seller API follows no associations and drops
private_ransackable_attributes[:seller]
(order , , ; plus the / scopes).
- Admin API gets the full allowlist. So
Spree.ransack.add_association(Spree::Product, :brand)
makes work for staff only — to let shoppers filter through it too, add Spree::Product.storefront_ransackable_associations |= %w[brand]
(only for associations whose data the storefront may see). Attributes added with reach every audience unless listed as private.
Addresses
Use
,
,
,
,
,
(ISO2),
. On carts,
copies shipping to billing.
/
still appear in responses but are deprecated.
Rich text
Rich-text attributes come back twice:
as plain text and
as sanitized HTML. Render
and write to the plain name (the value you send is HTML). Spree sanitizes it on save.
Money
Amounts are decimal
strings (
), each paired with a
formatted string. When prices are hidden they're
. On writes, prices are arrays:
"prices": [{ "currency": "USD", "amount": "29.99" }]
.
Idempotency
Send
(max 255 chars) on POST/PUT/PATCH/DELETE. For 24 hours a retry with the same key and body replays the stored response (header
Idempotent-Replayed: true
). Reusing a key with a different body returns 422
. 5xx responses aren't cached. The SDKs and the CLI add the key automatically.
Errors
json
{ "error": { "code": "validation_error", "message": "Email can't be blank",
"details": { "email": ["can't be blank"] } } }
| Status | Typical |
|---|
| 401 | , , , |
| 403 | ( / ), |
| 404 | , , , , |
| 422 | (per-attribute , with for form-level errors), , , , |
| 413 | (body > , 100 KB) |
| 429 | |
Rate limits
Built on Rails
, with counters in
. That cache must be shared (Solid Cache or Redis) across processes. Configure through
or the env vars:
| Preference (env) | Default | Bucket |
|---|
| () | 300 / window | Publishable key + client IP (per visitor); client IP when no key is sent |
rate_limit_per_secret_key
(SPREE_RATE_LIMIT_PER_SECRET_KEY
) | 600 / window | Per secret key, across the whole API |
| () | 60 s | |
| 5 | Per IP: store/admin/seller login, invitation acceptance |
| 3 | Per IP: customer registration, newsletter subscribe |
| 10 | Per IP: refresh, logout, admin auth providers |
rate_limit_password_reset
| 3 | Per IP |
Responses include
,
and, when limited,
. Inbound payment/fulfillment webhook endpoints have their own fixed limit of 120/min.
Debugging recipes
- 401 everywhere: a key sent to the Admin API or an key to the Store API; a revoked key; an admin JWT older than 5 minutes (refresh via the cookie); a customer token sent to the Admin API (wrong ).
- 403 on Admin: read / . For a key, mint a new one with that scope. For staff, add the key to their role on this store.
- Cart 404 right after checkout: expected. Fetch .
- Empty list but records exist: wrong channel ( or a channel-bound key), product not published on that channel, no price in the requested currency or market, or a silently dropped Ransack filter.
- Probe from a terminal:
spree api get /orders/or_xxx --expand payments,fulfillments
(see ).
Where to read further
- OpenAPI specs (authoritative):
node_modules/@spree/docs/dist/api-reference/store.yaml
, ,
- Store API guides:
node_modules/@spree/docs/dist/api-reference/store-api/
(authentication, localization, idempotency, rate limiting, querying, relations)
- Admin API guides:
node_modules/@spree/docs/dist/api-reference/admin-api/
(authentication, errors, querying)
- Webhook events:
node_modules/@spree/docs/dist/api-reference/webhooks-events.md
- Related skills: , , , (add endpoints), , (Seller API), .