Public Shopify apps must use expiring offline access tokens for the GraphQL Admin API by January 1, 2027. How the tokens work, what the app template does for you, and a failure we met first-hand: uninstall and privacy webhooks returning 500 when a token can't be refreshed.
Shopify apps use offline access tokens for everything that happens without a merchant watching: background jobs, scheduled syncs and webhooks. For most of Shopify's history those tokens never expired. That is ending. Since April 1, 2026, every new public app has had to use expiring offline tokens, and from January 1, 2027, existing public apps must use them too.
We build SellRank, a Shopify app, and it has used expiring tokens from the start. This post covers what the change involves, and a failure we ran into that is easy to miss: webhooks for stores that have uninstalled your app can start failing with server errors, because the token they depend on can no longer be refreshed.
The timeline
- December 10, 2025: Shopify introduced offline access tokens that expire after 60 minutes and come with a refresh token.
- April 1, 2026: new public apps must request and use expiring offline tokens.
- August 28, 2026: refreshes became more forgiving. An app can now retry its previous refresh token until it starts using the new one, for up to 30 days, which gives it a way back after a lost response or a failed database write.
- January 1, 2027: Shopify rejects GraphQL Admin API requests from public apps that present a non-expiring offline token, and merchants lose access to the app until they re-authenticate.
Custom apps, and apps that merchants create themselves in the Dev Dashboard or the Shopify admin, are not affected.
How expiring tokens work
When a merchant approves an app, Shopify returns an access token that lives for an hour, together with a refresh token. Before the access token runs out, or when a request is refused with a 401, the app exchanges the refresh token for a fresh pair and stores both. Refresh tokens expire too, 90 days in Shopify's current documentation, and each refresh returns a new one to use next time.
Shopify's guidance for doing this well is short. Refresh a few minutes before expiry. Run one refresh at a time for each shop. Store every new token pair in a single atomic write. Always use the newest refresh token. Watch your logs for refresh failures.
Migrating an existing app
If your app is built on Shopify's app template, with the Remix or React Router packages, the change is one flag, expiringOfflineAccessTokens, in the future settings of your shopifyApp configuration, followed by a deploy. The template then requests, stores and refreshes expiring tokens by itself. Apps that implement token exchange or the authorisation code grant on their own add expiring=1 to those requests.
Stores that installed your app before the switch still hold old, non-expiring tokens. You can wait for each merchant to open the app, which triggers a new token, or migrate every installation directly with a token exchange that uses the stored token and your app's credentials. Shopify warns that this exchange permanently invalidates the old token, so the new pair has to be saved before anything else happens.
The failure we hit: webhooks for stores that have gone
Here is the part that is not in the migration guide. In the version of the Remix package we run, 4.2.1, webhook authentication happens in two steps. First it verifies the webhook's signature, and rejects a bad one with a 401. Then it loads the shop's stored offline session and checks whether the token expires within the next five minutes. If it does, it refreshes the token before your handler runs.
That refresh cannot succeed for a store that has uninstalled your app, or for a development store that has been deleted. The app's access is gone, so Shopify will not issue a new token. When the refresh fails, the package throws, the request ends in an HTTP 500, and your own webhook code never runs.
Two webhooks are more exposed to this than any others.
- app/uninstalled, whenever the stored token has expired or is about to. With hour-long tokens, that is the usual state for a merchant who has not opened the app recently.
- shop/redact, the privacy webhook Shopify sends 48 hours after a store uninstalls, asking you to delete that store's data. If the shop's session is still stored by then, its token has certainly expired, so the refresh is attempted and fails.
The failure also compounds. The template's uninstall handler is what deletes a shop's stored sessions. If that handler never runs, the session stays behind, and every later webhook for the same shop fails in exactly the same way. Shopify treats any response outside the 200 range as a failed delivery and retries it up to eight times over four hours, and its delivery metrics flag failure rates above half a percent as higher than average.
We found ours in the Dev Dashboard's webhook logs, which keep seven days of deliveries: a run of 500s, all for one deleted development store. Our handlers were fine. They were never reached.
How we fixed it
- 1Keep signature checks strict. A request with an invalid signature must still get a 401, which is also what Shopify requires of the privacy webhooks.
- 2Treat a token that cannot be refreshed as a normal condition, not a server error. We wrap the package's webhook authentication. Because the package verifies the signature before it touches the token, a refresh failure can only happen on a request that has already been verified. When one happens, we take the shop domain and topic from the webhook's headers, read the body ourselves, and run the handler without an Admin API client.
- 3Make uninstall and redaction independent of the token. Our uninstall handler deletes the shop's sessions whether or not a session loaded, and our shop/redact handler deletes every record we hold for that shop using nothing but the shop domain.
- 4Stop background jobs knocking on closed doors. Our scheduler works out whether a shop has anything due before it builds an API client, so a store that has left does not trigger a refresh attempt every time the scheduler runs. When a token does fail, the attempt is recorded so the next run moves on.
- 5Pin the behaviour down with tests. Our release checks confirm that the error from a refused refresh is recognised as a token failure, and that the 401 for a bad signature never is, so the fallback cannot swallow a request with an invalid signature.
What to do before January 1, 2027
- If your public app still uses non-expiring tokens, switch it to expiring tokens now, and migrate existing installations rather than waiting for merchants to open the app.
- Run one refresh at a time per shop, store each new pair atomically, and alert on refresh failures.
- Route your uninstall and shop/redact webhooks through a path that does not need a working token, and test it with a store that has uninstalled.
- Look at your webhook delivery metrics in the Dev Dashboard every week, not only when a warning email arrives.
None of this is exotic. It is the kind of detail that decides whether an app runs quietly for years or produces a steady trickle of errors nobody can explain, and it is why we test SellRank the way we do.
