Agent Skill · Spree Commerce

spree-api-v3

Use when the user is integrating with Spree's v3 REST API — making requests as a customer, building an admin app, writing webhook consumers, debugging auth errors, parsing API responses. Distinguishes the Store API (customer-facing) from the Admin API (back-office). Common phrasings include "Spree API", "Store API", "Admin API", "publishable key", "secret key", "X-Spree-API-Key", "API scopes", "prefixed IDs in API", "expand", "API pagination", "Spree 401", "Spree 403", "{data, meta} envelope", "v3 endpoint". For ADDING a new resource to the API, use the spree-resource skill instead.

Provider: Spree Commerce Path in repo: skills/spree-api-v3/SKILL.md

Skill body

Spree API v3

Spree exposes two distinct API surfaces under /api/v3/:

Surface Path Audience Auth
Store API /api/v3/store/* Storefronts, mobile apps, customers Publishable key + optional JWT customer
Admin API /api/v3/admin/* Back-office apps, integrations, admin SPAs Secret key + scopes OR JWT admin + CanCanCan

They share conventions (envelope shape, prefixed IDs, pagination) but have different auth, different default actions, and different exposed fields. This is the most-confused-about distinction in the API; cover it carefully.

Store API vs Admin API — the contract differences

Store API

Who calls it: customer browsers and apps. Public, untrusted clients.

Auth: Always include X-Spree-API-Key: pk_<token> (a publishable key). Additional layers:

What’s exposed: customer-visible fields only. No timestamps (created_at/updated_at are NOT in Store responses). No cost prices, no admin internal notes, no private metadata.

What actions are enabled: read-only by default. index and show for catalog endpoints. Cart/customer/address endpoints opt into create/update/destroy.

Channel scope: Store responses are scoped by X-Spree-Channel: <code> (e.g. online, pos). When omitted, the store’s default channel is used. Products not published on the requested channel don’t appear.

curl -H "X-Spree-API-Key: pk_CzEKBTWFiuNLgz4wciLsS59n" \
     -H "X-Spree-Channel: online" \
     -H "Accept-Language: en-US" \
     https://my-spree.example.com/api/v3/store/products

Admin API

Who calls it: trusted backend apps (your ERP integration, marketplace fulfillment service) or trusted humans (admin SPA users). Never the browser of an anonymous visitor.

Auth: Two distinct paths, each with its own authorization model.

Path 1: Secret key with scopes — for server-to-server apps and integrations.

curl -H "X-Spree-API-Key: sk_…" \
     https://my-spree.example.com/api/v3/admin/orders

Secret keys (sk_* prefix) carry scopes that gate which endpoints they can hit. Scopes are granted at key creation. Each request’s scope is enforced by ScopedAuthorization; missing scope = 403.

The scope list (5.5):

read_orders               write_orders
read_products             write_products
read_customers            write_customers
read_payments             write_payments
read_fulfillments         write_fulfillments
read_refunds              write_refunds
read_gift_cards           write_gift_cards
read_store_credits        write_store_credits
read_stock                write_stock
read_categories           write_categories
read_custom_field_definitions  write_custom_field_definitions
read_exports              write_exports
read_settings             write_settings
read_webhooks             write_webhooks
read_dashboard
read_all                  write_all     # superset (full admin)

This is the right path for building an app or integration. The app gets a minimum-privilege secret key from the merchant; no human user is involved. Audit-friendly (you know exactly which app made each request).

Path 2: JWT with CanCanCan abilities — for human admin users.

# Login first
curl -X POST https://my-spree.example.com/api/v3/admin/auth/login \
     -H "Content-Type: application/json" \
     -d '{"email":"admin@example.com","password":"…"}'
# Returns: { token, user } — the JWT is in `token`; the refresh token is set as an
# httpOnly cookie (used by POST /api/v3/admin/auth/refresh), not returned in the body.

# Then use the JWT
curl -H "Authorization: Bearer <jwt>" \
     https://my-spree.example.com/api/v3/admin/orders

JWT admin auth uses Spree::Ability (CanCanCan) to determine what the human user can do. Roles + permission sets configure who can manage what.

(The store is resolved from the request host, not from an API key.)

What’s exposed: everything visible to the Store API plus timestamps (created_at, updated_at, deleted_at if paranoid), cost prices, private metadata, internal notes, audit fields (approved_by_id, cancelled_by_id), back-office relations.

What actions are enabled: full CRUD by default. index, show, create, update, destroy for every resource unless explicitly restricted.

Quick reference

Question Store API Admin API
Default actions index, show full CRUD
API key prefix pk_* sk_* (or use JWT instead)
Timestamps in responses No Yes
Cost prices exposed No Yes
Channel scoping Optional header (default channel when omitted) N/A (header ignored; filter via Ransack)
Default for new endpoints Read-only Full CRUD
Authorization Per-endpoint defaults Scopes (sk_*) OR CanCanCan abilities (JWT)

The {data, meta} envelope

Every list endpoint returns:

{
  "data": [
    { "id": "prod_86Rf07xd4z", "name": "...", ... },
    { "id": "prod_kvJ0pQrTb9", "name": "...", ... }
  ],
  "meta": {
    "page": 1,
    "limit": 25,
    "count": 152,
    "pages": 7,
    "from": 1,
    "to": 25,
    "in": 25,
    "previous": null,
    "next": 2
  }
}

Single-record endpoints return the record’s attributes directly (no wrapping):

{ "id": "prod_86Rf07xd4z", "name": "...", ... }

Conventions:

Prefixed IDs

Every v3 API uses Stripe-style prefixed IDs:

prod_86Rf07xd4z       Product
variant_k5nR8xLq      Variant
or_m3Rp9wXz           Order
py_…                  Payment       (Stripe parity)
ful_…                 Shipment
adj_…                 Adjustment
li_…                  LineItem
ch_…                  Channel
key_…                 ApiKey        (record ID; the credential token values are prefixed pk_/sk_ — those are secrets, not IDs)
cf_…                  CustomField   (alias of Metafield)

The integer PK is never in API responses — only the prefixed form. Same on writes: send "variant_id": "variant_k5nR8xLq", not "variant_id": 42. The server resolves prefixed IDs to integer PKs internally.

Computation: Sqids.encode([integer_pk]) with min_length: 10. Deterministic — the same PK always gets the same prefixed ID. There’s no database column for it; it’s computed on read.

Expand

Resources support an expand query param for sideloading related data:

curl '/api/v3/store/products/cool-shirt?expand=media,default_variant,categories'

Returns the product with media, default_variant, and categories inlined as full objects. Without expand, related objects appear as ID references on the parent. Dot notation lets you expand nested associations:

curl '/api/v3/store/products/cool-shirt?expand=variants.media'

Allowed expand keys are per-resource and listed in the OpenAPI spec.

Pagination

GET /api/v3/admin/orders?page=2&limit=50

Offset-based via Pagy. limit defaults to 25; max is generally 100. Use meta.next / meta.previous to navigate.

Filtering with Ransack

List endpoints accept Ransack predicates as q[<attribute>_<predicate>]:

# Products with name containing "shirt"
GET /api/v3/store/products?q[name_cont]=shirt

# Orders completed in the last 30 days
GET /api/v3/admin/orders?q[completed_at_gteq]=2026-05-01

# Multiple filters
GET /api/v3/admin/orders?q[state_eq]=complete&q[total_gt]=100

# Sort by completed_at descending
GET /api/v3/admin/orders?q[s]=completed_at+desc

Common predicates: _eq, _not_eq, _in, _not_in, _cont (LIKE %x%), _start (LIKE x%), _gteq, _lteq, _gt, _lt, _present, _blank.

Only attributes in the model’s whitelisted_ransackable_attributes and whitelisted_ransackable_associations are queryable. Predicates on attributes outside the whitelist are silently ignored — the request returns 200 with that condition dropped (any remaining whitelisted predicates still apply), not a 422.

Error responses

All errors use a consistent envelope:

// 401 Unauthorized
{ "error": { "code": "invalid_token", "message": "Valid API key required" } }

// 403 Forbidden (scope missing)
{ "error": { "code": "access_denied", "message": "API key lacks scope: write_orders", "details": { "required_scope": "write_orders" } } }

// 404 Not Found
{ "error": { "code": "record_not_found", "message": "Product not found" } }

// 422 Unprocessable Entity (validation)
{
  "error": {
    "code": "validation_error",
    "message": "Email can't be blank and Customer is required for checkout",
    "details": {
      "email": ["can't be blank"],
      "base": ["Customer is required for checkout"]
    }
  }
}

// 429 Too Many Requests (rate limited)
{ "error": { "code": "rate_limit_exceeded", "message": "..." } }

Note: some resources return a specific code instead of record_not_found: order_not_found (orders), cart_not_found (carts — order lookups on /carts paths), line_item_not_found, variant_not_found. The message is always “ not found".

The details map on 422s uses attribute names as keys. Map field-level errors to inputs; base errors are non-field-specific (display as a form-level banner). The @spree/sdk includes a SpreeError class that parses these automatically.

Rate limiting

The API has per-key and per-endpoint rate limits configured via Spree::Api::Config:

Setting Default Scope
rate_limit_per_key 300 / 60s General requests, per API key
rate_limit_window 60s Window for the per-key counter
rate_limit_login 5 / 60s POST /api/v3/{store,admin}/auth/login + admin invitation acceptance, per IP
rate_limit_register 3 / 60s Register endpoint, per IP
rate_limit_refresh 10 / 60s Token refresh (store + admin) and store logout, per IP
rate_limit_password_reset 3 / 60s Password reset, per IP

Hit limits → 429 Too Many Requests with Retry-After header. The @spree/sdk retries with exponential backoff automatically.

Tune via Spree::Api::Config[:rate_limit_per_key] etc. in config/initializers/spree.rb. For tougher global throttling (per-IP at the proxy edge), layer Rack::Attack or your CDN’s WAF on top.

Read/write attribute symmetry (a v3 invariant)

For any resource: whatever a serializer returns, the controller’s permitted_params accepts on write under the same name. No label exposed but presentation accepted. No customer_note exposed but special_instructions accepted. The client never has to translate.

When the underlying column has a legacy name, the model has an alias method (e.g. Spree::OptionType exposes label/label= aliasing the underlying presentation column). The model owns the bridge.

Common debugging recipes

“I’m getting 401 on every call”

“I’m getting 403 on Admin API”

“My q[…] filter is silently ignored”

The attribute isn’t in the model’s Ransack allowlist. The API uses lenient .ransack, so conditions on non-whitelisted attributes are silently dropped — the list comes back unfiltered (200, no error). Add the attribute via Spree.ransack.add_attribute(Spree::Product, :attr) in an initializer, or append it to whitelisted_ransackable_attributes in a model decorator.

“Empty data array but I know records exist”

“Webhook payload uses raw IDs?”

It doesn’t — Webhooks 2.0 uses the same prefixed IDs as the API. If you’re seeing integer IDs, you’re either on the legacy webhooks system OR the consumer is parsing wrong.

Where to read further