Shopify recently added more resilient recovery for lost refresh responses. We need the same for cycling a non-expiring offline token to an expiring pair. Losing that response permanently orphans the install: the old shpat_ is destroyed, retry returns invalid_subject_token, and the only fix is merchant re-auth.
Docs
- Refresh recovery: More resilient refreshes for expiring offline access tokens - Shopify developer changelog
- Migration / cycle guide: Migrate to expiring offline access tokens
- Relevant section: “Cycle existing tokens without waiting for a merchant”
What we believe happened
- Shop had a legacy non-expiring offline access token (
shpat_…, length 38). - No refresh token / expiry fields were populated (never successfully cycled).
- With expiring offline tokens enabled, our app ran the token-exchange cycle described in that section:
subject_token= stored non-expiring offline token,expiring=1, no merchant session. - Shopify appears to have accepted the exchange and destroyed the subject token, but our app did not persist the returned access token + refresh token (lost HTTP response, worker crash, or DB write failure, same failure class as the refresh case in your changelog). This was not a clean total outage: across the same app, the cycle succeeded for some merchants and orphaned others, with no obvious pattern. That points to an intermittent lost-response / failed-persist failure during cycle, not a systematic misconfiguration that would have broken every install.
- Live DB ended up with an empty offline access token; our pre-outage backup still had the original
shpat_.
Evidence
Live row (before recovery attempt)
| Field | Value |
|---|---|
| Offline access token | empty |
| Refresh token | empty |
| Access / refresh expiry | null |
password_updated_at |
2025-10-23 |
Backup row (same shop)
| Field | Value |
|---|---|
| Offline access token | present (shpat_…, len 38) |
| Refresh token | empty |
| Expiry columns | null |
password_updated_at |
2025-10-23 |
Recovery attempt
- Restored the non-expiring offline token from backup onto live.
- Re-ran the cycle exchange from “Cycle existing tokens without waiting for a merchant”.
Shopify response
invalid_subject_token
No new access token / refresh token was returned that we could store.
This matches your own docs on that page:
Cycling is irreversible, and it isn’t safe to replay. Shopify destroys the non-expiring token in the same transaction that issues the expiring one, so if your app loses the response, that store has no usable token and the merchant has to reauthorize…
and:
When a parameter doesn’t meet these rules, Shopify returns
400 Bad Requestwith{"error": "invalid_subject_token"}… including for a store you’ve already cycled.
So the docs describe the failure, but there is still no recovery path other than merchant re-auth, unlike refresh, where you recently added a retry window.
Contrast: refresh recovery
This shop had no refresh token, so the new “previous refresh token stays valid until replacement is used” behaviour cannot apply. The orphan was created at cycle / migration exchange, not at refresh.
Why this is a gap
Your refresh resilience fix covers:
refresh succeeded at Shopify → app never saved the new pair → retry old refresh token
Cycling has the same failure shape, but worse outcomes:
cycle succeeded at Shopify → old
shpat_destroyed → app never saved the new pair → retry subject token →invalid_subject_token→ permanent orphan until merchant opens the app
Your migration docs already warn this is unsafe to replay. I’m asking you to close that hole the same way you did for refresh, especially for background-only apps that can’t wait for a merchant to open the app (which that same section says is why cycling exists).
Ask
Please ship a real recovery path for a lost cycle response, not another doc warning.
Developers need one of:
- Parity with refresh recovery: keep the previous non-expiring offline access token valid until the app successfully begins using the replacement refresh token (bounded window), or
- Idempotent / retryable cycle: a second call with the same subject token after a lost response returns the same usable expiring pair instead of
invalid_subject_token, or - Partner-side recovery: a supported API for the app owner to re-issue offline access for an already-installed app without a merchant browser session.
Developers need a solution, not a warning
This failure mode should never have been left as “merchant must reopen the app.”
You designed a one-way cycle that destroys the old token in the same transaction that issues the new pair. Your own docs say losing the response orphans the store. That is not an edge case for hobby apps, it is a production outage for every public app that runs webhooks, jobs, Flow actions, or scheduled sync without a merchant sitting in admin. Those are exactly the apps your cycle section says need this API.
You already know lost responses happen: that is why refresh recovery exists. Extending resilience to refresh while leaving cycle as irreversible and non-retryable means apps that followed your migration guidance are the ones left with dead installs and no server-side way back.
A caution in the docs is not a solution. If Shopify knows this can permanently brick an install, Shopify needs to give developers a recovery grant, an idempotent retry, or a Partner recovery API, before more of the ecosystem is forced through this path ahead of January 1, 2027.
I’m happy to share Partner app ID, timestamps, and request IDs privately with Shopify staff.