---
name: shopify-oauth-app
description: Creates or signs in to a Shopify partner/developer organization and registers a Shopify app to obtain OAuth2 client ID and client secret — with the right app type and distribution, redirect URLs, access scopes, protected customer data approval, offline access tokens, HMAC verification, the mandatory compliance webhooks, and a safe credential handoff. Use when asked to get Shopify OAuth credentials, create a Shopify app or partner account, rotate a Shopify client secret, pick between a public and a custom app, or fix a Shopify install error like `redirect_uri is not whitelisted`, `invalid_request`, an unsigned callback, or customer data coming back null. For any other vendor's developer portal, use that vendor's skill instead.
---

# Shopify OAuth2 App Registration

Get a working Shopify OAuth2 client — a developer organization, an app of the right type, redirect URLs, scopes,
client ID and client secret — for a platform that connects many merchants' Shopify stores.

Four things here are expensive to get wrong, and three of them cannot be undone by editing the app later. **The
distribution method is permanent** and decides whether you can serve more than one merchant at all (§1). **Protected
customer data approval** is a separate review from App Store review, and without it every customer name, email,
address and phone comes back `null` rather than erroring (§6). **Expiring offline access tokens** became mandatory
for new public apps on 1 April 2026 and for *all* public apps on 1 January 2027 — a client that stores one
never-expiring token per shop is on a deadline (§7). And **the mandatory compliance webhooks plus HMAC verification**
are review blockers, not hygiene (§8).

## The per-shop model — read this first

Shopify is not single-tenant OAuth with a tenant parameter bolted on. Every part of the flow is scoped to one store:

- The authorize URL is **on the merchant's own host**: `https://{shop}.myshopify.com/admin/oauth/authorize`. There is
  no central `login.shopify.com` to send merchants to.
- The token exchange is on that same host: `POST https://{shop}.myshopify.com/admin/oauth/access_token`.
- The API base is that host too: `https://{shop}.myshopify.com/admin/api/{version}/…`.
- The access token that comes back **is scoped to that one store**, and is sent as `X-Shopify-Access-Token`, not as a
  bearer token.

So "the Shopify credential" is **one client ID and one client secret for the app, plus one access token per store**.
The client secret is also the key for HMAC verification of both the OAuth callback and every webhook (§8). Your
connect flow must therefore collect the shop domain *before* it can even build an authorize URL — that is a UI
requirement, not an implementation detail, and it is the single biggest difference from Salesforce, HubSpot or Google.

## Inputs to collect before you start

Ask in one batch. Never invent.

| Input | Notes |
| --- | --- |
| **App name**, app URL, contact email, icon | Shown to merchants at install and in any listing |
| **Distribution intent** | Many merchants (public) or one store / one Plus org (custom) — permanent (§1) |
| **Which organization owns the app** | Existing partner/dev organization, or a new one (§3) |
| **Redirect URLs** | Every callback host your platform serves (§5) |
| **Scope set** | The exact scope strings the connector calls (§6) |
| **Does the app need customer PII?** | Names, emails, addresses, phones → protected customer data review (§6) |
| **Does the client store a refresh token and refresh it?** | Blocking for new public apps (§7) |
| **Privacy policy URL, data-retention and security answers** | Required by the protected-data request (§6) |
| **Where the compliance webhooks are handled** | `customers/data_request`, `customers/redact`, `shop/redact` (§8) |

## Quick Start

1. Decide **public vs custom distribution** — permanent, and it caps how many stores you can serve (§1).
2. Confirm a **new app** is needed; a new client ID orphans every existing per-shop token (§2).
3. Sign in to, or create, the partner/developer organization that will own the app (§3).
4. Create the app and mark it **not embedded** if it has no Shopify admin UI (§4).
5. Add **every** redirect URL, exactly (§5).
6. Declare the scopes, and file the **protected customer data** request if you touch PII (§6).
7. Confirm the client requests **expiring offline** tokens and stores the refresh token (§7).
8. Confirm HMAC verification on the callback and webhooks, and the three compliance webhooks (§8).
9. Pin and diary an **API version** — it expires in about a year (§9).
10. Capture client ID and secret; the secret is rotate-only, not re-readable (§10).
11. Submit for App Store review if public (§11), then verify end-to-end on a development store (§12).
12. Hand the credentials over — never commit them (§13).

## Platform state (verified 2026-09-20 — re-verify before trusting)

Five changes land directly on this task. Check them at the source before following any older runbook:

- **The Dev Dashboard (`dev.shopify.com/dashboard`) is where apps are now created and configured.** Announced
  21 May 2025 as early access, it is now the hub for creating apps, managing organizations and development stores,
  and generating the credentials an app uses. The Partner Dashboard still exists and still owns commercial concerns
  (payouts, the App Store listing, and the API access requests in §6), so expect to work across both and to find
  older screenshots and click paths in the wrong one. **Verify which dashboard your organization is on before quoting
  a click path.**
- **Expiring offline access tokens are mandatory for new public apps as of 1 April 2026**, and for *all* public apps
  calling the Admin API as of **1 January 2027**. Custom apps and merchant-created apps are unaffected. See §7.
- **Legacy admin-created custom apps can no longer be created as of 1 January 2026.** Existing ones keep working and
  are still managed from the Shopify admin, but new custom apps must be built as Shopify apps with custom
  distribution selected. Any instruction that starts "in the Shopify admin, go to Settings → Apps and sales channels
  → Develop apps" is describing the legacy path. Private apps are older still: they were converted to custom apps on
  20 January 2023 and do not exist as a type any more.
- **The REST Admin API has been legacy since 1 October 2024**, and since **1 April 2025** new public apps submitted to
  the App Store must be built with the **GraphQL Admin API**. REST still answers, but a new public app built on it
  will not pass review. See §9.
- **Distribution is chosen once and cannot be changed.** Public and custom are separate, permanent paths (§1).

If the dashboard does not look like this, stop and report what you actually see rather than clicking on.

## 1. App types and distribution — the permanent choice

Shopify's "app type" is really a **distribution method**, selected once per app and **not changeable afterwards**.

| Type | Who can install | Review | Billing API | Notes |
| --- | --- | --- | --- | --- |
| **Public distribution** | Any merchant, unlimited stores | **Yes** — App Store review | Yes | Can be listed in the Shopify App Store. The only option for a multi-tenant connector. |
| **Custom distribution** | **One store**, or the stores of **one Shopify Plus organization**, via a generated install link | No review | **No** | Fine for one customer; it cannot serve a second unrelated merchant, ever. |
| **Shopify admin (legacy custom)** | One store | No | No | **Cannot be created since 1 Jan 2026.** Existing ones keep working. No App Bridge, no extensions. |
| **Private apps** | — | — | — | Retired; converted to custom apps on 20 Jan 2023. |

**A multi-tenant connector needs public distribution.** That is the answer, and it carries the cost: App Store
review (§11), the mandatory compliance webhooks (§8), protected customer data approval (§6), the GraphQL requirement
(§9) and the expiring-token deadline (§7). Choosing custom distribution to skip review works exactly until the second
customer, at which point the app cannot be converted and a brand-new app — new client ID, every merchant
re-authorizing — is the only way out.

Two practical notes:

- A public app does **not** have to be *listed*. Public distribution is what makes it installable by any merchant;
  publishing a listing in the App Store is a further step. An unlisted public app still goes through app review to
  become installable, so "unlisted" is not a way around review.
- If the user's real requirement is "our own stores only", the **client credentials grant** exists for stores inside
  the app owner's own organization and skips merchant authorization entirely. Say so rather than registering a public
  app for it.

## 2. Decide: reuse the existing app, or register a new one

