# Ekx Shopify

> Shopify Admin API integration — private app tokens, GraphQL Admin queries, inventory and order sync, webhooks with HMAC verification, and API version pinning. Use when syncing products or stock, reading orders or sales figures, handling a Shopify webhook, or reconciling Shopify data against Supabase.

- Skill: `ekinoxis-evm/ekx-shopify` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ekinoxis-evm/ekx-shopify`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ekinoxis-evm/ekx-shopify/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: Ekinoxis-evm (https://skillmd.com/u/ekinoxis-evm)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/ekinoxis-evm/ekx-shopify

---


# 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

```bash
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.

```ts
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:

```graphql
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:

```ts
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`](../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

1. **Unpinned API version** breaks quarterly.
2. **`userErrors` ignored** → silent no-op mutations.
3. **`total_price` vs `current_subtotal_price`** — wrong sales numbers.
4. **Inventory is per-location.** A single-location assumption breaks on the second store.
5. **Webhook HMAC on parsed body** fails. Use raw.
6. **Rate-limit cost, not request count.**

