Integrations No. 419

Shopify Apps skill for Claude Code

GraphQL-first Shopify apps that pass review. Keeps Claude on the current Shopify platform: CLI-scaffolded apps configured in shopify.app.toml, GraphQL Admin calls with pinned versions and userErrors checks, bulk operations for big syncs, and webhooks verified against raw bytes and deduped by webhook id. It treats REST calls and Shopify Scripts as migration debt and never ships an app missing the three compliance webhooks.

  • nameshopify-apps
  • size257 lines · ≈2,800 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds and maintains Shopify apps on the current platform - Shopify CLI scaffolding, shopify.app.toml config, embedded admin auth with session tokens, GraphQL Admin API queries and bulk operations, HMAC-verified webhooks including mandatory compliance topics, theme and checkout extensions, Shopify Functions, and Polaris UI. Use when creating or debugging a Shopify app, migrating off the REST Admin API or Shopify Scripts, adding webhooks or metafields, or hitting GraphQL throttling.

Try asking

  • Add an orders/create webhook to my Shopify app and make sure it is verified properly
  • Migrate these REST Admin API product calls to GraphQL
  • Sync all 80k products from a store into our database without getting throttled

Use it when

  • Building or extending an embedded Shopify admin app
  • Migrating REST Admin API calls or Shopify Scripts to current APIs
  • Webhooks are failing HMAC checks or arriving duplicated
  • Large catalog syncs are hitting GraphQL cost throttling

Skip it when

  • You are designing generic webhook infrastructure across many providers - use webhook-handling instead
  • The work is a headless storefront UI with no app - use nextjs-app-router instead

How to check the result

Install on a dev store with shopify app dev, trigger each webhook topic and confirm 200, send a tampered body and confirm 401, then uninstall and confirm sessions are deleted.

The common mistake it prevents

Running a JSON body parser before HMAC verification re-serializes the payload, so every genuine Shopify webhook fails the signature check.

~/.claude/skills/shopify-apps/SKILL.md

--- name: shopify-apps description: Builds and maintains Shopify apps on the current platform - Shopify CLI scaffolding, shopify.app.toml config, embedded admin auth with… license: MIT ---

A Shopify app is done when it installs cleanly on a dev store, every Admin call goes through GraphQL with a pinned API version, every webhook is HMAC-verified and answered within 5 seconds, the three compliance webhooks work, and uninstall leaves no orphaned access tokens.

Ground rules

  • Use the GraphQL Admin API. The REST Admin API is legacy (since October 2024) and new public apps submitted from April 2025 must be GraphQL-only. Treat any REST call in an existing app as migration debt.
  • Pin an API version (YYYY-MM, released quarterly, each supported about 12 months). Put it in one constant and in shopify.app.toml. Upgrade deliberately, not by drift.
  • Never put the client secret or Admin access tokens in frontend code, git, or logs. Read them from env (SHOPIFY_API_KEY, SHOPIFY_API_SECRET) or the platform’s secret store.
  • Request the minimum scopes. Every extra scope is a review question and a merchant trust cost; adding a scope later forces merchants to re-approve.
  • Store offline sessions per shop in a durable DB (Prisma/Postgres/Redis session storage), never in memory.

Workflow

- [ ] 1. Identify app type and surface (embedded admin, extension-only, custom app)
- [ ] 2. Inspect existing config: shopify.app.toml, scopes, API version, session storage
- [ ] 3. Implement Admin API work in GraphQL with cost awareness
- [ ] 4. Declare and verify webhooks (incl. compliance topics)
- [ ] 5. Build UI: App Bridge + Polaris, or extensions
- [ ] 6. Test on a dev store with `shopify app dev`
- [ ] 7. Deploy config and extensions with `shopify app deploy`

1. Choose the surface