A new app means a **new client ID and secret**, and **every existing per-shop access token is bound to the old
client**. Every merchant would have to reinstall. Reuse the existing app for: adding a redirect URL, adding or
removing scopes (§6), rotating a compromised secret (§10), requesting protected customer data access (§6), or
diagnosing an install failure.

Register a **new** app only when the user explicitly wants one: a replacement for a compromised app, a separate app
for a separate product, or an unavoidable migration off a custom-distribution or legacy custom app (§1). Say which
path you are taking before you touch anything.

## 3. The organization that owns the app

- **Partner / developer organization** — the account that owns apps and development stores. Start from
  `https://www.shopify.com/partners`; app creation and configuration then happen in the Dev Dashboard at
  `dev.shopify.com/dashboard`.
- **Development stores** — free test stores created from the dashboard. This is what you verify against (§12); you do
  not need a paid store.
- **Plus sandbox stores** — a separate, Plus-only facility. If the user asks for a sandbox that behaves like a real
  Plus store, that is a Shopify Plus entitlement, not something you can provision.
- **Staff permissions** — creating and managing apps requires the app-development permission on the organization.

Hand control back for anything only a human can do: account signup and email verification, accepting the partner
agreement, 2FA enrollment, and anything that asks for business or tax details. Do not retry a blocked step in a loop.

**If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give
the user an exact, ordered click path with the literal values to paste — the redirect URLs from §5 and the scope list
from §6 — then continue once they report back with the client ID.

## 4. Create the app

Two supported routes, and they converge on the same app record:

- **Dev Dashboard** — create the app in the organization, then configure it from the app's settings.
- **Shopify CLI** — scaffold or link an app and configure it from `shopify.app.toml`, then `shopify app deploy` to
  push configuration to all stores. Changes made only during `shopify app dev` apply to your development store and
  **do not reach production until you deploy** — a common reason a redirect URL "was added" and still fails.

The choices that matter:

1. **Distribution** — §1. Permanent.
2. **Embedded or not.** A connector with no UI inside the Shopify admin is **not embedded**. Mark it so. Embedded apps
   are expected to use session tokens and the token exchange grant; a non-embedded app has no ID token to exchange and
   therefore **must** use the authorization code grant described here.
3. **App URL** — the app's own URL. Distinct from the redirect URLs in §5; merchants land here after install.
4. **Access scopes** — declared on the app (§6), not invented per authorize request.
5. **Compliance webhook endpoints** — §8. Configure them now; review will check them.

## 5. Redirect URLs

Register **every** callback host your platform serves. For Unified.to these are one per data center; confirm the
current list with the platform owner rather than assuming:

```
https://api.unified.to/oauth/code          # us (default)
https://api-eu.unified.to/oauth/code       # eu
https://api-au.unified.to/oauth/code       # au
https://api-dev.unified.to/oauth/code      # dev
```

Notes:

- The `redirect_uri` you send must **exactly match** a registered value. No prefix matching, no trailing-slash
  forgiveness. A mismatch fails at install time with a "redirect_uri is not whitelisted" style error, not at save
  time.
- These are **your platform's** hosts. They do not vary per merchant, even though the authorize host does (see “The per-shop model”) — that
  asymmetry is what trips people who expect the callback to be shop-scoped too.
- Redirect URLs can be set in the Dev Dashboard **or** in the `[auth]` section of `shopify.app.toml`. If both exist,
  a deploy from the CLI overwrites the dashboard values. Pick one source of truth and say which.
- Changes may take a few minutes to propagate.

## 6. Scopes and protected customer data

Scopes are **declared on the app** and sent in the authorize URL. Shopify's docs specify a **comma-separated** list in
the `scope` parameter; if your client joins them another way, confirm it against a real authorize round trip rather
than assuming tolerance.

The ones that matter for a commerce/accounting connector:

