The headless Store API for Shopper. It exposes the catalog, geo data,
cart, customer authentication and account endpoints as a JSON:API under
the /store prefix, ready to be consumed by any storefront or by the official
@shopperlabs/shopper-sdk JavaScript client.
- PHP 8.3+
- Laravel 12.x or 13.x
shopper/frameworkinstalled and migrated
composer require shopper/api
php artisan shopper:api:installThe service provider is auto-discovered and registers every /store route. No route file
needs to be published.
The install command takes care of everything the API needs after the package is required:
- publishes the Laravel Sanctum migrations
(
personal_access_tokensis required by the auth endpoints) - publishes the Shopper configuration files
- adds the
HasApiTokenstrait to your configured user model, with your confirmation - runs the database migrations
Prefer doing it by hand? The auth endpoints issue stateless Sanctum tokens, so publish and run the Sanctum migration once:
php artisan vendor:publish --tag=sanctum-migrations
php artisan migrateSkipping this step makes every auth endpoint fail with a "relation personal_access_tokens does not exist" error.
Then customers authenticate against the user model configured in auth.providers.users.model,
which needs the Sanctum trait:
use Laravel\Sanctum\HasApiTokens;
use Shopper\Traits\InteractsWithShopper;
class User extends Authenticatable implements ShopperUser
{
use HasApiTokens;
use InteractsWithShopper;
}InteractsWithShopper is already part of every Shopper installation. It gives the model the
customer columns, the avatar accessor, and the public_id ULID exposed as the JSON:API id.
All routes live under the configurable /store prefix (SHOPPER_API_PREFIX), are throttled,
and answer in application/vnd.api+json.
| Area | Endpoints |
|---|---|
| Catalog | GET /store/products[/{slug}], categories, collections, brands, attributes |
| Geo | GET /store/countries[/{code}], zones[/{code}], currencies[/{code}] |
| Cart | POST /store/carts, GET /store/carts/{id}, POST /store/carts/{id}/lines, PATCH/DELETE /store/carts/{id}/lines/{lineId} |
| Auth | POST /store/auth/register, login, logout, forgot-password, reset-password |
| Account | GET/PATCH /store/customers/me, POST/DELETE /store/customers/me/avatar, addresses CRUD, GET /store/customers/me/orders |
Lists support the JSON:API query family: filter[...], sort, include,
page[number]/page[size] and sparse fieldsets via fields[type]. The allowed filters,
sorts and includes per resource are driven by the shopper.api.resources config and can be
extended in your application.
php artisan vendor:publish --tag=shopper-configThis publishes config/shopper/api.php (pagination, per-resource query allowlists) and
config/shopper/http.php (route prefix, rate limiters, zone resolution, extra middleware).
Prices are scoped by zone. The storefront pins one by sending its code on every request:
X-Shopper-Zone: af
The resolver is swappable through shopper.http.zone.resolver if you prefer resolving the
zone from a country, a geo-ip lookup, or any custom strategy.
The cart works for guests and authenticated customers with the same endpoints. Create one, persist its id on the client (cookie, localStorage), then drive it through the line endpoints:
POST /store/carts → 201, the new cart
GET /store/carts/{id} → 200, summary + totals
POST /store/carts/{id}/lines → 200, add a purchasable
PATCH /store/carts/{id}/lines/{lineId} → 200, change quantity / metadata
DELETE /store/carts/{id}/lines/{lineId} → 200, remove the line
Every response is the full cart document, recalculated by the shopper/cart pipelines
(lines, coupon discounts, taxes), so the storefront never needs a follow-up call to refresh
totals. All amounts are integers in cents.
{
"data": {
"type": "carts",
"id": "01jxd9se2cd6kr925vqgve46rb",
"attributes": {
"currency_code": "USD",
"promotions": [{ "code": "SAVE20", "amount": 1000 }],
"lines_count": 1,
"lines_quantity": 2,
"subtotal": 5000,
"discount_total": 1000,
"tax_total": 400,
"total": 4400,
"tax_inclusive": false
}
}
}The cart document is partitioned: the attributes above are the cheap summary (enough for a header badge), everything heavier is an opt-in JSON:API include:
| Include | Expands |
|---|---|
lines |
The cart lines with their computed amounts (subtotal, discount_total, tax_total), applied adjustments and tax_lines |
lines.purchasable |
The full product or variant behind each line, typed by the line's purchasable_type |
addresses |
The shipping and billing addresses attached to the cart |
GET /store/carts/{id}?include=lines.purchasable,addresses
The include parameter works on the line endpoints too, so a POST .../lines?include=lines
answers with the updated lines in the same round trip.
Lines target a purchasable through its public id and a type discriminator:
{
"purchasable_type": "variant",
"purchasable_id": "01jxd9se2cd6kr925vqgve46rb",
"quantity": 2,
"metadata": { "engraving": "MC" }
}Line operations are idempotent on the purchasable: adding the same product or variant again
increments the existing line instead of creating a duplicate. The API refuses anything a
storefront cannot sell, draft products, external products, products that sell through their
variants, or a purchasable without a price in the cart currency, with a 422 validation error.
A guest cart is a capability URL: whoever holds the ULID can read and mutate it. A cart
created with a Bearer token belongs to that customer, and any other caller gets a 404
indistinguishable from a missing cart. Mutating a completed cart answers 409 Conflict.
POST /store/auth/register → 201, customer + Sanctum token in meta.token
POST /store/auth/login → 200, customer + token in meta.token
POST /store/auth/logout → 204, revokes the current token
Send the token as a Bearer header on every account endpoint:
Authorization: Bearer {token}
Tokens carry the store ability. The customer is always resolved from the token, so account
URLs never contain a customer id.
The @shopperlabs/shopper-sdk
package wraps every endpoint, manages the token automatically and flattens the JSON:API
payloads into the types from @shopperlabs/shopper-types:
import Shopper from '@shopperlabs/shopper-sdk'
const sdk = new Shopper({ baseUrl: 'https://my-store.com' })
await sdk.auth.login({ email, password })
const me = await sdk.store.customer.me()
const { data } = await sdk.store.product.list({ include: ['variants', 'brand'] })The cart module mirrors the cart endpoints and asks for the lines include by default, so
cart.lines is always populated. Pass your own include to slim it down or expand more:
let cart = await sdk.store.cart.create({ currency_code: 'USD' })
cart = await sdk.store.cart.createLineItem(cart.id, {
purchasable_type: 'variant',
purchasable_id: variant.public_id,
quantity: 2,
})
cart = await sdk.store.cart.updateLineItem(cart.id, cart.lines[0].id, { quantity: 3 })
cart = await sdk.store.cart.deleteLineItem(cart.id, cart.lines[0].id)
// Header badge: skip the lines entirely
const summary = await sdk.store.cart.retrieve(cart.id, { include: [] })
console.log(summary.lines_quantity, summary.total)Shopper API is open-sourced software licensed under the MIT license.