Engineering · Shopify API
How Shopify Admin API tokens expire and refresh: permanent tokens, expiring tokens and the refresh traps
Shopify Admin API tokens come in kinds that must be handled differently: a permanent custom-app token must never go through refresh logic, while an expiring token must be refreshed with a single-use refresh token that you persist every time. Most of our token outages were not expired tokens but refresh logic applied to the wrong token, or two refreshes racing each other.
Updated
What kinds of Shopify Admin API tokens exist
A Shopify Admin API access token is the value a program sends in the X-Shopify-Access-Token header on every Admin API request. We meet three kinds in practice. A permanent (non-expiring) offline token, typically the shpat_ token of a custom app, stays valid until the app is uninstalled or its secret is rotated. An expiring offline token lives 1 hour and comes with a refresh token (prefix shprt_) that lives 90 days and can be used only once. A minted token is created on demand from an app's client credentials and lives about 24 hours, so storing it is pointless.
Shopify also issues online tokens, tied to a logged-in user and valid for 24 hours at most; we do not use them for background work. The whole method fits in one rule: a permanent token must never pass through refresh code, and an expiring token must always be refreshed through exactly one careful path that saves the new refresh token.
Step 1: resolve the token type before every call
Every Shopify store we reach from code has one row in a store registry, and the token column holds either a static token or a self-describing marker that names the two credentials from which a token is minted on demand. One resolver function reads the registry, recognises the marker, mints when needed and returns a working token. Scripts never read the raw registry themselves.
We learned this the hard way. Six stores answered 401 Invalid API key or access token from our scripts, and the reflex was “the token expired, renew it”. Nothing had expired. The scripts were reading the registry directly and sending the marker text as if it were a token. The fix was in the resolver, and later we found a second tool with its own copy of the registry reader making the same mistake, so the fix had to go into every copy. A 401 from Shopify usually means wrong kind of token for this store before it means dead token.
Tokens are secrets: they live only in the secrets vault, are piped into the process, and are never printed. When a log must identify one, it shows the last 4 characters.
Step 2: leave permanent tokens alone
A permanent shpat_ token from a custom app has no refresh token and no expiry. If a token manager written for expiring tokens handles it, the manager will eventually try to refresh it, fail, and may mark the store as broken. Our rule: a permanent custom-app token is never passed through OAuth refresh, and if the token store requires an expiry date, it gets one far in the future so the refresh branch can never fire.
Custom apps are exempt from Shopify's expiring-token requirement; the exemption ends when the same app is distributed publicly.
Step 3: request and refresh expiring tokens correctly
Shopify returns an expiring offline token only when the request asks for it with expiring=1, in both the token-exchange flow and the authorization-code flow. Both requests are form-encoded, not JSON. Without the parameter Shopify returns a non-expiring token, which is the documented default. Under Shopify's published rule, public apps created on or after 1 April 2026 must use expiring offline tokens and older public apps must migrate by 1 January 2027.
Selecting public distribution switched the enforcement on immediately for one of our apps: the same token that returned 200 earlier started returning 403 Non-expiring access tokens are no longer accepted for the Admin API on every call. Across our app fleet, 9 of 12 apps healed when we deleted the stored offline session and reloaded the app so it minted an expiring token; the other 3 were missing a single configuration flag and kept minting non-expiring tokens until the flag was added. A redeploy alone does not heal a non-expiring token, because the library treats it as valid forever and never exchanges it again.
Persist the refresh token on every refresh. Webhooks and scheduled jobs have no user session, so the refresh token is their only route back in. Each refresh consumes the old refresh token and returns a new one. A 401 on refresh means the grant is dead: stop and ask a person to reopen the app. A 5xx, 429 or network error is transient: retry with the same refresh token, because Shopify replays the same response for 1 hour. We refresh when less than 10 minutes of validity remain.
One more sharp edge: the exchange that migrates a legacy token to an expiring one revokes the old token at once, even if you intended the call as a test. Do the migration in one step and save the result.
The refresh traps we hit
A read-only simulation burned a refresh token. A test script forced its database sessions to read-only and then read Shopify through the production code path. The access token had 8 minutes left, under the 10-minute margin, so the code refreshed it. Shopify consumed the refresh token and issued a new one, and the script could not save it. Shopify's grace window saved the store: 7 minutes later the live system refreshed with the old refresh token and received the same new token. The fix is a process-level flag that forbids refreshing altogether; every read-only process runs with it, and only processes that write may refresh.
Two simultaneous refreshes deleted the new token. One refresh succeeded and saved a new refresh token. One second later a second refresh, started in parallel with the old refresh token, received 401, and its error handler cleared the stored refresh token, which by then was the winner's new one. One hour later the access token expired and the store had no API access until someone opened the app from the store admin. The fix is a per-store lock: a token is cleared only under the lock, after re-reading the store and confirming that the stored refresh token is still the one the failed request used.
A cached HTTP client kept sending an old token. Our client cache kept one client per store with the token baked into its headers. The cache compared tokens on the wrong object, so after a rotation in another task it decided nothing had changed, and for 73 minutes many stores answered 401 in the middle of their tokens' lifetimes. The fix compares the token in the live client's header with the stored one, and on a 401 refreshes once and retries once. The same incident taught a second lesson: one error handler turned the failure into a business decision (“this order is a draft, skip it”), and nine orders shipped without an item. A failed read must be logged as a failure, not as a conclusion.
Rate limits and backoff
The Admin GraphQL API uses a leaky bucket per app and store. On the standard plan the bucket holds 1000 points and refills at 100 points per second; a query costs 1 point by default and a mutation 10. Read extensions.cost.throttleStatus on every response, slow down when currentlyAvailable drops below about 200, and on HTTP 429 honour Retry-After.
The REST Asset API for theme files is far stricter, at roughly 2 requests per second. Fetching theme assets with 12 concurrent workers got mass-throttled and silently dropped most files; at about 3 requests per second with retry and backoff, all 369 assets came back.
Two checks belong next to the rate limiter. A mutation that returns 200 with a non-empty userErrors array did nothing, and no exception is raised. And a read-back right after writing a theme asset can return the old body for a few seconds, so our verifier retries the read up to 5 times with growing waits and reports “could not confirm” rather than “rejected”.
Where we were wrong
We treated every 401 as an expired token, when the most common cause was a token of the wrong kind or a placeholder sent as a token. We assumed that a redeploy would fix stale tokens across our app fleet, when only deleting the stored session did. And after the refresh race and the read-only burn were fixed in production, we kept warning colleagues about them as live risks; before warning, we now check the version that is actually running.
Limits of this method
The token lifetimes and deadlines above are Shopify's published values at the time of writing and can change; check the current documentation before relying on them. We observed the grace window in one incident only. A per-store lock prevents races inside our own system, not between two different systems holding the same app's credentials. And none of this helps when the merchant uninstalls the app: then the only fix is a new installation.
Checklist
1. One resolver returns a working token; scripts never read the registry directly. 2. Permanent custom-app tokens never enter refresh code. 3. Request expiring tokens with expiring=1 in both flows. 4. Save the new refresh token on every refresh; retry transient errors with the same one. 5. Read-only processes are forbidden from refreshing. 6. Clear a token only under a per-store lock, after re-reading it. 7. On 401, refresh once and retry once, then fail loudly. 8. Respect the bucket, read userErrors, and verify writes by reading back with retries.
Questions and answers
Why does the Shopify Admin API return 401 Invalid API key or access token?
In our experience the most common cause of a Shopify 401 is not an expired token but the wrong kind of token, such as a stale static value for a store whose token rotates, or a placeholder sent instead of a token minted from client credentials. Check how that store authenticates before renewing anything. If the token kind is right, a 401 on refresh means the grant is dead and someone must reopen the app from the store admin.
How long do Shopify expiring offline access tokens last?
A Shopify expiring offline access token lasts 1 hour. It comes with a refresh token, prefixed shprt_, that lasts 90 days and can be used only once: each refresh returns a new access token and a new refresh token, and the new refresh token must be saved. Background jobs and webhooks depend on it because they have no user session to exchange.
Do I need expiring tokens for a Shopify custom app?
No. Under Shopify's published rule, custom apps are exempt; public apps created on or after 1 April 2026 must use expiring offline tokens and older public apps must migrate by 1 January 2027. The exemption ends when the app is distributed publicly: in our case every Admin API call returned 403 immediately after public distribution was selected, until the app requested tokens with expiring=1.
What should my code do when a Shopify token refresh fails?
Treat a 401 on the refresh call as final: the grant is dead, stop retrying and alert a person. Treat 5xx, 429 and network errors as transient and retry with the same refresh token, because Shopify replays the same response for 1 hour. Never clear the stored refresh token without a per-store lock and a fresh read, or a parallel refresh can lose the newly issued token.
How do I avoid Shopify GraphQL rate-limit errors?
Read extensions.cost.throttleStatus on every response and slow down before the bucket empties. On the standard plan the bucket holds 1000 points and refills at 100 per second; queries cost 1 point by default and mutations 10. Honour Retry-After on HTTP 429. The REST Asset API is much stricter, at roughly 2 requests per second, so keep theme-file work sequential with retries.
How to cite this
Going Stellar (2026). How Shopify Admin API tokens expire and refresh: permanent tokens, expiring tokens and the refresh traps. GOING STELLAR S.R.L..
https://goingstellar.ro/methods/shopify-admin-api-tokens-expiring-refresh/