| Scope | Covers |
| --- | --- |
| `read_products` / `write_products` | Products, variants, collections, selling plans |
| `read_inventory` / `write_inventory` | Inventory items and levels |
| `read_locations` | Locations |
| `read_publications` / `write_publications` | Sales channels / publications |
| `read_orders` / `write_orders` | Orders, fulfillments, transactions, abandoned checkouts — **last 60 days only** |
| `read_all_orders` | Removes the 60-day window. **Requires Shopify approval.** |
| `read_customers` / `write_customers` | Customers and companies — **protected customer data** |
| `read_returns` / `write_returns` | Returns and reverse logistics |
| `read_own_subscription_contracts` | Subscription contracts created by your app |
| `read_users` | Staff users — **Shopify Plus stores only**, and must be enabled by Shopify Support |

Three traps in that table, each of which looks like a bug rather than a permission:

1. **`read_orders` silently truncates to 60 days.** A historical sync returns a partial result with no error.
   `read_all_orders` fixes it and is approval-gated — request it early, with a written justification.
2. **`read_users` is Plus-only and support-gated.** On a non-Plus store the staff endpoint is simply unavailable, so a
   connector that maps employees from Shopify will come back empty for most merchants. That is expected, not broken.
3. **Changing the scope set forces merchants to re-approve.** Adding a scope means every installed merchant is
   prompted again on the next install/update cycle, and until they accept, the new scope is not granted. Scope churn
   is a merchant-visible event — batch it, do not drip it.

### Protected customer data

Any access to customer-related data is gated by a **separate approval**, requested from the Partner Dashboard's API
access section, and independent of App Store review:

- **Level 1** — customer data *excluding* name, address, phone and email. Public apps must request it.
- **Level 2** — data *including* name, address, phone or email fields. Requested field by field, with a heavier data
  protection review.

Protected types include customers, orders, shipping rates, fulfillment data, gift cards, and webhooks and metafields
relating to an individual customer. Shopify approves only "the minimum amount required by your app", so a request that
asks for everything is a request that gets pushed back.

**What unapproved access looks like at runtime is the trap:** requests for unapproved *fields* return **`null`**, and
requests for unapproved *types* return **HTTP 200 with an error message in the body**. No 403. A connector will
happily sync customers whose every name and email is null and report success. If a customer reports "all the PII is
blank", check approval status before debugging the mapper.

Level 1 requires minimum-necessary processing, merchant transparency, opt-out support, data-protection agreements,
retention limits and encryption in transit and at rest. Level 2 adds encrypted backups, separated test and production
environments, a data-loss-prevention strategy, limits and logging on staff access, and a security incident response
policy. Custom and admin-created custom apps bypass the review — though for admin-created apps, Level 2 access
depends on the store's plan.

**The data-protection answers are business commitments, not facts you can derive.** Collect them from the user; do not
draft attestations on their behalf.

## 7. Offline vs online access tokens

| | Offline | Online |
| --- | --- | --- |
| Scoped to | **The store** | The staff member who authorized |
| Lifetime | Non-expiring, or **expiring: 1 hour** with a refresh token | **24 hours, or until that user logs out** — whichever comes first |
| Requested by | The default | `&grant_options[]=per-user` on the authorize URL |
| Right for | Background sync, webhooks, scheduled jobs | Acting as a specific staff member in a UI |

**A background connector needs offline tokens.** Online tokens die with the merchant's admin session — and logging
out revokes *every* online token minted during that web session, not just the current one — so a nightly sync built
on them fails as soon as the merchant closes their laptop. Offline is also the default, so the practical rule is:
do **not** append `grant_options[]=per-user`.

Offline no longer means permanent:

- Add **`expiring=1`** to the token request to get an **expiring offline token**: `expires_in` is `3600` (1 hour),
  returned with a `refresh_token` whose `refresh_token_expires_in` is `7776000` (90 days).
- **New public apps created on or after 1 April 2026 must use expiring offline tokens**; **all public apps must, from
  1 January 2027.** New public apps already cannot use non-expiring tokens for GraphQL Admin API requests.