NeedBuild
Merchant settings/dashboards inside adminEmbedded app (React Router template; older apps use the Remix template)
Storefront widget merchants place in themesTheme app extension (app block or app embed)
Custom discount, shipping, payment, cart, or checkout validation logicShopify Function (Wasm; JS or Rust)
UI inside checkout / thank-you / order statusCheckout UI extension (checkout steps need Plus)
Action or block inside admin resource pagesAdmin action / block extension
One merchant, no App StoreCustom app created from the Dev Dashboard / Partner Dashboard
Replacing Shopify Scripts or checkout.liquidFunctions + checkout UI extensions (Scripts are being retired; plan migration now)

2. Inspect before changing

shopify version
cat shopify.app.toml                     # client_id, scopes, webhooks api_version, URLs
grep -rn "apiVersion\|ApiVersion" app/   # pinned version in code
grep -rn "/admin/api/.*\.json" .         # REST calls still to migrate
shopify app env show                     # values the CLI injects locally

Scaffold new work with the CLI rather than by hand:

shopify app init                          # pick the current template
shopify app dev                           # tunnel + dev store install + hot reload
shopify app generate extension            # theme, checkout UI, function, admin extensions
shopify app deploy                        # pushes app config + extension versions
shopify app config link                   # bind local toml to an app in Partners

3. GraphQL Admin API

In the React Router/Remix templates, authenticate.admin(request) validates the session token and returns an Admin client:

// app/routes/app.products.tsx
import { authenticate } from "../shopify.server";

export async function loader({ request }) {
  const { admin } = await authenticate.admin(request);
  const res = await admin.graphql(
    `#graphql
    query Products($first: Int!, $after: String) {
      products(first: $first, after: $after, query: "status:active") {
        nodes { id title handle totalInventory }
        pageInfo { hasNextPage endCursor }
      }
    }`,
    { variables: { first: 50, after: null } },
  );
  const { data, errors } = await res.json();
  if (errors?.length) throw new Error(JSON.stringify(errors));
  return data.products;
}

Rules for the Admin API:

  • IDs are GIDs: gid://shopify/Product/123. Do not strip them to integers internally; store the GID.
  • Paginate with cursors (first/after, pageInfo.endCursor). Max page size is 250.
  • Rate limiting is cost-based (leaky bucket, restore rate depends on the merchant’s plan). Read extensions.cost.throttleStatus (currentlyAvailable, restoreRate) and back off when a THROTTLED error arrives; do not retry immediately in a loop.
  • Mutations return userErrors { field message } with HTTP 200. Always check userErrors, not just errors.
  • Products use the current product model: create the product, then manage variants with productVariantsBulkCreate / productVariantsBulkUpdate, options with productOptionsCreate. Older single-call variant patterns are removed in recent versions.
  • For more than a few thousand records, use bulk operations instead of paging:
mutation {
  bulkOperationRunQuery(query: """
    { products { edges { node { id title variants { edges { node { id sku } } } } } } }
  """) {
    bulkOperation { id status }
    userErrors { field message }
  }
}

Then subscribe to the bulk_operations/finish webhook (or poll currentBulkOperation) and download the JSONL from url. Child rows reference parents via __parentId. Bulk mutations use bulkOperationRunMutation with a staged upload of a JSONL file.

  • Store app data on Shopify objects with metafields; use app-reserved namespaces ($app:...) so merchants and other apps cannot overwrite them. Define metafield and metaobject definitions in shopify.app.toml when the template supports it.

4. Webhooks

Declare subscriptions in config so they deploy with the app version:

# shopify.app.toml
[webhooks]
api_version = "2025-10"

  [[webhooks.subscriptions]]
  topics = ["app/uninstalled"]
  uri = "/webhooks/app/uninstalled"

  [[webhooks.subscriptions]]
  topics = ["orders/create", "products/update"]
  uri = "/webhooks/commerce"

  [[webhooks.subscriptions]]
  compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"]
  uri = "/webhooks/compliance"

Handler with the template helper (verifies HMAC, parses topic/shop/payload):

