Shopify Apps skill for Codex
Admin GraphQL, verified webhooks, safe deploys. Codex builds Shopify apps the way Shopify expects in 2025-2026: configuration in shopify.app.toml, managed installation with session tokens, and Admin GraphQL on a pinned quarterly version. Every mutation checks userErrors, syncs respect cost-based throttling or switch to bulk operations, and webhooks are HMAC-verified on raw bytes with compliance topics in place. Deploys and billing changes wait for explicit approval.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- shopify-apps Installs to ~/.agents/skills/shopify-apps/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install shopify-apps The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project shopify-apps Installs to .agents/skills/shopify-apps/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/shopify-apps.zipunzip -o shopify-apps.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) shopify-apps PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $shopify-apps, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds and maintains Shopify apps with Shopify CLI and the React Router app template - shopify.app.toml config, managed installation and session-token auth, Admin GraphQL API queries and mutations with userErrors and cost-based throttling, bulk operations, webhooks with HMAC verification and mandatory compliance topics, and app extensions (Functions, checkout UI, theme app extensions). Use when asked to build a Shopify app, sync products, orders or inventory, add or debug Shopify webhooks, migrate off the REST Admin API, or write a Shopify Function or checkout extension.
Use it when
- You are building or maintaining a Shopify app with Shopify CLI
- Products, orders, inventory or metafields must sync through the Admin GraphQL API
- Shopify webhooks need verification, deduplication or compliance handling
- You need a Shopify Function, checkout UI extension or theme app extension
Use something else when
- You are taking payments on your own site rather than in Shopify: use stripe-payments
- The store is WordPress-based: use wordpress-plugins
How to check the result
Run shopify app dev against a dev store, trigger each subscribed webhook topic and confirm HMAC passes, and log throttleStatus during a sync to confirm no THROTTLED errors.
The mistake it prevents
Treating a 200 response from a GraphQL mutation as success without reading userErrors, so failed writes go unnoticed.
Done well means: the app is configured as code in shopify.app.toml, talks to a pinned Admin
GraphQL API version, checks userErrors on every mutation, respects query cost, verifies every
webhook HMAC against the raw body, implements the three compliance webhooks, and never deploys
or charges merchants without explicit confirmation.
Inspect first
- [ ] Read AGENTS.md, package.json and shopify.app.toml (client_id, scopes, webhooks, api_version)
- [ ] Note the template (React Router is current; older apps use Remix) and the session storage
- [ ] Check for REST Admin calls (/admin/api/<v>/*.json): new public apps must use GraphQL
- [ ] Find every webhook handler and confirm HMAC verification happens before parsing
- [ ] Confirm which store is the dev store; never point experiments at a live merchant store
Shopify CLI needs network and a Partner/Dev Dashboard login; in an offline Codex sandbox, prepare the changes and ask the user to run CLI commands.
CLI loop
shopify app init # scaffold (choose the React Router template)
shopify app dev # tunnel + dev store install + hot reload
shopify app generate extension # Functions, checkout UI, theme app extension...
shopify app function run # run a Function locally against input JSON
shopify app deploy # creates and releases an app version: confirm first
shopify app deploy pushes config and extensions to every store that has the app. Treat it as
a production release: show the user the diff of shopify.app.toml and extensions and get a
clear yes.
Configuration as code
client_id = "..."
name = "Stock Sync"
application_url = "https://app.example.com"
embedded = true
[access_scopes]
scopes = "read_products,write_inventory,read_orders"
[webhooks]
api_version = "2025-10"
[[webhooks.subscriptions]]
topics = ["app/uninstalled", "app/scopes_update"]
uri = "/webhooks/app"
[[webhooks.subscriptions]]
topics = ["products/update"]
uri = "/webhooks/products"
[[webhooks.subscriptions]]
compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"]
uri = "/webhooks/compliance"
- Scopes are least privilege; every extra scope is shown to the merchant and reviewed in app review. Customer and order fields with personal data need protected customer data access approved in the dashboard, or fields come back null/denied.
- Managed installation: Shopify installs with the scopes in the TOML; embedded apps get a
session token from App Bridge and exchange it for an access token (token exchange). The
template’s
authenticate.admin(request)does this. - API versions are quarterly (
YYYY-MM) and supported for about 12 months. Pin one version for both GraphQL calls and webhooks; upgrade deliberately each quarter after reading the changelog.
Admin GraphQL
// app/routes/app.products.tsx (React Router template)
import { authenticate } from "../shopify.server";
export async function loader({ request }: { request: 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 variants(first: 10) { nodes { id sku inventoryQuantity } } }
pageInfo { hasNextPage endCursor }
}
}`,
{ variables: { first: 50, after: null } },
);
const { data } = await res.json();
return data.products;
}
Mutations return HTTP 200 even when they fail. Always read userErrors:
const res = await admin.graphql(
`#graphql
mutation SetMeta($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields { id key }
userErrors { field message code }
}
}`,
{ variables: { metafields: [{ ownerId: productGid, namespace: "custom", key: "erp_id",
type: "single_line_text_field", value: "ERP-8812" }] } },
);
const { data } = await res.json();
if (data.metafieldsSet.userErrors.length) throw new Error(JSON.stringify(data.metafieldsSet.userErrors));
| Need | Use |
|---|---|
| Create or fully sync a product with variants | productSet (declarative; good for ERP syncs) |
| Add or edit variants | productVariantsBulkCreate / productVariantsBulkUpdate |
| Set inventory | inventorySetQuantities (per location, with reason) |
| Custom data | Metafields (metafieldsSet, up to 25 per call) or metaobjects |
| Read thousands of records | bulkOperationRunQuery, then download the JSONL from url |
| Write thousands of records | bulkOperationRunMutation with a staged JSONL upload |
Rate limits
GraphQL is cost-based: each query has a calculated cost and the shop’s bucket refills at a
plan-dependent rate. Read extensions.cost.throttleStatus (currentlyAvailable,
restoreRate) and sleep before the bucket empties. A single query cannot exceed 1,000 points;
request smaller pages or fewer nested connections. On THROTTLED errors, wait
(requested - available) / restoreRate seconds and retry.
IDs are global IDs (gid://shopify/Product/123). Money values are decimal strings with a
currency; never parse them into floats for arithmetic.
Webhooks
import crypto from "node:crypto";
export function verifyShopifyHmac(rawBody: Buffer, hmacHeader: string, secret: string) {
const digest = crypto.createHmac("sha256", secret).update(rawBody).digest("base64");
const a = Buffer.from(digest), b = Buffer.from(hmacHeader ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
The template’s authenticate.webhook(request) verifies for you; custom servers must read the
raw body before any JSON middleware. Rules:
- Respond 200 within about 5 seconds; enqueue work. Shopify retries failed deliveries with backoff and removes subscriptions that keep failing.
- Deduplicate by
X-Shopify-Webhook-Id; ordering is not guaranteed, so compareupdated_atbefore overwriting newer data. - Payload shape follows the webhook
api_version; changing it is a breaking change for handlers. - Compliance topics are mandatory for public apps: answer data requests and actually delete
data on
customers/redactandshop/redact. app/uninstalled: delete sessions and stop jobs for that shop.
To debug a failing signature from a captured delivery, run
python3 scripts/verify_webhook_hmac.py body.json <hmac-header> <secret-env-var-name>; it
reports whether the raw bytes verify and common causes when they do not. Trigger test events
with shopify app webhook trigger --topic products/update --address <url>.
Extensions
| Goal | Extension | Note |
|---|---|---|
| Custom discounts, shipping/payment rules, cart validation | Shopify Functions (Rust or JS compiled to Wasm) | Run on Shopify, strict instruction and size limits; test with shopify app function run |
| UI in checkout or thank-you/order status pages | Checkout UI extension | Checkout steps beyond thank-you/order status require Plus |
| Storefront widgets | Theme app extension (app blocks, app embeds) | Replaces ScriptTag injection; merchants place blocks in the theme editor |
| Actions inside the admin | Admin UI extensions |
Functions get only the data their input query requests; keep the query minimal to stay under limits.
Billing
Charges use appSubscriptionCreate or appPurchaseOneTimeCreate, returning a
confirmationUrl the merchant must approve. Use test: true on dev stores. Changing plans or
prices for live merchants needs explicit confirmation from the user.
Gotchas
- Inventory lives per location (
InventoryLevel);inventoryQuantityon variants is a total. - Product search
query:syntax is eventually consistent; do not read-after-write through it. - Online vs offline tokens: background jobs need offline tokens; online tokens expire with the user session.
- Embedded apps must not set cookies for auth in iframes; use session tokens.
- Ignoring
app/scopes_updateleaves stored scopes stale after a merchant approves new ones.
Verification
shopify app devon a dev store; exercise each route and mutation, confirmuserErrorsempty.- Trigger each subscribed webhook topic; confirm 200, HMAC verified, idempotent on replay.
- Log
throttleStatusduring a bulk sync and confirm noTHROTTLEDerrors at steady state. - Before
deploy, list scopes and webhook topics in the TOML and get the user’s approval.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Shopify Apps"
short_description: "Admin GraphQL, verified webhooks, safe deploys"
default_prompt: "$shopify-apps Build a Shopify app that syncs inventory from our ERP every 15 minutes" scripts/verify_webhook_hmac.py
#!/usr/bin/env python3
"""Verify a captured Shopify webhook delivery against its X-Shopify-Hmac-Sha256 header.
Usage:
python3 verify_webhook_hmac.py <raw-body-file> <hmac-header-value> <SECRET_ENV_VAR>
The secret is read from the named environment variable so it never appears in shell history.
The body file must contain the exact bytes Shopify sent (not re-serialised JSON).
Exit code 0 when the signature verifies, 1 when it does not, 2 on usage errors.
"""
import base64
import hashlib
import hmac
import json
import os
import sys
def digest(secret: str, body: bytes) -> str:
return base64.b64encode(hmac.new(secret.encode(), body, hashlib.sha256).digest()).decode()
def main() -> int:
if len(sys.argv) != 4:
print(__doc__.strip())
return 2
path, header, env_name = sys.argv[1:]
secret = os.environ.get(env_name)
if not secret:
print(f"error: environment variable {env_name} is empty or unset")
return 2
try:
body = open(path, "rb").read()
except OSError as exc:
print(f"error: cannot read {path}: {exc}")
return 2
expected = digest(secret, body)
if hmac.compare_digest(expected, header.strip()):
print(f"OK: signature verifies for {len(body)} bytes")
return 0
print("FAIL: signature does not match")
print(f" body bytes: {len(body)}")
hints = []
if body.endswith(b"\n"):
trimmed = body.rstrip(b"\r\n")
if hmac.compare_digest(digest(secret, trimmed), header.strip()):
hints.append("matches once the trailing newline is removed: the capture added a newline")
try:
reserialised = json.dumps(json.loads(body), separators=(",", ":")).encode()
if reserialised != body:
hints.append("make sure this file is the raw request body; a framework that parsed and "
"re-serialised the JSON changes whitespace and key order and breaks the HMAC")
except ValueError:
hints.append("body is not valid JSON: the capture may be truncated or decoded")
if b"\r\n" in body:
hints.append("body contains CRLF line endings: it was likely altered by a tool or editor")
hints.append("check you are using the app's client secret (API secret key), not an access token")
hints.append("if the app's secret was rotated recently, try the previous secret")
for h in hints:
print(f" hint: {h}")
return 1
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.