Storefront APIs can't be gradually adopted by apps

We’re adding an opt-in, storefront-facing feature to an existing published app. It writes a cart metafield from the Online Store (via the Storefront cartMetafieldsSet), which requires the unauthenticated_write_checkouts access scope.

Because only a subset of merchants will use this feature, we wanted to use optional scopes (introduced Dec 2024 for exactly this “progressive permissions” use case) so that only merchants who enable the feature are prompted to grant it. Existing merchants who don’t use it would be unaffected.

That doesn’t appear to be possible. Repro:

  1. Declare the scope as optional in shopify.app.toml:

    [access_scopes]
    scopes = ""
    optional_scopes = [ ..., "unauthenticated_read_checkouts", "unauthenticated_write_checkouts" ]
    
  2. shopify app deploy (succeeds, no error/warning about these scopes).

  3. Request the scope via the standard optional-scope grant flow — App Bridge shopify.scopes.request([...]) or the standalone request URL:

    https://admin.shopify.com/store/{store}/oauth/install?client_id={client_id}&optional_scopes=unauthenticated_write_checkouts
    

Result: 400 — Oauth error: undeclared_optional_scopes.

The same flow works perfectly for Admin optional scopes. Requesting e.g. write_discounts (declared optional, not yet granted) via the identical URL redirects straight to the consent screen (/app/grant?access_change_uuid=…). So the flow and the deploy are fine — Shopify simply does not treat unauthenticated scopes as declarable/requestable optional scopes. The only way to obtain unauthenticated_write_checkouts is to put it in the required scopes field.

Why this is a problem for established apps:

Moving a scope into required scopes forces every existing merchant to re-consent to the updated permissions (the app/scopes_update re-prompt) — even though the vast majority will never use the new feature. For a large installed base, prompting everyone to accept a new “read/write checkouts (unauthenticated)” permission for a niche feature is a real adoption/churn risk, and for us it’s a hard stop.

This is the exact scenario optional scopes were meant to solve, and it works for Admin scopes — but the door is closed for anything touching the Storefront API. It effectively means an app must decide up front, at install time, whether it will ever need unauthenticated access, or accept a disruptive global re-prompt later.

Hey @Dylan, hope all’s well - thanks for the detailed repro. I checked our optional-scope documentation and couldn’t find any documented exclusion for unauthenticated Storefront scopes, so this does seem weird to me.

Could you share the optional array returned by shopify.scopes.query() after deployment? If possible, if you’re able to share a UTC timestamp, and any x-request-id that would be super helpful. That should tell us whether the scope is being dropped during deployment or rejected later during the grant.

For now, declaring it as required appears to be the only supported route. I’ll route this for investigation once we have those details for sure.

Now I’m not able to repro!

Just for my information - how do you log these x-request-id’s from AppBridge or Admin GraphQL requests?

I know that’s the standard question, and it’s nice to provide a stack, but I don’t see anywhere in the Shopify dev docs on how to retrieve these unique IDs for requests across surfaces.

Hey @Dylan - glad to hear it seems to solved!

Also, happy to help with the request ID stuff for sure! For browser-initiated requests the easiest option I find is actually Chrome DevTools.

You should be able to open the Network tab, enable Preserve log, reproduce the issue, then select the relevant request and copy the x-request-id from its response headers.

For Admin GraphQL requests where you have access to the raw Response, you can also log it directly with:

response.headers.get("x-request-id")

A lot of the time, I’ll also just download a HAR file from a replication and share it with an AI agent (Codex/Claude/pi, etc) and have it pull the request ID/trace info from the recorded HAR and then use that to dig into our logs further.

Hope that helps a bit!