- Shopify keeps **one current expiring offline token per app and store**. Acquiring a new one retires the older ones,
  though a retired token stays valid until its own `expires_in` elapses so in-flight requests finish.
- If the refresh token's 90 days lapse without use, the merchant has to go through the install flow again.

So the client-side question to ask before registering anything: **does the connector store a refresh token per shop
and refresh proactively?** If the answer is no or unknown, say so plainly. The app will register fine and then fail
after an hour. That is a finding to report, not a step to work around.

## 8. HMAC verification and the mandatory compliance webhooks

Both use the **client secret** as the HMAC key, which is why the secret is an operational dependency and not just a
login credential.

**The OAuth callback.** Shopify redirects to your callback with `code`, `hmac`, `shop`, `state` and `timestamp`.
Before exchanging the code you must:

1. Compare `state` to the nonce you stored; reject on mismatch.
2. Remove `hmac` from the query string, sort the remaining parameters alphabetically, compute HMAC-SHA256 with the
   client secret, and compare in constant time.
3. Validate the shop domain against `^[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com$`, anchored at both ends. Skipping
   this is how a connector gets pointed at an attacker-controlled host — the shop domain is attacker-supplied input
   in a flow where you then send it your credentials.

**Webhooks.** The signature arrives in the `X-Shopify-Hmac-SHA256` header: base64 HMAC-SHA256 of the **raw request
body**, keyed with the client secret. Reject mismatches. Return `200`; anything outside the 200 range, including 3xx,
counts as an error. Shopify allows a 1-second connection timeout and a 5-second total timeout, retries 8 times over
4 hours, and **deletes the subscription after 8 consecutive failures** — a slow endpoint silently unsubscribes itself.

**The three mandatory compliance webhooks.** Every app distributed through the App Store must implement them,
*whether or not it collects personal data*:

| Topic | Meaning |
| --- | --- |
| `customers/data_request` | A customer has requested the data you hold on them |
| `customers/redact` | Delete that customer's data |
| `shop/redact` | Delete the shop's data, sent after uninstall |

Acknowledge with a 2xx and complete the action **within 30 days**. And a rule that is easy to get backwards: if a
mandatory compliance webhook arrives with an **invalid** HMAC header, the app **must return `401 Unauthorized`** —
returning 200 to be safe is itself a review failure.

Missing or unverified compliance webhooks are one of the standard reasons an app is sent back from review. They are
engineering work with a deadline, not a form field.

## 9. API versioning — a standing maintenance commitment

- A new API version ships **every three months**, at 17:00 UTC on the first day of the quarter, named for its quarter
  (`2026-04`).
- Each stable version is supported for a **minimum of 12 months**, with at least nine months of overlap between
  consecutive versions.
- The release candidate for the next version appears at the same moment as the stable one, and may contain
  backwards-incompatible changes. Do not pin production to it.
- The version goes **in the request URL**; responses echo `X-Shopify-API-Version`.
- If you target a version that is no longer accessible, Shopify **falls forward** to the oldest accessible stable
  version. This is the quiet failure mode: the integration keeps working, on a version nobody chose, until a shape
  change breaks a mapper and the cause is a pin that expired months ago.

Treat the version pin as a **recurring quarterly chore with a hard annual deadline**, owned by someone. Say so in the
handoff; it is a commitment the user is taking on, not a setting.

**Direction of travel.** REST Admin API has been legacy since 1 October 2024, and new public apps submitted to the
App Store since 1 April 2025 must use the **GraphQL Admin API**. For a *new* public app this is not a preference —
REST will not get through review. For an existing app, REST still answers, but new capability lands in GraphQL first.

## 10. Capture the credentials

From the Dev Dashboard: the app → **Settings → Credentials**.

Capture:

- **Client ID** (the OAuth `client_id`) — permanent; it does not change.
- **Client secret** (`client_secret`) — also the HMAC key for §8.
- The URL templates, with `{shop}` left as a placeholder: authorize `https://{shop}.myshopify.com/admin/oauth/authorize`,
  token `https://{shop}.myshopify.com/admin/oauth/access_token`, API `https://{shop}.myshopify.com/admin/api/{version}`.
- The API version you pinned (§9), and the distribution method (§1).

**Rotation.** The secret is rotate-only; assume you cannot re-read it. Rotation is a sequence, not a click:

1. Generate a new secret — **both stay active** until you revoke the old one.
2. Update webhook validation to accept signatures from **both** secrets during the transition. Shopify signs webhooks
   with your app's **oldest unrevoked** client secret, so webhooks keep arriving signed with the *old* one.
3. Update the app configuration.
4. Replace stored access tokens — every token you hold is still tied to the old secret. A dashboard-issued rotation
   refresh token, valid for **one hour**, can re-pin non-expiring tokens to the new secret.
5. Only then revoke the old secret.

Revoking early cuts merchants off. Never rotate without explicit go-ahead and a cutover plan.

Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript
and can be rotated, then move on.

## 11. App Store review and distribution limits

Public distribution means review by Shopify's app approval team. The app moves through **Draft → Submitted →
Reviewed → Published**, with a **Paused** state when core requirements are unmet; you get an email listing the
required changes. Shopify does not publish a turnaround time — do not quote one.

What review reliably asks for, beyond the app working:

- **OAuth immediately on install** — the app must authorize before anything else happens, even for a merchant who
  installed and uninstalled before.
- **The mandatory compliance webhooks** (§8) and **protected customer data approval** (§6).
- **Supported APIs only** — an app cannot be submitted on an API version within 90 days of deprecation.
- **Shopify's billing system** for any charges, if the app charges merchants.
- **Listing assets**: app icon (1200×1200), 3–6 screenshots (1600×900), feature media, demo store URL, a ~100
  character introduction, ~500 character details, feature list, pricing, and a privacy policy URL.

Install limits by distribution: **public is unlimited**; **custom distribution is one store, or the stores of one
Plus organization**, via a link you generate, with no review and no Billing API. The legacy admin-created custom app
is one store and cannot be created any more (§1).

Pricing, listing copy, privacy policy content, data-protection attestations and support commitments are business
decisions. Collect them; do not write them.

## Product fact — the Unified.to Shopify connector

**As of 2026-09-20, Unified.to's Shopify connector requests the following access scopes**, as the union of what its
supported objects declare — confirm the current set with the connector's owner before you declare scopes on a new
app, since object coverage changes:

```
read_products      write_products
read_inventory     write_inventory
read_locations
read_publications  write_publications
read_customers
read_orders        write_orders      read_all_orders
read_returns       write_returns
read_own_subscription_contracts
read_users
```

For the sign-in/identity flow alone it requests only `read_products`.

Auth quirks to carry into the app registration, all as of **2026-09-20**:

- **The shop domain is collected from the merchant as a subdomain** at connect time — the store handle from their
  admin URL (`admin.shopify.com/store/{store_id}`) — and substituted into the host of the authorize URL, the token
  URL **and** the API base. There is no fixed authorize host. A merchant who pastes the wrong handle fails at
  authorize, not at save.
- **Offline tokens, non-expiring.** The connector sends no per-user grant option, so it receives offline tokens — the
  right choice for background sync. But it also does **not** request expiring tokens and treats the token as having
  no expiry, which is the **pre-April-2026 model**. A public app registered today falls under §7's mandate. Treat
  this as the blocking question for the connector's owner: does it need expiring-token plus refresh support before a
  new public app is registered?
- **No PKCE.** The connector does not use PKCE for Shopify; Shopify's authorization code grant for confidential apps
  does not require it.
- **The token is sent as `X-Shopify-Access-Token`**, not as a bearer token, and the token exchange is a JSON POST
  carrying `client_id`, `client_secret` and `code`.
- **Scopes are joined with a space** in the authorize URL, where Shopify's documentation specifies a comma-separated
  list. Confirm against a live authorize round trip.
- **The API version is pinned to `2024-07`** across the connector's REST and GraphQL paths. On the quarterly cadence
  in §9, that version is past its supported window, which means Shopify is falling forward to the oldest accessible
  stable version rather than serving what the code asks for. Raise it — it is the most likely cause of unexplained
  shape drift, and it is a maintenance debt, not a setting.
- **A non-OAuth path exists**: the connector also accepts an Admin API access token plus a store ID, the
  custom-app-style credential. Per §1, legacy admin-created custom apps can no longer be created, so that path now
  depends on a custom-distribution app.

Everything in this section is a snapshot. **Confirm with the connector's owner before acting on it.**

## 12. Verify end-to-end

Install on a **development store** created from your organization — not on a store that already has some other build
of the app installed.

1. Run the real connect flow: collect the shop domain, build the shop-scoped authorize URL, approve as the merchant.
2. Confirm the callback's **HMAC and `state` both verify**, and that the shop domain passes the regex (§8).
3. Exchange the code. Confirm the token response's `scope` matches what you asked for — Shopify returns what was
   actually granted.
4. If you requested expiring tokens, confirm `expires_in`, `refresh_token` and `refresh_token_expires_in` are present,
   then **force a refresh and then a second refresh using the token from the first**.
5. Make one read call against `https://{shop}.myshopify.com/admin/api/{version}/…` with the token in
   `X-Shopify-Access-Token`, and check the `X-Shopify-API-Version` response header is the version you pinned (§9).
6. If you touch customers, read a customer and confirm **name, email, phone and address are populated, not `null`**
   (§6).
7. Fire a test webhook and confirm your handler verifies the HMAC and returns 200 inside 5 seconds (§8).
8. Uninstall, then reinstall. The flow must work for a merchant who has installed before.

| Symptom | Cause |
| --- | --- |
| `redirect_uri is not whitelisted` / `invalid_request` at authorize | Redirect URL not registered exactly, or CLI config not deployed (§5) |
| Authorize URL 404s or lands on a store login | Wrong shop domain — wrong handle, or a custom domain instead of `{shop}.myshopify.com` (see “The per-shop model”) |
| HMAC never validates on the callback | Signing the wrong string: `hmac` not removed, params not sorted, or the wrong secret (§8) |
| Every API call 401s after about an hour | Expiring offline token with no refresh implemented (§7) |
| Connection dies after a quiet three months | Refresh token's 90 days lapsed unused (§7) |
| Token stops working when the merchant logs out | Online token — `grant_options[]=per-user` was sent (§7) |
| Customer names and emails all `null`, HTTP 200 | Protected customer data not approved for those fields (§6) |
| Orders older than 60 days missing, no error | `read_orders` without `read_all_orders` (§6) |
| Staff/user reads empty on most stores | `read_users` is Plus-only and support-gated (§6) |
| Webhooks stopped arriving, no config change | 8 consecutive delivery failures deleted the subscription (§8) |
| Webhook HMACs fail right after a secret rotation | Shopify still signs with the oldest unrevoked secret (§10) |
| Response shapes changed with no deploy | Pinned API version expired; Shopify fell forward (§9) |
| Merchants re-prompted for approval unexpectedly | The declared scope set changed (§6) |
| Second merchant cannot install | App was created with custom distribution (§1) |

## 13. Hand off — never commit the secret

- **Do not** write the client secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body
  or a chat channel. It is also the HMAC key, so a leak is a forgery capability, not just an access one. Values go to
  the user, for their secret store or console.
- If a code change is needed (a redirect host, a scope, expiring-token support, an API version bump), keep it
  secret-free and say what the human must set out of band.
- Close with: app name and owning organization; **distribution method** and that it is permanent; client ID; where
  the secret was delivered; the authorize/token/API URL templates with `{shop}` left in; the exact scope strings;
  protected customer data request status and level; offline-token mode and whether refresh is implemented; where the
  three compliance webhooks are handled; the pinned API version and who owns the quarterly bump; App Store review
  status; and anything left for the user to do.

