Short answer
The hard parts of a Shopify app are not features — they are OAuth done correctly, the mandatory compliance webhooks, and a cost-based rate limiter that punishes naive querying. Apps fail review for the same short list of reasons every time, and all of them are known in advance.
Building an app that works on your development store is a weekend. Building one that survives review, multiple merchants and a Black Friday is a different exercise.
OAuth and session tokens
Shopify apps use OAuth to obtain a per-shop access token. Two details cause most of the failures.
Verify the HMAC on the callback, using a constant-time comparison. Skipping this means anyone can hit your callback with a shop name of their choosing.
function verifyHmac(query, secret) {
const { hmac, ...rest } = query;
const message = new URLSearchParams(
Object.entries(rest).sort(([a], [b]) => a.localeCompare(b))
).toString();
const digest = crypto.createHmac('sha256', secret).update(message).digest();
const given = Buffer.from(hmac ?? '', 'hex');
// Length check first: timingSafeEqual throws on mismatched lengths.
return given.length === digest.length && crypto.timingSafeEqual(digest, given);
}
Embedded apps run in an iframe inside Shopify admin, where third-party cookies are unreliable. Session tokens — short-lived JWTs from App Bridge — are the supported mechanism. Verify the token’s signature and expiry on every request; do not treat its mere presence as authentication.
Store access tokens encrypted at rest, keyed by shop domain, and delete them when the app is uninstalled.
The webhooks that are not optional
Three GDPR compliance webhooks are mandatory for any public app: customers/data_request, customers/redact and shop/redact. They must be implemented, must verify their HMAC, and must return 401 when verification fails. Apps are rejected for this constantly.
Verification uses the raw request body — parsing it first and re-serialising changes the bytes and the signature will never match:
// The raw body is required. Capture it before any JSON middleware runs.
const digest = crypto
.createHmac('sha256', process.env.SHOPIFY_API_SECRET)
.update(rawBody, 'utf8')
.digest('base64');
if (!crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(hmacHeader))) {
return new Response('Unauthorized', { status: 401 });
}
app/uninstalled matters just as much operationally: without it you keep calling APIs with a revoked token and keep billing a merchant who left.
Beyond that, the rules are the same as any webhook consumer — acknowledge fast, process asynchronously, and deduplicate on the delivery id, because delivery is at-least-once. The full treatment is in Designing Resilient Multi-Platform Data Pipelines.
Rate limits are cost-based
The GraphQL Admin API uses a leaky bucket measured in query cost, not request count. A deeply nested query consumes far more budget than a simple one, so “one request” tells you nothing about what it costs.
Every response includes the current bucket state. Read it and adapt rather than guessing:
const { currentlyAvailable, restoreRate } = body.extensions.cost.throttleStatus;
// Back off before being throttled, not after.
if (currentlyAvailable < 100) {
await sleep(((100 - currentlyAvailable) / restoreRate) * 1000);
}
Request only the fields you use — cost is calculated from the query shape — and bound every connection with first:. An unbounded connection is both expensive and a correctness bug waiting for a large store.
Bulk operations for anything large
Paginating a 50,000-product catalogue through the normal API will exhaust your budget and take hours. The Bulk Operations API runs the query asynchronously and hands back a JSONL file.
mutation {
bulkOperationRunQuery(
query: """{ products { edges { node { id title variants { edges { node { id sku } } } } } } }"""
) { bulkOperation { id status } userErrors { field message } }
}
Poll for completion or, better, subscribe to the bulk_operations/finish webhook. One bulk operation runs per shop at a time, so queue them per shop rather than firing concurrently.
Billing
Charges go through Shopify’s Billing API — you cannot take payment for a Shopify app any other way. Handle the states properly: trial, active, frozen, cancelled. A merchant whose subscription lapsed should lose access gracefully rather than seeing errors, and reinstalling should restore their configuration rather than starting from nothing.
The review checklist that actually blocks listings
- All three GDPR webhooks implemented, verifying HMAC, returning 401 on failure.
- OAuth callback verifying HMAC in constant time.
- Embedded apps using App Bridge session tokens, not cookies.
- No API calls from client-side code with an admin token.
- Uninstall handled: token revoked, data removed on
shop/redact. - Requested scopes minimal and justified — asking for
write_orderswithout needing it invites questions. - Billing implemented through the Billing API.
- The app loads acceptably fast inside admin; performance is assessed.
The storefront-side counterpart to this — building the customer-facing experience against the Storefront API — is covered in Shopify Hydrogen and Custom Storefronts, and the store-scaling concerns in E-Commerce Engineering.
Questions people actually ask
- Why does my webhook HMAC verification always fail?
- Almost certainly because the body was parsed before verification. The signature covers the exact raw bytes, and JSON parsing followed by re-serialisation changes key order and whitespace. Capture the raw body before any body-parsing middleware runs.
- REST or GraphQL for the Admin API?
- GraphQL. Shopify has been consolidating on it, new functionality lands there first, and bulk operations are GraphQL-only. The cost model takes some getting used to, but you are querying against the API’s direction of travel rather than away from it.
- How do I handle a store with 100,000 products?
- Bulk Operations, not pagination. The bulk API runs the query asynchronously and returns a JSONL file without consuming your normal rate budget. Trigger it, wait for the
bulk_operations/finishwebhook, then stream the file – and queue one operation per shop, since only one runs at a time. - Do I need App Bridge?
- For an embedded app, yes – it provides the session tokens that work reliably in an iframe where third-party cookies do not, plus the navigation and modal primitives that make the app feel native to admin. A standalone non-embedded app can skip it, but embedded is what merchants expect.
- What is the most common reason apps fail review?
- Missing or incorrectly implemented GDPR compliance webhooks. All three are required, must verify the HMAC, and must return 401 when it fails. It is entirely mechanical and it is still the most frequent rejection, because teams treat it as paperwork rather than as a tested code path.