Spree API v2 — Legacy
Default recommendation: don't build new things on v2. Spree 5.4+ ships API v3 as the future-direction API; v2 still works but is feature-frozen. New endpoints, new auth modes (publishable + secret + JWT), prefixed IDs, expand, the typed SDKs — all v3. New integrations should start on v3.
This skill exists for two real reasons:
- You inherited a v2 integration and need to maintain it.
- You're migrating from v2 to v3 and need to know what changed.
If neither applies, use the spree-api-v3 skill instead.
What v2 looks like
Two distinct API surfaces under /api/v2/:
| Surface |
Path |
Audience |
| Storefront API |
/api/v2/storefront/* |
Customer-facing — carts, products, checkout |
| Platform API |
/api/v2/platform/* |
Admin/back-office — full CRUD |
Both are JSON:API style (https://jsonapi.org/) — strict envelope with data, attributes, relationships, included. Different envelope shape from v3.
Auth in v2
| Surface |
Header |
Token |
| Storefront (anonymous browse) |
None |
Public access |
| Storefront (guest cart) |
X-Spree-Order-Token |
Cart's order token |
| Storefront (logged-in customer) |
Authorization: Bearer <oauth_token> |
OAuth2 customer token |
| Platform (admin) |
Authorization: Bearer <oauth_token> |
OAuth2 admin token |
v2 uses OAuth2 Doorkeeper for token issuance — /spreeoauth/token endpoint with password or clientcredentials grants. No publishable keys, no secret keys. OAuth scopes exist but are coarse (admin, write, read — the Platform API requires read/admin to read and write/admin to write); nothing like v3's per-resource readproducts/writeorders API-key scopes.
# Get a Platform API token (admin)
curl -X POST https://my-spree.example.com/spree_oauth/token \
--header "Content-Type: application/x-www-form-urlencoded" \
--data "grant_type=password&[email protected]&password=…"
# => { "access_token": "…", "token_type": "Bearer", "expires_in": 2592000, ... }
# (tokens last 1 month by default — `access_token_expires_in 1.month`)
# Use it
curl -H "Authorization: Bearer …" \
https://my-spree.example.com/api/v2/platform/orders
Envelope shape
{
"data": {
"id": "42", // raw integer PK as string
"type": "product",
"attributes": {
"name": "Cool shirt",
"price": "29.99",
"currency": "USD",
"created_at": "2025-01-01T00:00:00Z"
},
"relationships": {
"variants": {
"data": [{ "id": "100", "type": "variant" }]
}
}
},
"included": [
{
"id": "100",
"type": "variant",
"attributes": { "sku": "COOL-S", ... }
}
],
"meta": { "total_count": 152, "total_pages": 7 }
}
Compared to v3:
- v2: raw integer IDs (
"42"). v3: prefixed IDs ("prod_…").
- v2: nested
attributes + relationships. v3: flat top-level fields.
- v2:
include query param pulls related resources into included array. v3: expand query param inlines them into the response directly (?expand=variants,media, dot-nested up to 4 levels).
- v2 (Platform API): filters use
filter[namecont]=…. v3: same idea but q[namecont]=… (Ransack). The Storefront API used fixed per-endpoint filter names instead — see Step 4 below.
- v2: sort uses
sort=-createdat. v3: identical — sort=-createdat works unchanged (the v3 ResourceController translates -field JSON:API notation to Ransack s internally).
Pagination
GET /api/v2/storefront/products?page=2&per_page=25
meta.totalcount, meta.totalpages. No keyset/cursor support in v2 — offset only.
When you find yourself on v2
"I need to add a field to a v2 response"
Decorate the JSON:API serializer (jsonapi-serializer gem under the hood):
module Spree::V2::Storefront::ProductSerializerDecorator
def self.prepended(base)
base.attribute :custom_field do |product|
product.custom_field_value
end
end
Spree::V2::Storefront::ProductSerializer.prepend self
end
"I need a new v2 endpoint"
Don't add to v2. Add to v3 instead — see spree-resource skill for the spree:api_resource generator. If your v2 client really can't talk to v3, write a thin compatibility wrapper that returns v2-envelope-shaped JSON but reads from your v3 resource internally.
"v2 returns an error, what's the shape?"
{
"error": "The resource was not found.",
"errors": { ... } // on 422
}
Plain JSON, not JSON:API errors object. v3 uses the structured { error: { code, message, details } } envelope — see spree-api-v3.
Migrating v2 → v3
The migration is per-endpoint. You don't need to flip everything at once; v2 and v3 coexist.
Step 1: Auth migration
| v2 |
v3 |
| OAuth2 password grant for customer login |
JWT login at /api/v3/store/auth/login (returns token + refresh_token + user) |
| OAuth2 client_credentials for admin apps |
Secret key (sk_*) with scoped permissions |
Authorization: Bearer <token> |
`X-Spree-Api-Key: <pk_…\ |
sk_…> + optional Authorization: Bearer <jwt>` |
X-Spree-Order-Token for guest carts |
Still X-Spree-Token (renamed) |
For admin apps: generate a secret key via the admin UI (Settings → Developers → API Keys, key type 'Secret') with the required scopes. Drop OAuth2 entirely.
For customer flows: replace the OAuth2 login call with JWT login. The new flow returns refresh tokens too — see the admin authentication docs for the cookie-based refresh pattern.
Step 2: ID migration
v2 returns integer IDs as strings; v3 returns prefixed IDs. If your client stores IDs:
// Old
const productId = response.data.id // "42"
// New
const productId = response.data.id // "prod_86Rf07xd4z"
If you've persisted v2 IDs in your own DB and need to look them up against v3 responses, the integer PK is the same — just use the v3 search endpoints with the integer:
GET /api/v3/admin/products?q[id_eq]=42
Note that exposing raw integer IDs in client code is a v2 anti-pattern that v3 fixes; the new IDs are URL-safe and don't leak record counts.
Step 3: Envelope unwrap
// v2 JSON:API
const product = response.data
const name = product.attributes.name
const price = product.attributes.price
const variantIds = product.relationships.variants.data.map(v => v.id)
const variants = response.included.filter(r => r.type === 'variant')
// v3 flat
const product = response // single endpoint — no envelope
const name = product.name
const price = product.price
const defaultVariantId = product.default_variant_id // direct field
// With ?expand=variants: product.variants is an array of full variant objects
Write a thin adapter if you can't refactor every call site at once:
function unwrapV2<T>(response: V2Response<T>): T {
return {
id: response.data.id,
...response.data.attributes,
...buildRelationships(response.data.relationships, response.included),
} as T
}
Step 4: Filter syntax
| v2 |
v3 |
filter[name_cont]=shirt (Platform API) |
q[name_cont]=shirt |
filter[price_gteq]=20 (Platform API) |
q[price_gteq]=20 |
sort=-created_at |
sort=-created_at (unchanged) |
For the Platform API, filter[...] keys are Ransack predicates and map 1:1 to v3's q[...] — filter[namecont] becomes q[namecont]. The Storefront API did not use Ransack: each endpoint had fixed filter names handled by a finder class, so translate them instead — filter[name] → q[namecont] (or q[search]), filter[taxons] → q[incategories], filter[price]=10,50 → q[pricegte]=10&q[pricelte]=50.
The @spree/sdk accepts flat Ransack predicates as typed list params — client.products.list({ namecont: 'shirt' }) — and produces q[namecont]=... for you — see spree-typescript-sdk.
Step 5: Switch SDK
If you're using the old @spree/storefront-api-v2-sdk package (JS), replace it with @spree/sdk (Store API) or @spree/admin-sdk (Admin API). The new SDKs are typed, have built-in retry, and follow the same protocol the rest of the v3 surface uses.
Step 6: Webhooks (if applicable)
If you receive v2 webhooks: v2 webhooks fire with raw IDs and the legacy payload shape. v3 webhooks use prefixed IDs and the {id, name, created_at, data, metadata} envelope shape. If you flip endpoints to v3, your webhook receiver must handle the new payload shape — see spree-events-webhooks.
When v2 will go away
v2 is no longer bundled with spreeapi. As of Spree 5.5 it lives in the separate, deprecated spreelegacyapiv2 gem, which works with Spree 5. To keep an existing v2 integration running, add the gem (bundle add spreelegacyapiv2); on a fresh app also run bin/rails g spree:legacyapiv2:install to install its migrations (apps upgraded from earlier Spree versions already have them). API v2 is slated for removal in the next major release and gets no new features — new fields, endpoints, and capabilities (e.g. Markets, the new pricing engine) land on v3 only.
If you maintain a high-value v2 integration, schedule the migration; you'll only fall further behind otherwise.
Where to read further
- v3 API protocol:
spree-api-v3 skill — the destination.
- v3 TypeScript SDKs:
spree-typescript-sdk skill.
- Adding a custom endpoint on v3:
spree-resource skill.
- v2 source: the
spreelegacyapiv2 gem — controllers + serializers (jsonapi-serializer based). v2 no longer ships inside spree_api.
- v2 OpenAPI spec:
docs/api-reference/storefront.yaml and docs/api-reference/platform.yaml. The v3 spec is docs/api-reference/store.yaml (also shipped in the @spree/docs npm package at node_modules/@spree/docs/dist/api-reference/store.yaml).