## Stop and ask

Hand back to a human rather than guessing when: the distribution choice is not clearly public and the user has not
confirmed it (it is irreversible); the client does not implement refresh for expiring offline tokens and a new public
app is being registered; the protected customer data request needs data-retention, encryption, staff-access or
incident-response commitments; an App Store listing asks for pricing, legal, compliance or volume claims; someone
proposes rotating the client secret on a live app without a cutover plan; the answer requires converting a custom
app to public (it does not convert — it is a new app and a full re-install); the fix requires Shopify Support to
enable something (`read_users`, `read_all_orders`); or the dashboard does not match the **Platform state** section.

## References

- Authentication and authorization overview — https://shopify.dev/docs/apps/build/authentication-authorization
- Access tokens — https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens
- Offline access tokens — https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens
- Online access tokens — https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/online-access-tokens
- Implement authorization code grant manually — https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant
- Authenticate a standalone or API-only app — https://shopify.dev/docs/apps/build/authentication-authorization/authenticate-standalone-apps
- Client secrets and rotation — https://shopify.dev/docs/apps/build/authentication-authorization/client-secrets
- Session tokens (embedded apps) — https://shopify.dev/docs/apps/build/authentication-authorization/session-tokens
- Expiring offline access tokens required for new public apps (1 Apr 2026) — https://shopify.dev/changelog/expiring-offline-access-tokens-required-for-public-apps-april-1-2026
- Expiring offline access tokens required for all public apps (1 Jan 2027) — https://shopify.dev/changelog/expiring-offline-access-tokens-required-for-all-public-apps-as-of-january-1-2027
- Offline access tokens now support expiry and refresh — https://shopify.dev/changelog/offline-access-tokens-now-support-expiry-and-refresh
- App distribution — https://shopify.dev/docs/apps/launch/distribution
- Select a distribution method — https://shopify.dev/docs/apps/launch/distribution/select-distribution-method
- Access scopes — https://shopify.dev/docs/api/usage/access-scopes
- Protected customer data — https://shopify.dev/docs/apps/launch/protected-customer-data
- Privacy law compliance and mandatory webhooks — https://shopify.dev/docs/apps/build/privacy-law-compliance
- Subscribe to webhooks over HTTPS (HMAC verification) — https://shopify.dev/docs/apps/build/webhooks/subscribe/https
- Webhook best practices — https://shopify.dev/docs/apps/build/webhooks/best-practices
- API versioning — https://shopify.dev/docs/api/usage/versioning
- New public apps must use GraphQL (from 1 Apr 2025) — https://shopify.dev/changelog/starting-april-2025-new-public-apps-submitted-to-shopify-app-store-must-use-graphql
- GraphQL Admin API reference — https://shopify.dev/docs/api/admin-graphql
- REST Admin API reference (legacy) — https://shopify.dev/docs/api/admin-rest
- Dev Dashboard — https://shopify.dev/docs/apps/build/dev-dashboard
- Migrate from the Partner Dashboard — https://shopify.dev/docs/apps/build/dev-dashboard/migrate-from-partners
- Early access: Dev Dashboard (21 May 2025) — https://shopify.dev/changelog/early-access-dev-dashboard
- App configuration (`shopify.app.toml`) — https://shopify.dev/docs/apps/build/cli-for-apps/app-configuration
- App requirements checklist — https://shopify.dev/docs/apps/launch/app-requirements-checklist
- App Store review — https://shopify.dev/docs/apps/launch/app-store-review
- App review process and statuses — https://shopify.dev/docs/apps/launch/app-store-review/review-process
- Billing — https://shopify.dev/docs/apps/launch/billing
- Shopify Partners — https://www.shopify.com/partners
- Help Center: about custom apps — https://help.shopify.com/en/manual/apps/app-types/custom-apps
