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.
- name
shopify-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.
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 inshopify.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
| Need | Build |
|---|---|
| Merchant settings/dashboards inside admin | Embedded app (React Router template; older apps use the Remix template) |
| Storefront widget merchants place in themes | Theme app extension (app block or app embed) |
| Custom discount, shipping, payment, cart, or checkout validation logic | Shopify Function (Wasm; JS or Rust) |
| UI inside checkout / thank-you / order status | Checkout UI extension (checkout steps need Plus) |
| Action or block inside admin resource pages | Admin action / block extension |
| One merchant, no App Store | Custom app created from the Dev Dashboard / Partner Dashboard |
| Replacing Shopify Scripts or checkout.liquid | Functions + 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 aTHROTTLEDerror arrives; do not retry immediately in a loop. - Mutations return
userErrors { field message }with HTTP 200. Always checkuserErrors, not justerrors. - Products use the current product model: create the product, then manage variants
with
productVariantsBulkCreate/productVariantsBulkUpdate, options withproductOptionsCreate. 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 inshopify.app.tomlwhen 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; compareupdated_atbefore overwriting newer state. app/uninstalled: delete the shop’s sessions/tokens.shop/redactarrives ~48 hours after uninstall: erase shop data.customers/redactandcustomers/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-keymeta tag; the globalshopifyobject provides toasts, modals, navigation, andshopify.idToken(). Do not bundle old@shopify/app-bridgev3 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/polarisReact 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 extensionand 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 runagainst saved input JSON before deploying.
Gotchas
shopify app devupdates 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 needtest: trueon dev stores. Merchants must approve via the returnedconfirmationUrl.
Verification
- Install on a fresh dev store via
shopify app dev; confirm the embedded page loads without auth loops. shopify app webhook trigger(pick topic and address) and confirm a 200 plus the job runs; send a tampered body and confirm 401.- Uninstall and reinstall: sessions deleted on uninstall, fresh token on reinstall.
- Grep for REST:
grep -rn "/admin/api/.*\.json" .returns nothing in new code. - Log
extensions.costfor 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
#!/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.