Shopify REST API Adapter
Admin API Compatible
Last updated: May 15, 2026
A drop-in compatible REST API for STOAR that mirrors Shopify's Admin REST API 2024-01 — same URL paths (/admin/api/2024-01/...), same flat query-parameter syntax, same JSON envelope shape ({ "order": {…} }, { "orders": [...] }), same auth header, same Link: <…>; rel="next" cursor pagination. Existing Shopify client libraries (shopify_api Ruby gem, @shopify/shopify-api-node, ShopifySharp, python-shopify-api) talk to STOAR with no modification.
The adapter is the third implementation of STOAR's extensible API adapter framework (after Magento and WooCommerce). It demonstrates a yet-distinct envelope flavour — top-level keyed wrappers + cursor pagination — sitting next to Magento and WC on the same vendor-agnostic data layer.
Table of contents #
- Quick start
- Authentication
- Endpoints
- Query parameters
- Cursor pagination
- Field mapping (Shopify ↔ STOAR)
- Status translation
- Response shape
- Real-world example
- Error shape
- Rate limiting
Quick start #
TOKEN="4|abcdef…" # Sanctum personal-access token with shopify:admin ability
# Discover shop currency / locale (real Shopify clients call this first)
curl -H "X-Shopify-Access-Token: $TOKEN" \
"https://app.stoar.ai/admin/api/2024-01/shop.json" | jq '.shop | {name, currency}'
# List the most recent 5 orders
curl -H "X-Shopify-Access-Token: $TOKEN" \
"https://app.stoar.ai/admin/api/2024-01/orders.json?limit=5" | jq '.orders[] | {id, financial_status, total_price}'
# Fetch a single order
curl -H "X-Shopify-Access-Token: $TOKEN" \
"https://app.stoar.ai/admin/api/2024-01/orders/10126.json"
# Cancel a pending order
curl -X POST -H "X-Shopify-Access-Token: $TOKEN" \
"https://app.stoar.ai/admin/api/2024-01/orders/123/cancel.json" \
-H "Content-Type: application/json" -d '{"reason":"customer"}'
Bearer auth also works — -H "Authorization: Bearer $TOKEN" is interchangeable with the Shopify-native header.
Authentication #
Shopify's canonical auth header is X-Shopify-Access-Token: <token>. STOAR's adapter accepts it and a Bearer fallback:
| Method | Format | Use case |
|---|---|---|
| X-Shopify-Access-Token | X-Shopify-Access-Token: <sanctum-token> |
Default for all Shopify client libraries |
| Bearer (STOAR extension) | Authorization: Bearer <sanctum-token> |
Native Sanctum — interchangeable with the Magento adapter's auth |
Tokens are issued from STOAR's /manager/api-tokens page, programmatically via AdminUser::createToken('label', ['shopify:admin']), or — for STOAR-internal flows — via Magento's /rest/V1/integration/admin/token endpoint and then granted the shopify:admin ability in the same record.
Mapping to Sanctum. The AccessTokenMiddleware runs before auth:sanctum and promotes X-Shopify-Access-Token into a Authorization: Bearer … header. From STOAR's perspective, every Shopify request is a normal Sanctum-authenticated request whose token row carries the shopify:admin ability. A single token can hold every adapter's ability simultaneously (shopify:admin + magento:admin + woocommerce:admin + bigcommerce:admin).
OAuth and HMAC verification (used by real Shopify apps) are not supported.
Endpoints #
All paths are relative to /admin/api/2024-01 (verbatim Shopify API namespace, including the 2024-01 version).
GET /admin/api/2024-01/shop.json
Returns shop metadata synthesised from STOAR's Setting table. Real Shopify clients call this first to learn the shop's currency / locale before doing anything else.
Response:
{
"shop": {
"id": 1,
"name": "STOAR",
"domain": "app.stoar.ai",
"myshopify_domain": "app.stoar.ai",
"email": "[email protected]",
"currency": "EUR",
"country_code": "DE",
"country_name": "Germany",
"primary_locale": "en",
"iana_timezone": "UTC",
"weight_unit": "kg",
"plan_name": "stoar",
"plan_display_name":"STOAR",
"shop_owner": "STOAR",
"money_format": "€ {{amount}}",
"checkout_api_supported": true,
"has_storefront": true,
"..."
}
}
GET /admin/api/2024-01/orders.json
List orders.
Authorization: shopify:admin.
Query: see Query parameters.
Response (200):
HTTP/1.1 200 OK
Link: <…?page_info=eyJwIjoyLCJzIjo1LCJmIjoiZjE…&limit=5>; rel="next"
Content-Type: application/json
{
"orders": [
{ /* order object — see Response shape */ }
]
}
The Link header drives pagination (no ?page= counter). See Cursor pagination.
GET /admin/api/2024-01/orders/{id}.json
Single order by numeric id.
Response: { "order": {...} } envelope. See Response shape.
Errors:
{ "errors": "Not Found" }
POST /admin/api/2024-01/orders/{id}/cancel.json
Cancel a pending or paid order.
Authorization: shopify:admin.
Body (optional):
{ "reason": "customer" }
Reason codes accepted by Shopify: customer, inventory, fraud, declined, other. STOAR records this on the resulting OrderStatusLog but doesn't otherwise act on it.
Response (200): the cancelled order in the standard envelope.
Errors:
404— unknown id422— order is in a state that disallows cancellation (e.g.delivered,refunded)
GET /admin/api/2024-01/products.json
List products. Same envelope + Link-header pagination as orders.
GET /admin/api/2024-01/products/{id}.json
Single product. Variants are inlined (not just ids — the full variant objects), matching Shopify's exact contract.
Response: { "product": {...} }. See Response shape.
Query parameters #
Shopify's REST list endpoints take flat query params — far simpler than Magento's nested searchCriteria[…]. The parser lives in app/Api/Adapters/Shopify/Search/.
Common params (orders + products)
| Param | Default | Purpose |
|---|---|---|
limit |
50 |
items per page (max 250) |
page_info |
— | opaque base64 cursor — overrides any other filter (see Cursor pagination) |
since_id |
— | only items with id > since_id (alternative to cursor) |
ids |
— | CSV id list (?ids=1,2,3) |
created_at_min / created_at_max |
— | ISO 8601 |
updated_at_min / updated_at_max |
— | ISO 8601 |
order |
created_at desc |
<field> <direction>. Direction: asc / desc |
Orders only
| Param | Example | Effect |
|---|---|---|
status |
open, closed, cancelled, any |
High-level lifecycle — translated to multiple Stoar statuses |
financial_status |
paid, pending, refunded, voided, authorized |
Translated to a single Stoar status |
fulfillment_status |
fulfilled, partial, unfulfilled, any |
Translated to Stoar status |
Products only
| Param | Example | Effect |
|---|---|---|
status |
active, archived, draft |
Translated to Stoar active / inactive |
title |
widget |
Substring (LIKE) on product name |
handle |
widget-pro |
Exact match on slug |
vendor / product_type |
— | Accepted but ignored — Stoar has no equivalent fields |
Unknown params are ignored. Filter values that don't translate to anything sensible (e.g. ?financial_status=foo) are silently dropped.
Cursor pagination #
Shopify uses opaque base64 cursors instead of page counters. STOAR mirrors the contract:
- Client sends
?limit=N(nopageparameter). - Server returns the first N items + a
Link:header:Link: <…/orders.json?page_info=eyJwIjoyLCJzIjo1LCJmIjoiYWJjMTIzIn0&limit=5>; rel="next" - Client follows the
rel="next"URL verbatim — never builds it manually. - Following the link, server decodes
page_infoto get(page=2, page_size=5, filter_hash=abc123)and returns the next page.
The filter_hash is a stable MD5 over the original filter set. If a client tries to reuse a cursor against a different result set (e.g. by changing ?status=open to ?status=closed), the cursor's hash mismatches and STOAR falls back to a fresh page-1 result. This matches real-Shopify behaviour where cursors are invalidated by parameter changes.
Link header may include both rel="next" and rel="previous":
Link: <…?page_info=PREV>; rel="previous", <…?page_info=NEXT>; rel="next"
Field mapping (Shopify ↔ STOAR) #
Order
| Shopify field | STOAR source | Notes |
|---|---|---|
id |
id |
numeric |
admin_graphql_api_id |
id (formatted) |
gid://shopify/Order/{id} |
name |
id |
#{id} (Shopify shows #1001 on orders) |
number |
id |
numeric |
order_number |
id + 1000 |
Shopify defaults to base 1000 |
email, contact_email |
customer_email |
|
phone |
— | always null (not stored on Stoar Order) |
currency, presentment_currency |
currency (uppercased) |
eur → EUR |
financial_status |
status (translated) |
see Status translation |
fulfillment_status |
status (translated) |
null, partial, or fulfilled |
status (lifecycle) |
status (translated) |
open, closed, cancelled |
total_price, current_total_price |
total_amount |
2-dp string |
total_price_set |
money_set wrapper around total_amount |
{shop_money, presentment_money} |
subtotal_price, total_line_items_price |
subtotal |
2-dp string + money_set |
total_tax, current_total_tax |
tax_amount |
2-dp string + money_set |
total_shipping_price_set |
money_set wrapper around shipping_amount |
|
total_discounts |
discount_amount |
2-dp string |
total_outstanding |
total_amount - sum(succeeded payments) |
money owed |
total_paid |
not exposed at top-level (Shopify computes from transactions) | available via current_total_price - total_outstanding |
gateway, payment_gateway_names[] |
first non-archived OrderPayment.gateway |
|
created_at, updated_at, processed_at |
created_at, updated_at |
ISO 8601 |
cancelled_at |
updated_at if status=cancelled |
null otherwise |
closed_at |
updated_at if status=delivered/refunded |
null otherwise |
cancel_reason |
'other' if cancelled |
null otherwise |
customer |
embedded Customer model |
null for guests |
billing_address |
billing_info JSON column |
Shopify flat shape |
shipping_address |
shipping_info JSON column |
Shopify flat shape |
line_items[] |
Order.items (with variant + product) |
see below |
discount_codes[] |
derived from coupon_code + discount_amount |
empty when no coupon |
tax_lines[] |
one element when tax_amount > 0 |
with rate 0 since Stoar doesn't store rate at order level |
shipping_lines[] |
derived from shipping_method + shipping_amount |
empty when no shipping |
refunds[] |
one synthetic entry when refunded_amount > 0 |
|
token |
lookup_token |
the order magic-link token — keep secret |
order_status_url |
/checkout/success?order_id=…&token=… |
clients use this for self-service order tracking |
tags |
"" |
not modelled |
Order line_item
| Shopify field | STOAR source |
|---|---|
id |
OrderItem.id |
variant_id |
OrderItem.variant_id |
product_id |
OrderItem.product_id |
title |
OrderItem.name |
variant_title |
Variant.name (when not "Default") |
name |
"{title} - {variant_title}" when variant has a name |
sku |
variant SKU first, falls back to product SKU |
quantity |
OrderItem.quantity |
price |
OrderItem.price (2-dp string) |
price_set |
money_set wrapper |
tax_lines[] |
one element when tax_amount > 0 |
vendor, properties[] |
always empty / null |
fulfillment_service |
'manual' |
fulfillment_status |
null |
gift_card, requires_shipping, taxable |
sensible defaults |
Product
| Shopify field | STOAR source | Notes |
|---|---|---|
id |
id |
|
admin_graphql_api_id |
gid://shopify/Product/{id} |
|
title |
name |
|
handle |
slug |
|
body_html |
description |
passed through (HTML or plain) |
status |
status (translated) |
active ↔ active; inactive ↔ archived |
vendor, product_type |
always "" |
not modelled in Stoar |
published_at |
created_at if active |
null when inactive/archived |
tags |
"" |
not modelled |
variants[] |
inline — full variant objects | always at least one (Default Title if Stoar has none) |
options[] |
derived from variant attributes JSON keys (max 3) |
each option has name, position, values[] |
images[] |
image_path + gallery_paths array |
position 1-indexed |
image |
first image (or null) | |
template_suffix, published_scope |
null, 'web' |
hardcoded |
Product variant
| Shopify field | STOAR source |
|---|---|
id |
Variant.id |
product_id |
Variant.product_id |
title |
Variant.name ("Default Title" when name is "Default") |
option1, option2, option3 |
values from attributes JSON in stable order (max 3 axes) |
price |
Variant.price (2-dp string) |
sku |
Variant.sku |
inventory_quantity |
Variant.stock |
inventory_management |
'shopify' (always) |
inventory_policy |
'deny' (always) |
weight |
Variant.weight |
weight_unit |
'kg' |
grams |
weight * 1000 (rounded) |
requires_shipping, taxable |
always true |
compare_at_price |
always null |
barcode |
always null |
Status translation #
Shopify decomposes order state across three fields. Stoar collapses them into one. The translator handles the round-trip.
Stoar → Shopify (rendering)
Stoar status |
financial_status |
fulfillment_status |
status (lifecycle) |
|---|---|---|---|
pending |
pending |
null |
open |
paid |
paid |
null |
open |
processing |
paid |
partial |
open |
shipped |
paid |
fulfilled |
open |
delivered |
paid |
fulfilled |
closed |
cancelled |
voided |
null |
cancelled |
refunded |
refunded |
null |
closed |
Shopify filter → Stoar (parsing)
| Shopify input | Stoar match |
|---|---|
?status=open |
pending, paid, processing, shipped |
?status=closed |
delivered, refunded |
?status=cancelled |
cancelled |
?status=any |
(no filter) |
?financial_status=pending |
pending |
?financial_status=paid / =authorized |
paid |
?financial_status=voided |
cancelled |
?financial_status=refunded / =partially_refunded |
refunded |
?fulfillment_status=fulfilled |
shipped |
?fulfillment_status=partial |
processing |
?fulfillment_status=unfulfilled |
paid |
Product status
| Shopify | Stoar |
|---|---|
active |
active |
archived, draft |
inactive |
Stoar has no draft concept — both archived and draft Shopify products map to Stoar inactive. Output uses archived.
Response shape #
Order — annotated example
{
"order": {
"id": 12345,
"admin_graphql_api_id": "gid://shopify/Order/12345",
"name": "#12345",
"number": 12345,
"order_number": 13345,
"token": "abc123…",
"email": "[email protected]",
"contact_email": "[email protected]",
"currency": "EUR",
"presentment_currency": "EUR",
"financial_status": "paid",
"fulfillment_status": null,
"status": "open",
"gateway": "stripe",
"payment_gateway_names":["stripe"],
"total_price": "115.00",
"total_price_set": {
"shop_money": { "amount": "115.00", "currency_code": "EUR" },
"presentment_money": { "amount": "115.00", "currency_code": "EUR" }
},
"subtotal_price": "100.00",
"total_tax": "10.00",
"total_shipping_price_set": {
"shop_money": { "amount": "5.00", "currency_code": "EUR" },
"presentment_money": { "amount": "5.00", "currency_code": "EUR" }
},
"total_discounts": "0.00",
"total_outstanding": "0.00",
"total_tip_received": "0.00",
"created_at": "2026-04-10T09:00:00+00:00",
"updated_at": "2026-04-10T09:30:00+00:00",
"processed_at": "2026-04-10T09:00:00+00:00",
"cancelled_at": null,
"closed_at": null,
"cancel_reason": null,
"customer": {
"id": 45,
"email": "[email protected]",
"first_name": "Jane",
"last_name": "Doe",
"verified_email": true,
"state": "enabled",
"currency": "EUR"
},
"billing_address": {
"first_name": "Jane",
"last_name": "Doe",
"name": "Jane Doe",
"address1": "1 Test St",
"address2": null,
"city": "Berlin",
"province": null,
"country": null,
"country_code": "DE",
"zip": "10115",
"phone": null
},
"shipping_address": { /* same shape as billing */ },
"line_items": [
{
"id": 987,
"variant_id": 12,
"product_id": 456,
"title": "Widget",
"variant_title":"Red",
"name": "Widget - Red",
"sku": "WID-RED",
"quantity": 2,
"price": "50.00",
"price_set": {"shop_money": {"amount": "50.00", "currency_code": "EUR"}, "presentment_money": {"amount": "50.00", "currency_code": "EUR"}},
"fulfillable_quantity": 2,
"fulfillment_service": "manual",
"fulfillment_status": null,
"requires_shipping": true,
"taxable": true,
"tax_lines": []
}
],
"shipping_lines": [
{
"id": 0,
"title": "Standard",
"code": "flat_rate",
"source": "shopify",
"price": "5.00",
"price_set": {"shop_money": {"amount": "5.00", "currency_code": "EUR"}, "presentment_money": {"amount": "5.00", "currency_code": "EUR"}},
"tax_lines": [],
"discount_allocations": []
}
],
"tax_lines": [],
"discount_codes": [],
"discount_applications":[],
"fulfillments": [],
"refunds": []
}
}
Product — abbreviated example
{
"product": {
"id": 789,
"admin_graphql_api_id":"gid://shopify/Product/789",
"title": "Widget",
"body_html": "<p>A great widget</p>",
"vendor": "",
"product_type": "",
"handle": "widget",
"status": "active",
"published_at": "2026-04-01T10:00:00+00:00",
"published_scope": "web",
"tags": "",
"variants": [
{
"id": 12,
"admin_graphql_api_id":"gid://shopify/ProductVariant/12",
"product_id": 789,
"title": "Red",
"price": "99.99",
"sku": "WID-RED",
"position": 1,
"inventory_policy": "deny",
"compare_at_price": null,
"fulfillment_service":"manual",
"inventory_management":"shopify",
"option1": "Red",
"option2": null,
"option3": null,
"weight": 0.5,
"weight_unit": "kg",
"grams": 500,
"inventory_quantity": 42
}
],
"options": [
{ "id": 0, "product_id": 789, "name": "Color", "position": 1, "values": ["Red", "Blue"] }
],
"images": [
{
"id": 0,
"admin_graphql_api_id": "gid://shopify/ProductImage/0",
"product_id": 789,
"position": 1,
"alt": null,
"src": "products/widget.webp",
"variant_ids":[]
}
],
"image": { /* same shape as images[0] */ }
}
}
Real-world example #
GET /admin/api/2024-01/orders/10126.json against production (live Shopify-shape transform of the same order featured in Magento and WooCommerce). PII anonymised. The response fits cleanly into Shopify's shopify_api Ruby gem and @shopify/shopify-api-node without modification.
{
"order": {
"id": 10126,
"admin_graphql_api_id": "gid://shopify/Order/10126",
"name": "#10126",
"number": 10126,
"order_number": 11126,
"token": "REDACTED-FOR-DOCS",
"email": "[email protected]",
"contact_email": "[email protected]",
"currency": "USD",
"presentment_currency": "USD",
"financial_status": "paid",
"fulfillment_status": null,
"status": "open",
"gateway": "payid",
"payment_gateway_names":["payid"],
"total_price": "936.98",
"subtotal_price": "936.98",
"total_tax": "0.00",
"total_outstanding": "936.98",
"total_price_set": { "shop_money": { "amount": "936.98", "currency_code": "USD" }, "presentment_money": { "amount": "936.98", "currency_code": "USD" } },
"created_at": "2025-06-03T04:56:43+00:00",
"updated_at": "2025-06-03T04:56:43+00:00",
"processed_at": "2025-06-03T04:56:43+00:00",
"cancelled_at": null,
"closed_at": null,
"billing_address": {
"first_name": "Jane",
"last_name": "Doe",
"name": "Jane Doe",
"address1": "1 Example Street",
"address2": null,
"city": "Phoenix",
"province": "AZ",
"country": null,
"country_code": "US",
"zip": "85001",
"phone": "+1-555-0100"
},
"shipping_address": { /* same shape */ },
"customer": {
"id": 5794,
"email": "[email protected]",
"first_name": "Jane",
"last_name": "Doe",
"state": "enabled",
"verified_email": true,
"currency": "USD"
},
"line_items": [
{
"id": 30219,
"variant_id": 95589,
"product_id": 112238,
"title": "Reloop Terminal Mix 8",
"variant_title":null,
"name": "Reloop Terminal Mix 8",
"sku": "RELOOP_TERMINALMIX8_025-DEF",
"quantity": 3,
"price": "299.00",
"fulfillable_quantity": 3,
"fulfillment_service": "manual",
"fulfillment_status": null,
"requires_shipping": true,
"taxable": false,
"tax_lines": []
},
{
"id": 30220,
"variant_id": 33857,
"product_id": 51706,
"title": "Premium Skateboard Socks",
"variant_title":null,
"name": "Premium Skateboard Socks",
"sku": "SK8-SOCK-027-DEF",
"quantity": 2,
"price": "19.99",
"fulfillable_quantity": 2,
"fulfillment_service": "manual",
"fulfillment_status": null,
"requires_shipping": true,
"taxable": false,
"tax_lines": []
}
],
"shipping_lines": [{
"id": 0,
"title": "Free Shipping",
"code": "flat_rate",
"source": "shopify",
"price": "0.00"
}],
"tax_lines": [],
"discount_codes": [],
"discount_applications":[],
"fulfillments": [],
"refunds": []
}
}
Caveats integrators should know
| Field | Observed | Why |
|---|---|---|
total_outstanding |
"936.98" for a paid order |
Computed from OrderPayment audit log; legacy orders predating the log show their full total as outstanding. |
gateway |
"payid" |
This is Stoar's gateway-key — not a Shopify-canonical name like shopify_payments. |
customer_locale, device_id, app_id |
always null |
Not modelled on Stoar Order. |
tax_lines |
[] even when total_tax > 0 |
Stoar tracks tax at the order level only, not per jurisdiction; we'd synthesise a single tax_line if needed. |
fulfillments, discount_applications |
always [] |
No fulfilment tracking; coupons modelled as flat discount_codes only. |
tags |
always "" |
No tag/label system in Stoar. |
token |
per-order magic-link token | Never expose to client-side code or logs — possession of the token is sufficient to view the order without auth. |
order_number |
id + 1000 |
Cosmetic — Shopify defaults all stores to base 1000. |
Error shape #
Shopify uses a loose envelope:
{ "errors": "Not Found" } // string
{ "errors": { "title": ["can't be blank"] } } // map (validation)
| HTTP | Trigger | Body |
|---|---|---|
| 401 | missing or invalid token | { "errors": "[API] Invalid API key or access token …" } |
| 403 | wrong-ability token | { "errors": "Forbidden" } |
| 404 | unknown resource id | { "errors": "Not Found" } |
| 422 | precondition failed (cancel non-cancellable, etc.) | { "errors": "Order N cannot be cancelled in status 'delivered'." } |
| 429 | rate-limit exceeded | { "errors": "Exceeded 2 calls per second for api client. …" } + Retry-After: 2 |
| 500 | unexpected server error | { "errors": "Internal Server Error" } |
Validation errors (only cancel.json accepts a body) use the field-keyed map shape.
Rate limiting #
Same api-rest Sanctum-token-keyed throttle that protects Magento and WooCommerce protects Shopify routes. Configure both globally at /manager/api-settings. When triggered, the response uses Shopify's error envelope:
HTTP/1.1 429 Too Many Requests
Retry-After: 2
Content-Type: application/json
{ "errors": "Exceeded 2 calls per second for api client. Reduce request rates to resume uninterrupted service." }
The Retry-After header value matches Shopify's documented behaviour. Real Shopify uses a leaky-bucket algorithm; STOAR uses a sliding-window minute-bucket — close enough for client-library compatibility.
A single Sanctum token used across all four adapters (Magento + WC + Shopify + BC) shares one bucket — see Magento's Rate limiting section for the full configuration UI walkthrough.
See also #
app/Api/Adapters/Shopify/Support/StatusTranslator.php— bidirectional vocabulary mappingapp/Api/Adapters/Shopify/Support/PageInfoCursor.php— opaque base64 cursor encoding- Shopify Admin REST API reference — the upstream spec this adapter mirrors