export async function action({ request }) {
  const { topic, shop, payload, webhookId } = await authenticate.webhook(request);
  // returns 401 automatically if the HMAC is invalid
  await enqueue({ topic, shop, payload, webhookId }); // do real work async
  return new Response(null, { status: 200 });
}

Without the helper, verify manually against the raw body:

import crypto from "node:crypto";

function verifyShopifyWebhook(rawBody: Buffer, hmacHeader: string | null): boolean {
  if (!hmacHeader) return false;
  const digest = crypto
    .createHmac("sha256", process.env.SHOPIFY_API_SECRET!)
    .update(rawBody)
    .digest("base64");
  const a = Buffer.from(digest);
  const b = Buffer.from(hmacHeader);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// header: X-Shopify-Hmac-Sha256; also read X-Shopify-Topic, X-Shopify-Shop-Domain,
// X-Shopify-Webhook-Id (dedupe key), X-Shopify-API-Version

Webhook rules:

  • Verify on the raw bytes. A JSON body parser that re-serializes breaks the HMAC.
  • Respond 200 within 5 seconds; push work to a queue. Shopify retries failed deliveries (multiple attempts over several hours) and removes subscriptions that keep failing.
  • Delivery is at-least-once and unordered. Dedupe on X-Shopify-Webhook-Id; compare updated_at before overwriting newer state.
  • app/uninstalled: delete the shop’s sessions/tokens. shop/redact arrives ~48 hours after uninstall: erase shop data. customers/redact and customers/data_request: act on them and log completion. Public apps fail review without these.
  • Webhooks are not a reconciliation mechanism. Run a periodic sync job for critical data.

To test signature handling locally, run python3 scripts/verify_shopify_hmac.py --secret-env SHOPIFY_API_SECRET --body body.json --hmac <header> (also supports --query for OAuth callback and app proxy signatures).

5. UI

  • Embedded apps load App Bridge from Shopify’s CDN with the shopify-api-key meta tag; the global shopify object provides toasts, modals, navigation, and shopify.idToken(). Do not bundle old @shopify/app-bridge v3 action patterns into new code.
  • Embedded auth uses session tokens plus token exchange (Shopify-managed installation). Never rely on third-party cookies or redirect-based OAuth inside the iframe.
  • Use Polaris (web components are the current direction; @shopify/polaris React exists in many apps) so the app passes “Built for Shopify” design expectations.
  • Theme app extensions: ship CSS/JS in the extension assets/, keep blocks small, and never inject script tags into themes via the Asset API.

6. Shopify Functions

  • Generate with shopify app generate extension and choose the function type.
  • Input is defined by input.graphql; output must match the function API schema. Functions have strict instruction-count and size limits: keep logic data-driven via metafields rather than hardcoded tables.
  • Test with shopify app function run against saved input JSON before deploying.

Gotchas

  • shopify app dev updates URLs in the Partner config for the dev app; never point it at the production app’s config.
  • Access scope changes only take effect after the merchant re-authorizes. Detect missing scopes and redirect, rather than failing silently.
  • Shop domain inputs (?shop=) must match ^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$ before use, or you open a redirect/SSRF hole.
  • GraphQL query: search strings use Shopify search syntax and are eventually consistent; a just-created product may not appear in a filtered search for a few seconds.
  • Money fields are strings with currency (MoneyV2); never parse them to floats for math.
  • Billing: use appSubscriptionCreate (or managed pricing); test charges need test: true on dev stores. Merchants must approve via the returned confirmationUrl.

Verification

  1. Install on a fresh dev store via shopify app dev; confirm the embedded page loads without auth loops.
  2. shopify app webhook trigger (pick topic and address) and confirm a 200 plus the job runs; send a tampered body and confirm 401.
  3. Uninstall and reinstall: sessions deleted on uninstall, fresh token on reinstall.
  4. Grep for REST: grep -rn "/admin/api/.*\.json" . returns nothing in new code.
  5. Log extensions.cost for the heaviest query and confirm it stays well under the single-query limit.

Output contract

When adding a feature, report:

**Surface:** embedded route /app/bundles + product metafield definition
**Scopes:** added write_products (merchants must re-approve)
**API version:** 2025-10 (pinned in shopify.app.toml and shopify.server.ts)
**Webhooks:** products/update -> /webhooks/commerce (HMAC verified, deduped on webhook id)
**Tested:** dev store install, webhook trigger, tampered-HMAC 401, uninstall cleanup
**Follow-ups:** migrate 3 remaining REST calls in app/lib/legacy.ts

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/verify_shopify_hmac.py 90 lines · 3.2 KB
#!/usr/bin/env python3
"""Verify Shopify signatures locally (stdlib only).

Modes:
  Webhook:   --body FILE --hmac <X-Shopify-Hmac-Sha256 header value>
  OAuth:     --query "<raw query string from the install/callback URL>"
  App proxy: --query "<raw query string>" --proxy

The secret is read from an environment variable (default SHOPIFY_API_SECRET) so it
never appears in shell history.

Exit code 0 = valid, 1 = invalid, 2 = usage error.
"""
import argparse
import base64
import hashlib
import hmac
import os
import sys
from urllib.parse import parse_qsl


def webhook_digest(secret: bytes, body: bytes) -> str:
    return base64.b64encode(hmac.new(secret, body, hashlib.sha256).digest()).decode()


def oauth_digest(secret: bytes, query: str) -> tuple[str, str]:
    pairs = parse_qsl(query, keep_blank_values=True)
    provided = next((v for k, v in pairs if k == "hmac"), "")
    msg = "&".join(
        f"{k}={v}" for k, v in sorted((k, v) for k, v in pairs if k not in ("hmac", "signature"))
    )
    return hmac.new(secret, msg.encode(), hashlib.sha256).hexdigest(), provided


def proxy_digest(secret: bytes, query: str) -> tuple[str, str]:
    pairs = parse_qsl(query, keep_blank_values=True)
    provided = next((v for k, v in pairs if k == "signature"), "")
    grouped: dict[str, list[str]] = {}
    for k, v in pairs:
        if k != "signature":
            grouped.setdefault(k, []).append(v)
    msg = "".join(f"{k}={','.join(vs)}" for k, vs in sorted(grouped.items()))
    return hmac.new(secret, msg.encode(), hashlib.sha256).hexdigest(), provided


def main() -> int:
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("--secret-env", default="SHOPIFY_API_SECRET", help="env var holding the app client secret")
    p.add_argument("--body", help="file containing the exact raw webhook body")
    p.add_argument("--hmac", help="X-Shopify-Hmac-Sha256 header value")
    p.add_argument("--query", help="raw query string (OAuth or app proxy)")
    p.add_argument("--proxy", action="store_true", help="treat --query as an app proxy request")
    a = p.parse_args()

    secret = os.environ.get(a.secret_env)
    if not secret:
        print(f"error: env var {a.secret_env} is not set", file=sys.stderr)
        return 2

    key = secret.encode()
    if a.body:
        if not a.hmac:
            print("error: --body requires --hmac", file=sys.stderr)
            return 2
        with open(a.body, "rb") as f:
            expected = webhook_digest(key, f.read())
        provided = a.hmac.strip()
        kind = "webhook"
    elif a.query:
        query = a.query.lstrip("?")
        expected, provided = proxy_digest(key, query) if a.proxy else oauth_digest(key, query)
        kind = "app proxy" if a.proxy else "oauth"
    else:
        p.print_usage(sys.stderr)
        return 2

    if provided and hmac.compare_digest(expected, provided):
        print(f"VALID {kind} signature")
        return 0
    print(f"INVALID {kind} signature")
    print(f"  expected: {expected}")
    print(f"  provided: {provided or '(missing)'}")
    if kind == "webhook":
        print("  hint: the body must be the exact raw bytes; re-serialized JSON will not match")
    return 1


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc