8 min read

Shipping a Shopify App — the 33 scopes that pass review and the GDPR webhooks that don't

Most Shopify Apps die in admin-UI scope rejection or in GDPR webhook misconfiguration. The validated 33-scope unified Custom App set, the three GDPR webhook handlers, and the auth-hub pattern that makes a Shopify SaaS multi-tenant by default.

  • Shopify
  • Multi-tenant SaaS
A Shopify-bag silhouette holding 33 scope dots with three GDPR webhook arrows

Shopify’s App Store admin UI silently rejects roughly a third of OAuth scopes you might request — the rejected list isn’t documented and discovering it takes days of trial-and-error. The validated 33-scope unified Custom App set, three GDPR webhook handlers (customers.data_request, customers.redact, shop.redact), and a multi-tenant auth-hub repo pattern (auth.<slug>.$.jsx) are the three pieces that turn “I have a Shopify app idea” into “I have a Shopify App Store listing earning $29–$99/mo per merchant.”

Why most Shopify Apps die in review

Shopify’s App Store has roughly 8,000 apps; the long tail churns hard. The three most common reasons a new app fails review:

  1. Scope rejection. You request read_orders when the app only needs read_orders.last_60_days (which is a different scope). The admin UI rejects but the error message is generic. Most developers iterate three or four times before they find a scope set that the admin UI accepts.
  2. GDPR webhook missing or misimplemented. Every app is required to handle three GDPR webhooks: customers/data_request, customers/redact, and shop/redact. The actual implementation must respond with 200 OK within 5 seconds and must be HMAC-verified — most reference implementations skip the HMAC check.
  3. Multi-tenancy gone wrong. Shopify Apps install per-shop; storing the shop’s access token, the shop domain, and the shop’s preferences in a way that scales to thousands of installs is a non-trivial schema decision that most apps get wrong the first time.

Each of these is a 1–3 day fix. Combined, they’re roughly a 2-week delay to launch. The boilerplate that ships with the AI SEO Agent collapses all three to a configuration step.

The 33-scope unified Custom App set

The accepted scopes for an App Store-targeted Shopify app fall into 6 categories:

CategoryExample scopesWhy these specifically
Storefront contentread_content, write_content, read_themes, write_themes, read_files, write_filesRequired for any app that reads or writes blog posts, theme settings, or media assets
Products + inventoryread_products, write_products, read_inventory, write_inventory, read_product_listings, write_product_listingsRequired for any product-touching app
Orders + fulfillmentread_orders, read_all_orders, read_fulfillments, write_fulfillments, read_shipping, write_shippingread_all_orders is required for orders older than 60 days
Customersread_customers, write_customers, read_marketing_events, write_marketing_eventsCustomer scopes trigger extra GDPR review
Discounts + pricingread_discounts, write_discounts, read_price_rules, write_price_rules, read_gift_cards, write_gift_cardsDiscount + price-rule scopes are 2 different sets — both needed
Metaobjects + metafieldsread_metaobjects, write_metaobjects, read_metafields, write_metafieldsRequired for any SEO app writing schema markup or product structured data

That’s the unified 33. The rejected list — scopes the admin UI silently bounces — includes read_locales, read_shopify_payments_disputes, read_translations (use a different scope name), and a handful of Plus-only scopes that App Store-targeted apps shouldn’t request.

The boilerplate ships the unified set as a single shopify.app.toml block. Drop into your repo, install, and you’ll pass the admin-UI scope check on first submission.

The three GDPR webhooks that actually pass review

The implementation pattern that works:

// pseudocode shape — see the boilerplate for the real handler
export async function POST(request: Request) {
  const hmac = request.headers.get('x-shopify-hmac-sha256');
  const body = await request.text();
  if (!verifyHmac(body, hmac, process.env.SHOPIFY_API_SECRET!)) {
    return new Response('Unauthorized', { status: 401 });
  }
  const payload = JSON.parse(body);
  // Route by webhook topic
  switch (request.headers.get('x-shopify-topic')) {
    case 'customers/data_request':
      await queueDataExport(payload);
      return new Response('OK', { status: 200 });
    case 'customers/redact':
      await deleteCustomerData(payload.customer.id, payload.shop_id);
      return new Response('OK', { status: 200 });
    case 'shop/redact':
      await deleteAllShopData(payload.shop_id);
      return new Response('OK', { status: 200 });
  }
  return new Response('Unknown topic', { status: 422 });
}

The non-obvious requirements:

  • Respond within 5 seconds. If your handler takes longer (e.g., synchronous database deletes for a large shop), respond 200 immediately and queue the work async. Shopify retries on timeout; you’ll end up triple-deleting.
  • HMAC verification is mandatory. Apps that skip it pass review by accident sometimes, but get suspended later when an automated review flags it.
  • Idempotency. Webhooks can retry; your delete should be a no-op the second time around.

The boilerplate ships these three handlers with a worker-queue pattern (Cloud Run + Cloud Tasks, or BullMQ + Redis) that responds 200 immediately and handles the actual deletion async.

The multi-tenant auth-hub pattern

The non-obvious architectural decision is how to store per-shop credentials and route requests to the right shop’s data. The pattern that works at scale:

auth.<your-domain>.com/<shop-slug>/$.jsx

single OAuth-callback endpoint

write { shop, access_token, scopes_granted, installed_at }
to tenants table keyed by shop_id

all subsequent shop API calls pull from
withTenantCredentials(shop_id, fn) wrapper

Three things this pattern gets right:

  • One OAuth endpoint, N shops. You don’t deploy a new instance per shop. The same code base serves all installed shops; tenant context is request-scoped.
  • Encrypted at rest. The access_token in the database is encrypted with a KMS key — even if the DB is compromised, the tokens aren’t immediately usable.
  • Per-request credential resolution. Every API call to Shopify goes through withTenantCredentials(shop_id, fn) which decrypts on the way in. No raw tokens in application memory.

The Glitch Grow AI SEO Agent boilerplate uses this exact pattern. It’s the same pattern most production multi-tenant SaaS apps converge on; the boilerplate just ships it with the GDPR webhooks and Billing API integration pre-wired.

What the Billing API integration adds

Shopify’s Billing API charges merchants through Shopify’s own checkout — you don’t need a Stripe relationship with the merchant. Two pricing patterns work:

  • Recurring application charge. Monthly fee at $29/$49/$99 tiers. Shopify takes a 20% cut on revenue above $1M lifetime per app (its newer terms, often called Shopify’s “App Store 0% under $1M” policy).
  • Usage charge with cap. Per-action pricing (e.g., $0.05 per audit) with a monthly cap. Better for variable-volume use cases like SEO audits or AI generations.

The boilerplate ships both subscription-tier and usage-charge endpoints, plus the cap-enforcement logic that prevents accidental overages.

Where this approach doesn’t fit

Not every Shopify app should be a multi-tenant SaaS:

  • A one-off custom app for a single store. Use the same boilerplate but skip the auth-hub pattern; install as a Custom App (private to the store).
  • Apps that need Plus-only scopes. The unified 33 don’t cover all Plus features. If your app needs read_shopify_payments_payouts or similar Plus-exclusive scopes, you’re shipping a Plus-only app — talk to Shopify about a Plus app review track.
  • Apps with embedded payments outside Shopify. If you’re taking payment from merchants for something Shopify Billing won’t process (e.g., physical hardware add-ons), you need a separate billing rail and the App Store rules get more complex.

Frequently asked questions

How long does the App Store review actually take?

First submission: typically 3–7 business days for review, plus 1–2 rounds of revisions. Subsequent updates after the first approval: 1–3 business days. Plan a 3-week buffer between “code ready” and “listed.”

Do I need a Shopify Partner account?

Yes. Free to create at partners.shopify.com. The Partner account is what hosts the listing, receives revenue payouts, and gates the development store access you need for testing.

What’s the actual revenue split?

0% on the first $1M lifetime per Partner account, then 15% above $1M. For most small SaaS, that means the first 18–36 months are 0% Shopify cut — better than App Store / Play Store terms.

Can the same boilerplate ship to App Store and as a Custom App?

Yes. The boilerplate detects install path and skips the App Store-specific pieces (Billing API, listing metadata) when installed as Custom. Same code, two install paths.

What about Shopify Functions and Hydrogen?

Shopify Functions (WASM-based checkout customizations) are a separate runtime; the boilerplate has stubs for the most common Function patterns (shipping rate customization, discount targeting). Hydrogen (headless storefronts) is another runtime entirely — different boilerplate.

Further reading

The 2-week delay between “I have an app idea” and “I have an approved App Store listing” is what kills most indie Shopify apps. Buying the boilerplate is the trade where someone else has already spent those two weeks finding what the admin UI rejects and what the GDPR review wants.

Free Vibe Coder Kit

Get the kit. Ship like a vibe coder.

Installs into Claude Code, Codex, or OpenClaws in under a minute. Required to deploy our paid agents.

Protected by Cloudflare Turnstile. We never share your details. Unsubscribe any time.