Skip to content

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 #

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 id
  • 422 — 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:

  1. Client sends ?limit=N (no page parameter).
  2. Server returns the first N items + a Link: header:
    Link: <…/orders.json?page_info=eyJwIjoyLCJzIjo1LCJmIjoiYWJjMTIzIn0&limit=5>; rel="next"
  3. Client follows the rel="next" URL verbatim — never builds it manually.
  4. Following the link, server decodes page_info to 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) eurEUR
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) activeactive; inactivearchived
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 mapping
  • app/Api/Adapters/Shopify/Support/PageInfoCursor.php — opaque base64 cursor encoding
  • Shopify Admin REST API reference — the upstream spec this adapter mirrors