Shopify
The commerce backbone behind our storefronts and store operations.
There are bundled shopify-admin / shopify-dev skills in this installation — those are
authoritative for API detail. This file records our integration patterns.
Environment
SHOPIFY_STORE_DOMAIN= # your-store.myshopify.com
SHOPIFY_ADMIN_ACCESS_TOKEN= # SECRET shpat_…
SHOPIFY_API_VERSION= # e.g. 2025-07 — PIN IT
SHOPIFY_CLIENT_ID=
SHOPIFY_CLIENT_SECRET= # SECRET
SHOPIFY_WEBHOOK_SECRET= # SECRET
⚠️ Both SHOPIFY_ADMIN_ACCESS_TOKEN and SHOPIFY_ADMIN_TOKEN are in circulation.
Standardise on SHOPIFY_ADMIN_ACCESS_TOKEN.
Pin the API version. Shopify deprecates quarterly. An unpinned client silently follows the newest version and breaks on a schedule you do not control.
Admin GraphQL
REST Admin is legacy; use GraphQL for everything new.
async function shopify<T>(query: string, variables?: object): Promise<T> {
const res = await fetch(
`https://${process.env.SHOPIFY_STORE_DOMAIN}/admin/api/${process.env.SHOPIFY_API_VERSION}/graphql.json`,
{
method: "POST",
headers: {
"X-Shopify-Access-Token": process.env.SHOPIFY_ADMIN_ACCESS_TOKEN!,
"Content-Type": "application/json",
},
body: JSON.stringify({ query, variables }),
},
);
const json = await res.json();
if (json.errors) throw new Error(JSON.stringify(json.errors));
// userErrors live INSIDE data on mutations — check them too
return json.data;
}
Two error channels. Top-level errors (malformed query) and per-mutation
userErrors (business rejection). A mutation can return HTTP 200 with errors: null
and still have done nothing. Always check both.
The sales metric
In live, NET sales = Shopify current_subtotal_price. Not total_price (includes
tax and shipping), not subtotal_price (pre-refund). Every ranking, commission and
contest in that app depends on this one field — do not substitute another.
Inventory
Stock lives on InventoryLevel, per location, not on the variant. Set it absolutely
rather than by delta:
mutation ($input: InventorySetOnHandQuantitiesInput!) {
inventorySetOnHandQuantities(input: $input) {
userErrors { field message }
}
}
Our multi-channel setup treats Supabase as the source of truth across three channels (Toast POS, Shopify, pop-up) and pushes corrections to Shopify. That direction matters: two systems both believing they own stock produces drift that nobody can reconcile.
Webhooks
Verify the HMAC on the raw body:
const raw = await req.text();
const digest = crypto
.createHmac("sha256", process.env.SHOPIFY_WEBHOOK_SECRET!)
.update(raw, "utf8").digest("base64");
if (!crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(req.headers.get("x-shopify-hmac-sha256")!))) {
return new Response("bad hmac", { status: 401 });
}
Raw body, timingSafeEqual, respond 200 fast — same discipline as Stripe
(../ekx-stripe/SKILL.md). Shopify retries for 48 hours and
disables an endpoint that keeps failing.
Useful topics: orders/create, orders/updated, refunds/create,
inventory_levels/update, products/update.
Rate limits
GraphQL uses a cost-based leaky bucket (1000 points, refills 50/s). A big query
costs more than a small one; the response includes extensions.cost with your
remaining budget. Read it and back off rather than retrying blindly.
Use bulkOperationRunQuery for full catalog or order-history exports — it runs
asynchronously and returns a JSONL file, and is the only sane way to pull a year of orders.
Gotchas
- Unpinned API version breaks quarterly.
userErrorsignored → silent no-op mutations.total_pricevscurrent_subtotal_price— wrong sales numbers.- Inventory is per-location. A single-location assumption breaks on the second store.
- Webhook HMAC on parsed body fails. Use raw.
- Rate-limit cost, not request count.