Shopify Optional Scopes: Ask When You Need Them

Shopify optional scopes are access scopes your app declares under optional_scopes and asks for after installation, instead of at the install screen. The merchant sees a permission modal inside your app at the moment the feature needs the data, and can decline it without losing the rest of the app. You can revoke a granted one later.

The install screen is a single yes or no. Put read_customers and read_orders on it and every merchant reads that list before your app has done anything for them. Shopify’s position on the review side is not subtle: “Shopify restricts access to scopes for apps that don’t have a legitimate use for the associated data.”

Optional scopes shipped for apps on Admin API version 2024-10, in a developer changelog entry dated 4 December 2024. Everything below was written against Admin API version 2026-07.

What are optional scopes in a Shopify app?

Two lists in one app. scopes is what the merchant grants at install, and the only way to take one back is to uninstall. optional_scopes is a pool your app may request later, store by store, and give back later still.

The split is per store, which is the part that makes it worth the work. A merchant who never opens the feature that reads orders never grants read_orders, and your app still installs for them in one click. Two stores on the same app version can be holding different sets of scopes.

One rule shapes both lists. A write scope includes read, so write_products already grants read_products, and the access scopes reference tells you to “request the write scope only when your app needs both”. Declaring the implied read scope as optional alongside the required write scope is a deploy-time failure, not a runtime one: Declared optional_scopes [read_products] cannot be implicit required scopes.

How do you declare the two lists in shopify.app.toml?

Both lists live in the same block:

[access_scopes]
scopes = "write_products"
optional_scopes = ["read_orders", "read_customers"]

scopes is a required comma-separated string, documented as the scopes “your app will request access to during the authorization process”. optional_scopes is an array of handles, “any access scopes that your app can request dynamically after installation”. Neither list exists on the store until you deploy the config, so a scope you added to the TOML five minutes ago is not requestable yet.

There is a third key in that block, use_legacy_install_flow. Left out or set to false, scopes go through Shopify managed installation. Set to true, the legacy flow requests scopes through a URL parameter during OAuth instead, and the changelog describes optional scopes as a managed-install feature, so leave it alone.

Managed installation also changes what a scope change costs you. Add a required scope and redeploy, and merchants who already have the app “approve the new ones the next time they open it”. Every existing install hits an approval screen on next open. Add an optional one and nothing happens to anybody until your code asks.

How does a merchant grant a scope without leaving your app?

The App Bridge Scopes API does it client side, with no redirect. shopify.scopes.query() resolves to a ScopesDetail of three string arrays, granted, required and optional. shopify.scopes.request() opens a permission grant modal on top of your running app and resolves to {detail, result}, where result is 'granted-all' or 'declined-all'.

async function enableOrderSync(): Promise<boolean> {
  const {granted} = await shopify.scopes.query();
  if (granted.includes('read_orders')) return true;

  const response = await shopify.scopes.request(['read_orders']);
  return response.result === 'granted-all';
}

Read the UserResult union again: 'granted-all' or 'declined-all', nothing in between. A request for three scopes is one decision, and a merchant who balks at the third declines all three. Group a request around a feature the merchant has just clicked, not around everything you might want later.

The scopes you pass must be valid handles and must be declared optional. Passing a required scope to revoke() throws.

What does the server have to do about it?

Check again. The modal tells your front end what happened, and your front end is not evidence. On the React Router adapter the same three methods hang off the admin context:

import type {LoaderFunctionArgs} from 'react-router';
import {authenticate} from '../shopify.server';

export async function loader({request}: LoaderFunctionArgs) {
  const {scopes} = await authenticate.admin(request);
  const {granted} = await scopes.query();

  return {orderSync: granted.includes('read_orders')};
}

Two differences from the client API are worth knowing before you copy a code sample across. Server-side scopes.request() returns Promise<void> and performs a redirect, so it belongs in an action, not a loader that renders a page. And scopes.revoke() resolves to {revoked} rather than a full ScopesDetail.

Without the adapter, query the installation directly:

query AppScopes {
  currentAppInstallation {
    accessScopes {
      handle
      description
    }
  }
}

Standalone apps that do not render in the admin have no modal to open. They send the merchant to https://admin.shopify.com/store/{STORE_NAME}/oauth/install?client_id={CLIENT_ID}&optional_scopes={REQUESTED_SCOPES} and handle the return, which is the same shape of redirect dance as the rest of that auth flow. Our notes on verifying Shopify session tokens in Node cover the request-level half of it.

When would you revoke a scope?

When the merchant switches the feature off, and you should wire that up in the same commit that wires the grant. appRevokeAccessScopes takes scopes: [String!]! and returns revoked as a list of AccessScope plus userErrors:

mutation RevokeOrderAccess {
  appRevokeAccessScopes(scopes: ["read_orders"]) {
    revoked {
      handle
    }
    userErrors {
      field
      message
    }
  }
}

It runs only on the current app, and only against scopes that were configured as optional and dynamically granted. A required scope will not come off this way.

Protected customer data sits on top of all of this and is not solved by making a scope optional. You can declare read_customers, ship, and still get nothing back: the API “won’t return data from non-development stores until your app is configured and approved for protected customer data use”. Declare a scope you have not been approved for and creating an app version fails outright with an app_access validation error on scopes.

Which scopes should stay required?

The ones your first screen cannot render without. If the app opens on a product table, write_products is required and arguing about it wastes a sprint.

Everything attached to a toggle, a settings tab or a paid tier belongs in optional_scopes. So does anything a merchant is likely to ask about in a security review, which in practice means orders, customers and anything touching payments.

The cost is real and it is in your code, not your config. Every optional scope is a branch: a UI state for not-granted, a server check before the API call, and a path through your background jobs for a store that granted the scope last week and does not have it now. Read the granted list on each run instead of storing it at install time, because a revoke can land between two runs of the same job. Anything that executes outside a request from the admin needs that check, webhook handlers included, and our write-up on HMAC verification and deduplication covers what those should be doing first.

What I would do: move orders and customers to optional on any app that does not need them on screen one, request them from the click that turns the feature on, and revoke on the toggle going the other way. What I would not do is make a scope optional to skip a protected customer data application, or split a single feature’s scopes across two request() calls, because the all-or-nothing result makes the second call a second chance to say no.

Whoooop builds and maintains Shopify apps, including the auth and scope plumbing that decides how many merchants get past the install screen. If you have an app asking for more than it uses, our Shopify development page has more on how we work.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch