Migrate Avada Core V5 (Expiring Offline Tokens)
Guide migrate một app Shopify của Avada lên @avada/core v5, để hỗ trợ Shopify expiring offline access tokens.
Tham khảo chi tiết API trong repo core: @avada/core → docs/expiring-offline-tokens.md.
Tại sao phải migrate
Section titled “Tại sao phải migrate”Shopify bắt buộc expiring offline access tokens cho public apps:
- App tạo từ 2026-04-01 trở đi: bắt buộc ngay.
- Tất cả public apps: phải migrate trước 2027-01-01. Sau ngày này, gọi Admin API bằng non-expiring token sẽ bị reject.
Khác biệt: token cũ (non-expiring) không bao giờ hết hạn. Token mới (expiring) sống ~1 giờ (expires_in: 3600), được xoay vòng bằng refresh_token sống 90 ngày (refresh_token_expires_in: 7776000). Custom apps / merchant-created apps không bị ảnh hưởng.
Breaking changes của V5
Section titled “Breaking changes của V5”- Bỏ
shopify-api-nodekhỏi@avada/core. Core không còn bundle package này; mọi Admin API call trong core dùng rawfetchvà throw error kèm status + body thật của Shopify. App của bạn vẫn giữshopify-api-noderiêng — không cần bỏ. getShopifyApi()/getShopifyApiWithValidToken()giờ trả về object nhẹ{options: {shopName, accessToken}}(không phải instanceshopify-api-node). Nếu code nào gọi methodshopify-api-nodetrực tiếp lên object trả về từgetShopifyApithì phải đổi sang gọi API trực tiếp.- Các token helper nhận options object thay vì positional params:
getValidAccessToken(shopDomain, {accessTokenKey, apiKey, secret}),refreshAccessToken(shop, refreshToken, {apiKey, secret}), v.v.
Đa số app Avada (joy, app-base-template, …) dùng
shopify-api-noderiêng +prepareShopData, không dùnggetShopifyApicủa core, nên breaking change này không ảnh hưởng call sites của app. Chỉ là bump version thuần về mặt API.
Mô hình “hai công tắc” — cần CẢ HAI
Section titled “Mô hình “hai công tắc” — cần CẢ HAI”| Công tắc | Là gì | Thiếu nó |
|---|---|---|
expiringOfflineToken: true trên auth options | acquire — Shopify cấp expiring token + refresh_token | token vẫn non-expiring, không có gì để refresh |
getValidShopToken trong initShopify | consume — refresh token trước khi hết hạn | token expiring sẽ chết sau ~1h → 401 |
Bật một cái mà thiếu cái kia là lỗi phổ biến nhất: chỉ flag → 401 sau 1h; chỉ getValidShopToken → no-op (không có refresh token để xoay).
Các bước migrate
Section titled “Các bước migrate”1. Bump @avada/core
Section titled “1. Bump @avada/core”packages/functions/package.json → "@avada/core": "5.5.1", rồi yarn install.
Đang ở 5.2.x, lên 5.4.0 thì làm thêm:
- Chọn
registerLifecycleWebhookstheoshopify.app.tomlcủa app — xem Core cũng đăng ký qua API (5.4.0). Để mặc định trong khi toml đã khai báo topic thì webhook về 2 lần. - Core mặc định gọi Admin API
2026-04(trước là2025-10, Shopify ngừng phục vụ từ 2026-10-16). App tự truyềnapiVersionhoặc gọisetApiVersion()thì không đổi. - Từ 5.3.0, route auth trả 400 cho
shop,host,tokenhoặcapiKeysai định dạng.
Đang ở 5.4.x, lên 5.5.0: không có gì bắt buộc. 5.5.0 thêm migrateLegacyOfflineToken để migrate headless (xem Migrate headless / hàng loạt), và xoá các field plaintext accessToken/refreshToken còn sót mỗi khi ghi đè session hoặc shop. Code app nào còn đọc thẳng field plaintext đó thì chuyển sang đọc qua core (getValidShopToken).
Đang ở 5.5.0, lên 5.5.1: không có gì bắt buộc, chỉ là bản sửa lỗi. Khi callback OAuth (/shopify/embed/callback, updateScopes, /shopify/callback) nhận token mà Shopify không trả refresh_token (cờ tắt, hoặc grant legacy), core cũ chỉ ghi token mới. Session ghi kiểu merge, nên refreshTokenHash và các hạn của token expiring trước đó vẫn còn, và getValidShopToken cứ refresh bằng refresh token đã chết. 5.5.1 xoá các field đó. Lỗi này quan trọng nhất với app từng bật expiringOfflineToken rồi tắt lại.
2. initShopify → async dùng getValidShopToken
Section titled “2. initShopify → async dùng getValidShopToken”getValidShopToken(shop, shopifyConfig) trả {shopifyDomain, accessToken} (cùng shape với prepareShopData), tự refresh qua offline session. shopifyConfig đã có sẵn {apiKey, secret, accessTokenKey}.
// trướcexport function initShopify(shop, apiVersion = API_VERSION) { const {shopifyDomain, accessToken} = prepareShopData(shop.id, shop, shopifyConfig.accessTokenKey); return new Shopify({shopName: shopifyDomain, accessToken, apiVersion, autoLimit: true});}
// sauimport {getValidShopToken} from '@avada/core';
export async function initShopify(shop, apiVersion = API_VERSION) { const {shopifyDomain, accessToken} = await getValidShopToken(shop, shopifyConfig); return new Shopify({shopName: shopifyDomain, accessToken, apiVersion, autoLimit: true});}getValidShopToken đọc offline session (nơi lưu refresh token — shop record KHÔNG lưu refresh token). Session có refresh token thì refresh khi gần hết hạn. Còn lại (shop legacy non-expiring, hoặc không có session) thì trả token trên shop record, nên backward-safe. Trước 5.4.0, session legacy còn token thì trả token đó, kể cả khi OAuth cũ đã ghi token mới lên shop record, nên có thể dính 401.
3. Thêm await cho TẤT CẢ call sites của initShopify
Section titled “3. Thêm await cho TẤT CẢ call sites của initShopify”initShopify giờ là async nên mọi nơi gọi phải await. Có 3 dạng call cần đổi: = initShopify(, shopify: initShopify( (object prop), fn(initShopify( (arg).
Regex an toàn theo từng file (loại trừ commands//scripts/, bảo vệ dòng định nghĩa function initShopify, tránh double-await):
grep -rl "initShopify(" packages/functions/src --include="*.js" \ | grep -vE "/commands/|/scripts/" \ | while read -r f; do perl -i -pe 's/(?<!function )(?<!await )\binitShopify\(/await initShopify(/g' "$f" done
# verify: phải = 0grep -rn "initShopify(" packages/functions/src --include="*.js" \ | grep -vE "/commands/|/scripts/" \ | grep -vE "function initShopify|import |from '|await initShopify\(" | wc -l4. Client factory build qua initShopify (vd makeGraphQlApi)
Section titled “4. Client factory build qua initShopify (vd makeGraphQlApi)”Nếu helper như makeGraphQlApi build client bằng initShopify(shop) bên trong và đã là async, thì bước 3 đã sửa luôn call nội bộ → hàng trăm callers của nó không cần đổi. Chỉ cần đảm bảo factory đó là async.
5. Build verify (bắt lỗi await trong default param)
Section titled “5. Build verify (bắt lỗi await trong default param)”cd packages/functions && node esbuild.config.js --productionesbuild sẽ báo lỗi nếu có await trong default parameter, vd function f(shop, shopify = await initShopify(shop)). Sửa bằng cách đưa vào body:
export async function f(shop, shopify) { if (!shopify) shopify = await initShopify(shop); ...}Lỗi Failed to write to output file ... lib/*: permission denied là do thư mục output bị root sở hữu trên máy local — KHÔNG phải lỗi code (parse/bundle đã pass), CI build sạch.
6. Bật expiringOfflineToken
Section titled “6. Bật expiringOfflineToken”Set expiringOfflineToken: true trên các option block acquire token: verifyEmbedRequest (token exchange — embedded apps) và shopifyAuth (OAuth install). verifyRequest() không exchange → bỏ qua.
shopifyCharge cũng không cần. Kiểm chứng trên build 5.0.1: flag chỉ được đọc ở đúng 3 file — controllers/authController.js, services/shopifyAuthService.js, helpers/verifyEmbedRequest/verifyToken.js — và không file charge nào (charge.js, shopifyCharge.js, chargeRepository.js) đụng tới token hay exchange. Set ở đó là no-op vô hại, không cần đi tìm. Tự verify cho version của bạn:
grep -rln "expiringOfflineToken" node_modules/@avada/core/build | grep -v '\.d\.ts'7. Audit các chỗ đọc token BỎ QUA initShopify
Section titled “7. Audit các chỗ đọc token BỎ QUA initShopify”Chỗ nào build client mà không qua initShopify sẽ 401 sau ~1h khi shop đã migrate:
grep -rn "new Shopify(" packages/functions/src --include="*.js" | grep -vE "/commands/|/scripts/"grep -rn "X-Shopify-Access-Token" packages/functions/src --include="*.js"grep -rn "prepareShopData" packages/functions/src --include="*.js"Phân loại:
- Thật:
new Shopify({accessToken})với token lấy từ shop record / payload cũ → route quagetValidShopToken. - False positive:
prepareShopDatariêng của app (vd build profile cho customer.io);X-Shopify-Access-Token: partnerKey(Partner API key, không phải token của shop); dòng trongmakeGraphQlApi(đã được cover).
Branch sống lâu: audit lại sau MỖI lần merge master
Section titled “Branch sống lâu: audit lại sau MỖI lần merge master”Đây là thay đổi contract xuyên suốt codebase, nên nó va chạm với công việc đang chạy theo cách
khó chịu nhất: thêm await là một phép chèn thuần tuý, nên một call site initShopify(
mới viết trên master sẽ merge vào branch của bạn không hề có conflict marker — thiếu
await và vô hình. Merge sạch về mặt text ≠ đúng về mặt ngữ nghĩa.
Cộng với việc build không bắt được (xem cảnh báo ở bước 5), sẽ không có công cụ nào báo cho
bạn. Chạy lại grep ở bước 3 sau mỗi lần merge, và quét cả cây packages/ — package mới vẫn được
thêm vào trong lúc branch của bạn còn mở:
grep -rn "initShopify(" packages --include="*.js" --include="*.mjs" --include="*.cjs" --include="*.ts" \ | grep -v node_modules | grep -vE "/commands/|/scripts/" \ | grep -vE "function initShopify|import |from '|await initShopify\(" \ | grep -vE "^[^:]+:[0-9]+: *(\*|//)" # bỏ JSDoc; phải không in ra gìSố liệu thật từ Joy: 5 call site thiếu await lọt vào theo đường này qua 3 lần merge — 3 cái
trong cùng một ngày khi catch-up ~900 commit. Coi đây là chắc chắn xảy ra, không phải “có thể”.
Sau khi deploy: initShopify async đổi gì ở runtime
Section titled “Sau khi deploy: initShopify async đổi gì ở runtime”Việc convert thì máy móc, nhưng nó đổi hai tính chất của một hàm trước đây vừa miễn phí vừa không bao giờ fail. Mọi call site đều thừa hưởng cả hai. Audit mấy thứ này trước lần deploy production đầu tiên — không cái nào lộ ra qua test, qua build, hay qua smoke test trên staging.
1. Mỗi lần gọi giờ tốn tới 1 Firestore read
Section titled “1. Mỗi lần gọi giờ tốn tới 1 Firestore read”getValidShopToken đọc session document để biết shop dùng token expiring hay legacy:
| Shop | Firestore read mỗi lần gọi (5.4.0) |
|---|---|
| expiring (session có refresh token) | 1 |
| legacy, hoặc không có session | 1, rồi 0 trong 60 giây kế tiếp trên cùng instance |
Cache 60 giây đó chỉ nhớ phân loại “shop này legacy”, không nhớ token — token luôn lấy từ shop truyền vào. Nó nằm trong RAM của từng instance, nên cold start hay instance khác vẫn đọc lại; đừng tính vào nó khi ước lượng chi phí. Trước 5.4.0, mọi lần gọi đều đọc session 1 lần.
Trước migration initShopify chỉ là một constructor, không tốn gì. Sau migration, app có 150+ call site tức là đã thêm 1 Firestore read vào từng chỗ đó. Đây là vấn đề chi phí và latency, không phải correctness — nhưng nó nhân lên ở các path fan-out.
Cách sửa là thêm parameter, không phải thêm cache. Hàm nào có thể bị gọi nhiều lần cho cùng một shop thì nên nhận client tuỳ chọn:
export async function updateMetafields({shopId, shopify}) { if (!shopify) { const shop = await getShopById(shopId); shopify = await initShopify(shop); // một lần, không phải mỗi item }}Caller trong vòng lặp build client một lần rồi truyền xuống. Grep những chỗ rebuild client của cùng một shop bên trong vòng lặp và kéo ra ngoài. Đừng nhét cache vào trong initShopify — token cache bị stale còn tệ hơn nhiều so với tốn thêm một read. Core cũng chỉ cache phân loại, không cache token, vì đúng lý do này.
2. initShopify giờ có thể throw — và catch nuốt lỗi biến nó thành mất dữ liệu âm thầm
Section titled “2. initShopify giờ có thể throw — và catch nuốt lỗi biến nó thành mất dữ liệu âm thầm”Đây là cái thực sự cắn. Hai loại lỗi mới tới được mọi call site:
| Throw từ | Message | Nghĩa là |
|---|---|---|
getValidShopToken | No access token available for {shop}. Merchant must re-authorize. | shop không có session expiring và shop record cũng không có token dùng được |
performRefresh | Failed to refresh access token for {shop}. Merchant may need to re-authorize. | refresh grant fail; error.isRefreshTokenRevoked phân biệt authorization chết hẳn với việc retry transient đã cạn |
Trước migration initShopify không thể fail — nó build client từ token trên shop record, token hỏng thì mãi sau mới lộ ra thành 401 của chính lời gọi API. Sau migration, điểm fail dịch lên sớm hơn, vào ngay lúc tạo client.
Điều đó quan trọng vì chỗ nó rơi vào. Một webhook handler dạng này trông vô hại:
try { const shopify = await initShopify(shop); // ← giờ có thể throw const customer = await getShopifyCustomer(shopify, id); return (ctx.body = {success: true});} catch (e) { console.error(`Error handling the order webhook ${e?.message}`); return (ctx.body = {success: false, error: e.message}); // ← HTTP 200}Koa trả 200 cho body đó. Shopify coi 200 là đã giao thành công và không retry. Nên một lần refresh fail thoáng qua sẽ âm thầm đánh rơi đơn đó: không cộng điểm, không retry, chỉ còn đúng một dòng console.error để giải thích với merchant ba tuần sau.
Lưu ý blast radius mở rộng ra chứ không phải sinh từ hư không — token chết hẳn thì trước đây cũng đã rơi vào catch này qua 401. Cái mới là một shop hoàn toàn khoẻ mạnh cũng có thể rơi vào đó khi refresh fail thoáng qua.
Audit theo hình dạng này: mọi catch bọc quanh call site đã convert mà trả về 2xx. Với từng chỗ, quyết định có chủ đích:
- Lỗi nhóm token nên retry được. Trả non-2xx cho nhóm đó để Shopify gửi lại — lần gửi lại thường thành công, vì cái refresh vừa fail lúc đó đã xong.
- Tối thiểu thì phải kêu to. Log lỗi token ở mức có alert thật. Một cái 200 im lặng thì không thể phân biệt với thành công trên mọi dashboard bạn đang có.
3. Refresh nằm trong ngân sách 5 giây của webhook
Section titled “3. Refresh nằm trong ngân sách 5 giây của webhook”Shopify cho webhook ~5 giây. Nếu call site đã convert nằm ở phần đồng bộ của webhook handler thì refresh giờ diễn ra bên trong ngân sách đó, mà path refresh thì không bị chặn trên:
performRefreshretry lỗi transient 2 lần, backoff 500 ms và 1000 ms (TRANSIENT_REFRESH_RETRIES = 2,TRANSIENT_REFRESH_BACKOFF_MS = 500) — 1,5 s chỉ để ngủ, chưa tính 3 lần gọi HTTP.fetchbên dưới không cóAbortController, không có timeout. Token endpoint của Shopify chậm thì không có gì trong core chặn lại.
Case thường: 1 Firestore read (vài chục ms). Case xấu nhất: 3 lần HTTP tới endpoint đang yếu + 1,5 s backoff — vượt 5 s thoải mái.
Hai tính chất khiến nó đáng thiết kế né chứ không nên bỏ qua:
- Hiếm với từng shop — refresh khoảng 1 lần/giờ, chỉ những lời gọi rơi vào cửa sổ 5 phút trước hạn mới kích hoạt.
- Nhưng tương quan giữa các shop — nguyên nhân thường là token endpoint của Shopify đang có vấn đề, tức là mọi shop refresh trong cùng cửa sổ đó đều dính cùng lúc.
Ưu tiên resolve client ở background consumer thay vì ở phần đồng bộ của webhook. Chỗ nào handler thật sự cần một lời gọi API sống trước khi trả lời (ví dụ check eligibility) thì chấp nhận rủi ro một cách có ý thức, và đảm bảo failure mode ở §2 là retry chứ không phải 200 im lặng.
4. Concurrency: core lo được tới đâu, và cái gap chính core tự chỉ ra
Section titled “4. Concurrency: core lo được tới đâu, và cái gap chính core tự chỉ ra”Refresh token là one-time-use trên toàn fleet, nên refresh song song cho cùng một shop là mối nguy thật. Core lo nhiều hơn bạn tưởng — đừng tự viết lock trước khi đọc cái đã có sẵn.
Trong một process: các caller đồng thời gộp vào cùng một in-flight promise, key theo shop. Từ 5.4.0, forceRefreshAccessToken gọi song song cũng gộp như vậy (trước đó hai caller cùng đọc session có thể cùng tiêu một refresh token).
Giữa nhiều instance: khi một refresh thua cuộc đua, core phục hồi thay vì fail:
// Another instance may have rotated this token first… Re-read the session: if a// different instance has since persisted a fresh, still-valid token, use it instead// of forcing the merchant through re-auth.const latest = await sessionRepository.findOne(sessionId, accessTokenKey);if (latest && latest.refreshToken !== refreshToken && /* còn hạn */) { return latest.accessToken; // "Refresh lost the race … using token refreshed by another instance"}Nên một đám worker fan-out vào cùng một shop lúc token hết hạn không cần lock ở tầng app.
Gap mà chính core nêu tên: “Scope is per-process. Multi-instance deployments can still race; the retry-on-401 path is the safety net for that case.” Cái safety net đó dành cho token bị revoke hoặc xoay ngoài luồng, khi accessTokenExpiresAt cache vẫn bảo “còn hạn” nên không có gì kích hoạt refresh — lời gọi chỉ đơn giản là 401. Core export sẵn forceRefreshAccessToken đúng cho việc này: bọc lời gọi Admin API sao cho gặp 401 thì ép refresh một lần rồi retry một lần. App chạy multi-instance nên có, không phải thứ trang trí.
Checklist trước khi deploy cho nhóm rủi ro này
Section titled “Checklist trước khi deploy cho nhóm rủi ro này”- Call site rebuild client theo từng item trong vòng lặp — kéo ra ngoài, hoặc truyền
shopifytuỳ chọn xuống. - Mọi
catchquanh call site đã convert mà trả 2xx — lỗi token phải retry được, hoặc tối thiểu là log có alert. - Call site đã convert nằm trong path đồng bộ của webhook — chuyển xuống background consumer, hoặc chấp nhận có ý thức.
- Retry-on-401 bằng
forceRefreshAccessTokennếu app chạy multi-instance. - Sau deploy, theo dõi lượng Firestore read: thêm tối đa 1 read cho mỗi lần call site chạy (shop legacy ít hơn nhờ cache 60 giây) là hình dạng mong đợi.
Khai báo webhook trong shopify.app.toml (bắt buộc)
Section titled “Khai báo webhook trong shopify.app.toml (bắt buộc)”Từ 5.2.0, chỉ webhook app/uninstalled mới đưa shop về gói free — giống template chính thức của Shopify (shopify-app-react-router): uninstall chỉ được biết qua webhook, không suy ra từ token chết. Webhook không tới thì shop gỡ thật rồi cài lại sẽ giữ gói cũ (xem Install hooks có reset gói không?). Vì vậy webhook này phải chắc chắn tới, ở mọi môi trường.
Đừng dựa vào việc core tự đăng ký qua API. registerWebhooks của core gọi webhookSubscriptionCreate cho từng shop, nhưng:
- từ 4.7.0 tới 5.3.0, mọi lần đăng ký đều fail: thư viện cũ gửi
Content-Type: application/graphql, Shopify trả 415 từ API 2025-01, và core không log gì. 5.4.0 sửa (gửi JSON) và log lỗi; - chỉ chạy khi core nhận ra một lần cài đặt thật — shop đã cài từ trước không bao giờ được đăng ký;
- địa chỉ lấy từ
ctx.hostcủa request lúc đó, có thể sai domain.
Đã gặp thật trên Joy staging 21 (2026-10-04): gỡ app rồi cài lại, log không có dòng Handling uninstalling for ... nào, và shop giữ nguyên gói Advanced. Trường hợp này khớp với lỗi 415 ở trên.
Webhook khai báo trong toml (app-specific) thì Shopify áp cho mọi shop ngay khi deploy config, kể cả shop đã cài, không cần token, không phụ thuộc code nào chạy. Shopify khuyến nghị cách này.
Mẫu đầy đủ
Section titled “Mẫu đầy đủ”Mỗi môi trường (production và từng staging) có file config riêng, nên phải sửa từng file. Thay <host> bằng domain backend của môi trường đó — đúng domain mà route /auth của core đang chạy (Joy prod: joy.avada.io; Joy staging N: avada-joy-staging-N.firebaseapp.com):
[webhooks]api_version = "2026-04"
# 3 webhook quyền riêng tư — BẮT BUỘC khai báo ở đây (không đăng ký được qua API) [[webhooks.subscriptions]] uri = "https://<host>/auth/webhook/customers/data_request" compliance_topics = [ "customers/data_request" ]
[[webhooks.subscriptions]] uri = "https://<host>/auth/webhook/customers/redact" compliance_topics = [ "customers/redact" ]
[[webhooks.subscriptions]] uri = "https://<host>/auth/webhook/shop/redact" compliance_topics = [ "shop/redact" ]
# Gỡ app — đây là thứ duy nhất đưa shop về initial plan [[webhooks.subscriptions]] uri = "https://<host>/auth/webhook/app/uninstalled" topics = [ "app/uninstalled" ]
# Shop đổi plan / đóng cửa — core đồng bộ plan, huỷ subscription khi store đóng [[webhooks.subscriptions]] uri = "https://<host>/auth/webhook/shop/update" topics = [ "shop/update" ]- Các path trên là route có sẵn của core (
/auth/webhook/..., đã verify HMAC bằng app secret) — không cần viết handler mới. api_versionchỉ quyết định format payload. Dùng cùng version với phần còn lại của app (core 5.4.0 mặc định2026-04;2025-10đã ngừng từ 2026-10-16).- App dùng
afterThemePublishthì thêm một khốitopics = [ "themes/publish" ]trỏ tới/auth/webhook/theme/publish(cần scoperead_themes).
Core cũng đăng ký qua API (5.4.0)
Section titled “Core cũng đăng ký qua API (5.4.0)”Từ 5.4.0 đăng ký qua API chạy thật trở lại. Toml và API cùng bật thì mỗi shop cài mới có hai subscription cho cùng topic và nhận mọi webhook 2 lần: afterUninstall và lần huỷ plan chạy hai lần. Tắt bằng option registerLifecycleWebhooks: false, truyền vào cả shopifyAuth lẫn verifyEmbedRequest:
shopifyAuth({...authOptions, registerLifecycleWebhooks: false});verifyEmbedRequest({...embedOptions, registerLifecycleWebhooks: false});false tắt cả ba topic core đăng ký cùng lúc, nên toml phải khai báo đủ những topic app cần:
| Topic | Core làm gì |
|---|---|
app/uninstalled | ghi nhận gỡ app (isInstalled: false), huỷ subscription, chạy afterUninstall |
shop/update | đồng bộ plan Shopify, domain, timezone; store bị frozen/cancelled/fraudulent thì đánh dấu đóng, huỷ subscription, chạy afterShopClosed |
themes/publish | chạy afterThemePublish |
shopify.app.toml production khai báo | Đặt |
|---|---|
app/uninstalled + shop/update (+ themes/publish nếu dùng afterThemePublish) | false |
| không topic nào | giữ mặc định true |
chỉ một phần, vd chỉ app/uninstalled | thêm topic còn thiếu vào toml, deploy config, rồi mới false |
Hàng cuối không có giá trị nào an toàn nếu giữ nguyên: true thì topic đã khai báo về 2 lần, false thì mất các topic còn lại.
Handler vẫn nên chịu được gửi trùng: shop cài trên core ≤ 4.6 có thể còn subscription per-shop cũ (Joy đã chặn trùng ở shopRepository.js). Query webhookSubscriptions của Admin API chỉ liệt kê subscription per-shop, nên tìm và xoá được theo từng shop.
Deploy và kiểm tra
Section titled “Deploy và kiểm tra”shopify app deploy -c <tên config>cho từng môi trường (hoặc sửa trực tiếp trên Dev Dashboard). Chỉ sửa file toml mà không deploy thì Shopify không biết gì.- Dev Dashboard → app → Versions: version vừa deploy phải có đủ 5 subscription ở trên (thêm
themes/publishnếu có). - Gỡ app trên một store test của môi trường đó. Log phải có
Handling uninstalling for <shop>vàCancelling subscription for <shop>. Cài lại → gói về initial plan. - Không có dòng log nào → kiểm tra lại
uri(đúng domain backend, đúng/auth/webhook/app/uninstalled) và version đã release chưa. - App giữ
registerLifecycleWebhooks: true: logLifecycle webhook registration failed(kèm shop, topic, HTTP status) nghĩa là shop đó sẽ không nhận topic ấy.
Code app coi 401 là “đã gỡ app”
Section titled “Code app coi 401 là “đã gỡ app””App trước v5 hay ghi isInstalled: false (hoặc hasUninstalled: true) mỗi khi một call Shopify chạy nền trả 401 — hợp lý khi offline token không bao giờ hết hạn. Với expiring token, 401 có thể chỉ là token vừa xoay giữa chừng, và một isInstalled: false ghi oan chính là đầu vào để nhánh refresh-expired ở dưới chạy install hooks. Joy có 2 chỗ như vậy (checkInstalledApp, chargeShop); quy tắc cuối cùng:
-
Không ghi
isInstalled: false/hasUninstalled: truetừ code app. Chỉ webhookapp/uninstalledđược ghi. Code chạy nền chỉ dùng kết quả phân loại bên dưới để dừng việc đang làm (bỏ qua email, không tính phí). Ghi cờ thì một lần phân loại sai sẽ thành vĩnh viễn và mở đường cho install hooks chạy trên shop đang sống. Cái giá: shop gỡ thật mà mất webhook vẫn cònisInstalled: true, nên mỗi lần chạy tốn thêm một lần gọi Shopify + refresh rồi mới dừng — rẻ hơn nhiều so với gỡ nhầm. Đây cũng là lý do phải khai báo webhook trong toml. -
Đọc HTTP status, không đọc message.
codes.some(c => e.message.includes(c))khớp cả “timed out after 4012ms”. Dùnge.response?.statusCode(shopify-api-node / got). -
Chỉ 401 mới có thể là đã gỡ. 403 thì không bao giờ — đó là thiếu scope trên token còn sống.
isAuthFailureErrorcủa core cũng loại 403 vì lý do này. Có một 403 đặc biệt:Non-expiring access tokens are no longer accepted for the Admin API— Shopify từ chối token non-expiring (app tạo từ 2026-04-01, hoặc mọi public app từ 2027-01-01). Đó cũng không phải gỡ app, mà là token cần migrate/re-auth. Core 5.4.0 coi lỗi này là token hết hiệu lực và chạy install recovery; từ 5.4.1,verifyTokenmigrate thay vì chạy install khi app bật cảautoMigrateOfflineTokenlẫnexpiringOfflineTokenvà session chưa có refresh token. Code chạy nền gặp lỗi này thì bỏ qua shop đó, để runner headless hoặc lần mở app kế tiếp đổi token. -
Gặp 401 thì gọi
forceRefreshAccessToken(shopDomain, shopifyConfig). Refresh thành công = app còn cài (app đã gỡ không refresh được). Không cần gọi thử lại API. -
Refresh thất bại thì phân loại giống core:
- không có offline session / không có
refreshToken→ shop đang dùng token non-expiring cũ, không thể xoay, nên 401 đúng là đã gỡ; e.isRefreshTokenRevoked === truevàrefreshTokenExpiresAtcủa session còn trong tương lai → đã gỡ;- bị từ chối nhưng
refreshTokenExpiresAtđã qua hoặc không có → shop ngủ quá 90 ngày, không phải gỡ app (core gọi làrefresh-expired); - còn lại (
isRefreshTokenRevoked === false: mạng, 5xx, 429) → lỗi tạm, không phải gỡ app.
Đọc
refreshTokenExpiresAttừ offline session (shopifySession/offline_<domain>), không đọc từ shop record: đường token exchange chỉ ghi field này vào session. Đọc session lỗi thì trả “chưa gỡ”. - không có offline session / không có
-
Lỗi do chính
initShopifyném ra (lần refresh bên tronggetValidShopTokenthất bại) cóisRefreshTokenRevokednhưng không córesponse— phân loại thẳng theo quy tắc 4. Nếu chỉ checke.response.statusCodethì shop gỡ thật (mất webhook) không bao giờ bị phát hiện, vì lúc phát hiện thì access token đã hết hạn và chính bước refresh mới là chỗ fail. -
Retry lệnh charge sau khi refresh thành công: 401 nghĩa là Shopify chưa charge gì, nên retry một lần bằng token mới là an toàn — với điều kiện trong lượt đó chưa tạo charge nào.
Code tham khảo ở Joy: packages/functions/src/services/shopifyService.js (isUninstallRefreshFailure, isUninstalledAfterRefresh, isUninstalledError), test ở shopifyService.checkInstalledApp.test.js / shopifyService.chargeShop.test.js.
Migrate các shop CŨ (đang dùng non-expiring token)
Section titled “Migrate các shop CŨ (đang dùng non-expiring token)”Đây là phần dễ hiểu nhầm nhất. Bật expiringOfflineToken: true chỉ đổi token cho install/re-auth mới. Shop đã cài rồi không tự migrate khi login — vì token non-expiring luôn hợp lệ → checkIfActiveAccessToken luôn true → verifyToken không re-exchange. Kiểm tra: session doc có accessTokenHash nhưng thiếu refreshTokenHash/accessTokenExpiresAt = chưa migrate.
Migrate cần token exchange. Shopify nhận 2 loại subject khác nhau, và chính lựa chọn này quyết định có cần merchant mở app hay không:
| Subject | subject_token_type | Cần embedded request? | Có trong @avada/core? |
|---|---|---|---|
| App Bridge session token | ...oauth:token-type:id_token | Có | Có — migrateToExpiringToken / autoMigrateOfflineToken |
| Chính token non-expiring cũ | urn:shopify:params:oauth:token-type:offline-access-token | Không — server-to-server | Có, từ 5.5.0 — migrateLegacyOfflineToken |
Hai cách dưới đây dùng subject thứ nhất nên đều cần embedded request (cần @avada/core ≥ 5.0.0-alpha.7). Subject thứ hai xem Migrate headless / hàng loạt.
- Tự động (config): set
autoMigrateOfflineToken: truevàexpiringOfflineToken: truetrên auth options.verifyTokensẽ re-exchange shop có token nhưng chưa có refresh token ở request embedded kế tiếp — một lần duy nhất mỗi shop, không cần code app, không cần merchant làm gì. Re-exchange chỉ chạy sau khicheckIfActiveAccessTokenxác nhận token hiện tại còn sống. Chính thứ tự đó là guard: session giữ token non-expiring đã bị revoke cũng có access token và không có refresh token, giống y hệt signature của shop chưa migrate. Check token trước thì phân biệt được; check signature trước thì một lần reinstall thật bị hiểu thành “migration” và bỏ quainitialPlan/registerWebhooks/afterInstall. Comment trong core ghi rõ đây là bug đã từng dính: “Letting needsMigration short-circuit it meant a session holding a REVOKED non-expiring token took the migration branch.” Từ 5.4.1, bước kiểm tra còn sống cũng coi lỗi 403Non-expiring access tokens are no longer accepted for the Admin APIlà còn sống khi sắp migrate (checkIfActiveAccessToken(session, needsMigration)), nên shop có token legacy đã bị Shopify từ chối sẽ được migrate thay vì chạy install hooks. 401 vẫn là bị thu hồi.
Nhánh nào chạy install hooks
Section titled “Nhánh nào chạy install hooks”isInstalled không được đọc ở nhánh migration — nhưng không phải là vô can trong verifyToken. Biết chính xác nó áp dụng ở đâu là thứ trả lời được câu “tại sao install hooks chạy / không chạy”:
checkIfActiveAccessToken(session, needsMigration)├─ còn sống → needsMigration ? re-exchange (migration) → hooks KHÔNG BAO GIỜ chạy, không đọc isInstalled│ (từ 5.4.1, lỗi 403 token non-expiring được tính là còn sống khi needsMigration)└─ đã chết → recoverInvalidToken(session) → 1 trong 4 outcome: ├─ transient → 503, không quyết gì, để client retry ├─ refreshed → hết hạn thường lệ; mang token vừa xoay đi tiếp ├─ revoked → (re)install thật → hooks CHẠY, không đọc isInstalled └─ refresh-expired → shop dormant quá 90 ngày → hooks chỉ chạy NẾU shop record không tồn tại hoặc isInstalled === falseHai hệ quả đáng nhớ:
revokedcố tình không phụ thuộc webhook.isInstalled: falsechỉ do một thứ ghi — webhookAPP_UNINSTALLED— mà webhook đó không thể fire nếu chưa từng được register. Nên reinstall trên shop có webhook không tới vẫn phát hiện được. Đó là lý do nhánh này cố tình không đọcisInstalled.refresh-expiredthì cố tình có đọc. Shop nằm im quá mốc refresh 90 ngày cần token mới nhưng chưa hề uninstall; coi đó là install thì sẽ tạo lại initial plan cho một merchant đang sống. Ở đây webhook im lặng mới là câu trả lời đúng.
Shop legacy non-expiring mà token đã chết thì phân loại là revoked, không phải refresh-expired — lý do của core: “A non-expiring offline token never expires, so one the API rejected can only have been revoked.”
verifyEmbedRequest({ apiKey, secret, accessTokenKey, scopes, expiringOfflineToken: true, autoMigrateOfflineToken: true // ← migrate shop cũ khi mở app});- Thủ công (function): gọi
migrateToExpiringToken(ctx, {apiKey, secret, accessTokenKey})trong embedded handler (vdafterLogin). No-op nếu đã expiring.
Shop không bao giờ mở admin (background-only) không migrate được bằng 2 cách trên — không có session token. Dùng cách headless bên dưới; cách này cũng migrate được phần còn lại của fleet mà không phải chờ merchant mở app.
Install hooks có reset gói không? (đã sửa ở 5.2.0)
Section titled “Install hooks có reset gói không? (đã sửa ở 5.2.0)”“Hooks chạy” trước đây nghĩa là builder.create(initialPlan) — huỷ subscription đang chạy và đặt shop.plan về initial plan (free). Với expiring token, outcome revoked / refresh-expired có thể xảy ra cho một shop chưa hề gỡ app:
- refresh xong nhưng mất response sau khi Shopify đã xoay token, nên refresh token trong DB đã chết (Shopify chỉ trả lại response cũ trong ~1h);
- refresh token quá 90 ngày ở shop ít hoạt động, cộng với một
isInstalled: falseghi oan — do chính code app ghi khi coi 401 là gỡ app (xem Code app coi 401 là “đã gỡ app”).
Dưới 5.2.0, việc này âm thầm hạ một merchant đang trả tiền về free trong khi Shopify vẫn thu tiền. Builder.create có guard (“Refusing to reset active paid subscription”) nhưng guard đòi endsAt của subscription còn trong tương lai, mà endsAt chỉ được ghi một lần lúc tạo và không bao giờ được gia hạn. Trên prod Joy (2026-10-01), 58/59 shop trả tiền không được guard này bảo vệ. Sau đó cũng không có gì tự khôi phục: lần reset tạo một subscription doc free mới, nên logic “khôi phục từ subscription mới nhất” chỉ thấy free.
5.2.0 cho cả 3 đường cài đặt (handleAfterInstall, OAuth callback, handleInstall) đi qua applyInitialPlan: chỉ tạo initial plan khi shop không còn subscription hiệu lực nào (không có doc, hoặc doc mới nhất đã bị huỷ). Reinstall thật vẫn về initial plan, vì webhook uninstall / store-closed đã huỷ subscription từ trước. Khi bỏ qua, core log Skipped initial plan "<id>" for shop <id>.
Đánh đổi đã chấp nhận: reinstall mà webhook APP_UNINSTALLED không bao giờ tới thì shop giữ gói cũ mà không bị thu tiền. Cố ý nghiêng về phía merchant — và là lý do webhook này phải được khai báo trong shopify.app.toml ở mọi môi trường. App nào cần thì đối soát gói với billing của Shopify (currentAppInstallation.activeSubscriptions) bằng một job riêng.
builder.create() không đổi ở 5.2.0 — code app muốn chủ động hạ gói vẫn phải truyền allowDowngrade = true.
OAuth callback không embedded (5.4.0). Shop đã ghi nhận gỡ app (isInstalled: false) quay lại qua OAuth callback của shopifyAuth với expiringOfflineToken thì đi thẳng vào install và chạy afterInstall. Lỗi refresh tạm thời vẫn trả lỗi, không bị coi là cài lại. Từ 5.4.0, hook đọc được ctx.state.isShopifyInstall để biết request hiện tại có phải lần cài không.
Migrate headless / hàng loạt (không cần merchant)
Section titled “Migrate headless / hàng loạt (không cần merchant)”Shopify: “Use this migration flow if you don’t want to wait for users to open your app and trigger a new token acquisition.” Dùng chính token cũ làm subject, kèm client credentials của app — không cần ID token hay session của merchant:
curl -X POST https://{shop}/admin/oauth/access_token \ -H 'Content-Type: application/x-www-form-urlencoded' \ -H 'Accept: application/json' \ -d 'client_id={client_id}' \ -d 'client_secret={client_secret}' \ -d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \ -d 'subject_token={non_expiring_offline_token}' \ -d 'subject_token_type=urn:shopify:params:oauth:token-type:offline-access-token' \ -d 'requested_token_type=urn:shopify:params:oauth:token-type:offline-access-token' \ -d 'expiring=1'Từ 5.5.0, core gửi request này giúp bạn — đừng tự viết lại:
import {migrateLegacyOfflineToken} from '@avada/core';
// shopifyConfig = {apiKey, secret, accessTokenKey}const result = await migrateLegacyOfflineToken(shopifyDomain, legacyAccessToken, shopifyConfig);// {migrated: true, accessTokenExpiresAt, refreshTokenExpiresAt}// {migrated: false, reason: 'already-expiring'} khi session đã có refresh tokenCore đảm bảo:
- validate domain
*.myshopify.comtrước khi gửi credentials, và từ chối shop không có shop record; - ghi cặp token đã mã hoá vào
shopifySession/offline_{shop}trước, sau đó mới ghi token và các field mirror lên shop. Chạy lại sau một lần dở dang sẽ sửa shop record theo session, và không bao giờ ghi đè một token shop không phải token legacy cũng không phải token của session; - không bao giờ chạy install hooks;
- các lần gọi đồng thời cho cùng một shop trong cùng một process dùng chung một lần exchange. Lỗi được throw kèm
statusCode, body của Shopify nằm trongmessage.
Phần app tự lo:
- Truyền token trên shop record:
prepareShopData(shop.id, shop, accessTokenKey).accessToken, cũng là token màgetValidShopTokendùng cho shop legacy. Token trên session legacy có thể cũ hơn. 400 {"error":"invalid_subject_token"}nghĩa là token cũ không còn hiệu lực (đã gỡ app, bị thu hồi, hoặc đã exchange rồi). Chỉ re-auth mới sửa được, đừng retry lặp lại.- Chạy tuần tự từng shop giữa các process. Chỉ chạy 1 worker một lúc (
maxInstances: 1), mỗi lần 1 shop, và chạy giờ thấp điểm: một request embedded cóautoMigrateOfflineTokencó thể lấy token cho đúng shop đó cùng lúc, và core không lock giữa hai đường này.
Chỉ shop nào token cũ còn dùng được mới migrate kiểu này. Shop đã mất token thì vẫn phải re-auth thật trước 2027-01-01.
Mẫu: nút DevZone — migrate 1 shop, hoặc toàn bộ shop có xác nhận
Section titled “Mẫu: nút DevZone — migrate 1 shop, hoặc toàn bộ shop có xác nhận”Gồm 4 phần:
- Endpoint DevZone chỉ cho staff: migrate 1 shop ngay (mặc định là shop đang mở DevZone), hoặc khởi động một run cho cả fleet;
- Worker Pub/Sub: duyệt các shop đã cài, tự publish lại trước khi chạm timeout của function;
- Log kết quả từng shop trong Firestore;
- Card DevZone trên giao diện.
Run thật cho cả fleet phải gõ cụm xác nhận MIGRATE ALL, và server kiểm tra cụm này chứ không chỉ modal, nên một lệnh gọi API lạc cũng không khởi động được. Hai nút cũng không chạy đè lên nhau: nút 1 shop trả 409 khi đang có run fleet còn heartbeat. Đường dẫn và helper (publishTopic, getCurrentShopData, useCreateApi, @functions/config/shopify) theo cấu trúc của Joy; app khác thay bằng helper tương ứng.
import {FieldPath, Firestore} from '@google-cloud/firestore';import {firestoreSession, migrateLegacyOfflineToken, prepareShopData} from '@avada/core';import shopifyConfig from '@functions/config/shopify';import publishTopic from '@functions/helpers/pubsub/publishTopic';
export const MIGRATION_TOPIC = 'legacyTokenMigration';export const CONFIRM_PHRASE = 'MIGRATE ALL';export const ALL_SHOPS = Number.MAX_SAFE_INTEGER;const PAGE_SIZE = 50;const TIME_BUDGET_MS = 6 * 60 * 1000; // well inside the 540s function timeoutconst ACTIVE_RUN_MS = 15 * 60 * 1000; // a run with no heartbeat for this long has died
const firestore = new Firestore();const shopsRef = firestore.collection('shops');export const runsRef = firestore.collection('legacyTokenMigrationRuns');
/** * The shop document for a domain, or null. */export async function getShopDoc(shopifyDomain) { const {docs} = await shopsRef.where('shopifyDomain', '==', shopifyDomain).limit(1).get(); return docs[0] || null;}
/** * Whether a fleet run is still working. A run that died halfway keeps status `running`, so trust * its heartbeat rather than its status. */export async function hasActiveRun() { const {docs} = await runsRef.where('status', '==', 'running').get(); return docs.some(doc => Date.now() - doc.get('heartbeatAt').toMillis() < ACTIVE_RUN_MS);}
/** * Migrate one shop or, in a dry run, only classify it. Never throws: the outcome is the result. */export async function migrateShop(shopDoc, dryRun) { const {shopifyDomain} = shopDoc.data(); try { const {accessToken} = prepareShopData(shopDoc.id, shopDoc.data(), shopifyConfig.accessTokenKey); if (!accessToken) return {status: 'no-token'}; if (dryRun) { const session = await firestoreSession.loadCallback(`offline_${shopifyDomain}`, shopifyConfig.accessTokenKey); return {status: session?.refreshToken ? 'already-expiring' : 'would-migrate'}; } // Call core even for already-expiring sessions: it skips Shopify for them, but repairs a // shop record that an earlier, half-finished run left on the legacy token. const result = await migrateLegacyOfflineToken(shopifyDomain, accessToken, shopifyConfig); return {status: result.migrated ? 'migrated' : result.reason}; } catch (e) { const status = e.statusCode === 400 && /invalid_subject_token/.test(e.message) ? 'needs-reauth' : 'failed'; return {status, error: String(e.message).slice(0, 500)}; }}
/** * Pub/Sub worker. Handles shops one at a time and republishes itself with a cursor before the * time budget runs out, so a fleet of any size finishes without hitting the function timeout. */export async function handleLegacyTokenMigration({runId, dryRun = true, limit = ALL_SHOPS, cursor = null, processed = 0}) { const startedAt = Date.now(); let done = false; while (!done && Date.now() - startedAt < TIME_BUDGET_MS) { const pageSize = Math.min(PAGE_SIZE, limit - processed); let query = shopsRef.where('isInstalled', '==', true).orderBy(FieldPath.documentId()).limit(pageSize); if (cursor) query = query.startAfter(cursor); const {docs} = await query.get();
for (const doc of docs) { const outcome = await migrateShop(doc, dryRun); await runsRef.doc(runId).collection('shops').doc(doc.id) .set({shopifyDomain: doc.get('shopifyDomain'), ...outcome, at: new Date()}); console.log(`Legacy token migration ${runId}: ${doc.get('shopifyDomain')} -> ${outcome.status}`); } processed += docs.length; if (docs.length) cursor = docs[docs.length - 1].id; done = docs.length < pageSize || processed >= limit; }
if (!done) { await runsRef.doc(runId).update({processed, heartbeatAt: new Date()}); await publishTopic(MIGRATION_TOPIC, {runId, dryRun, limit, cursor, processed}); return; } await runsRef.doc(runId).update({status: 'done', processed, finishedAt: new Date()});}import {getCurrentShopData} from '../../helpers/auth';import publishTopic from '../../helpers/pubsub/publishTopic';import { ALL_SHOPS, CONFIRM_PHRASE, MIGRATION_TOPIC, getShopDoc, hasActiveRun, migrateShop, runsRef} from '../../services/legacyTokenMigrationService';
// Dev Zone routes live in the embedded API, which every merchant can call. These act on any shop,// so check staff on the server; hiding the buttons is not a guard.const STAFF_SHOPS = (process.env.DEV_ZONE_STAFF_SHOPS || '').split(',').filter(Boolean);
function fail(ctx, status, error) { ctx.status = status; ctx.body = {success: false, error};}
function rejectNonStaff(ctx) { if (STAFF_SHOPS.includes(getCurrentShopData(ctx).shopifyDomain)) return false; fail(ctx, 403, 'Forbidden'); return true;}
/** * Migrate one shop now. Defaults to the shop the Dev Zone is open on. */export async function migrateOne(ctx) { if (rejectNonStaff(ctx)) return; const {shopifyDomain = getCurrentShopData(ctx).shopifyDomain, dryRun = false} = ctx.req.body || {}; // The fleet worker may be on this very shop, and core does not lock across instances. if (await hasActiveRun()) return fail(ctx, 409, 'A fleet run is in progress. Wait for it to finish.'); const shopDoc = await getShopDoc(shopifyDomain); if (!shopDoc) return fail(ctx, 404, `No shop record for ${shopifyDomain}`); ctx.body = {success: true, data: {shopifyDomain, ...(await migrateShop(shopDoc, dryRun))}};}
/** * Start a fleet run. A real run needs the confirmation phrase: checked here, not only in the UI. */export async function start(ctx) { if (rejectNonStaff(ctx)) return; const {dryRun = true, limit = ALL_SHOPS, confirm} = ctx.req.body || {}; if (!dryRun && confirm !== CONFIRM_PHRASE) return fail(ctx, 400, `Type "${CONFIRM_PHRASE}" to start a real run.`); if (await hasActiveRun()) return fail(ctx, 409, 'Another run is in progress.'); const runRef = runsRef.doc(); const now = new Date(); await runRef.set({dryRun, limit, status: 'running', startedAt: now, heartbeatAt: now}); await publishTopic(MIGRATION_TOPIC, {runId: runRef.id, dryRun, limit}); ctx.body = {success: true, data: {runId: runRef.id}};}
export async function summary(ctx) { if (rejectNonStaff(ctx)) return; const run = await runsRef.doc(ctx.params.runId).get(); const {docs} = await run.ref.collection('shops').select('status').get(); const counts = {}; docs.forEach(doc => { counts[doc.get('status')] = (counts[doc.get('status')] || 0) + 1; }); ctx.body = {success: true, data: {...run.data(), counts}};}// routes/api.js (the embedded API router)router.post('/dev-zone/legacy-token-migration/shop', jsonType, legacyTokenMigrationController.migrateOne);router.post('/dev-zone/legacy-token-migration', jsonType, legacyTokenMigrationController.start);router.get('/dev-zone/legacy-token-migration/:runId', legacyTokenMigrationController.summary);
// index.js: one worker at a time, so two migrations of one shop never overlapexport const legacyTokenMigration = functions .runWith({timeoutSeconds: 540, memory: '512MB', maxInstances: 1}) .pubsub.topic(MIGRATION_TOPIC) .onPublish(message => handleLegacyTokenMigration(message.json));import React, {useState} from 'react';import {BlockStack, Button, Card, InlineStack, Modal, Text, TextField} from '@shopify/polaris';import useCreateApi from '../../hooks/api/useCreateApi';import {fetchAuthenticatedApi} from '../../helpers';
const CONFIRM_PHRASE = 'MIGRATE ALL'; // must match the server's
/** * Dev zone: migrate legacy offline tokens headlessly, for one shop now or for every shop in the * background. The fleet run sits behind a typed confirmation. * * @returns {JSX.Element} */export default function LegacyTokenMigration() { const [shopifyDomain, setShopifyDomain] = useState(''); const [result, setResult] = useState(null); const [run, setRun] = useState(null); const [confirming, setConfirming] = useState(false); const [phrase, setPhrase] = useState(''); const one = useCreateApi({url: '/dev-zone/legacy-token-migration/shop', successCallback: resp => setResult(resp.data)}); const fleet = useCreateApi({url: '/dev-zone/legacy-token-migration', successCallback: resp => setRun(resp.data)});
const closeConfirm = () => { setConfirming(false); setPhrase(''); }; const startFleetRun = async () => { await fleet.handleCreate({dryRun: false, confirm: phrase}); closeConfirm(); }; const refreshRun = async () => { const resp = await fetchAuthenticatedApi(`/dev-zone/legacy-token-migration/${run.runId}`); setRun({runId: run.runId, ...resp.data}); };
return ( <Card> <BlockStack gap="400"> <Text as="h2" variant="headingMd">Legacy token migration</Text> <InlineStack gap="200" blockAlign="end"> <TextField label="Shop domain (empty: this shop)" value={shopifyDomain} onChange={setShopifyDomain} autoComplete="off" /> <Button loading={one.creating} onClick={() => one.handleCreate(shopifyDomain ? {shopifyDomain} : {})}> Migrate this shop </Button> </InlineStack> {result && <Text as="p">{result.shopifyDomain}: {result.status} {result.error || ''}</Text>} <InlineStack gap="200"> <Button loading={fleet.creating} onClick={() => fleet.handleCreate({dryRun: true})}>Dry run all shops</Button> <Button tone="critical" variant="primary" onClick={() => setConfirming(true)}>Migrate all shops</Button> {run && <Button onClick={refreshRun}>Refresh run</Button>} </InlineStack> {run && <Text as="p">Run {run.runId}: {run.status || 'running'} {JSON.stringify(run.counts || {})}</Text>} </BlockStack> <Modal open={confirming} onClose={closeConfirm} title="Migrate every shop?" primaryAction={{ content: 'Start migration', destructive: true, disabled: phrase !== CONFIRM_PHRASE, loading: fleet.creating, onAction: startFleetRun }} secondaryActions={[{content: 'Cancel', onAction: closeConfirm}]} > <Modal.Section> <BlockStack gap="200"> <Text as="p"> This exchanges the legacy offline token of every installed shop and cannot be undone: the old tokens stop working for the GraphQL Admin API. Do a dry run and a few single-shop migrations first. </Text> <TextField label={`Type ${CONFIRM_PHRASE} to confirm`} value={phrase} onChange={setPhrase} autoComplete="off" /> </BlockStack> </Modal.Section> </Modal> </Card> );}Thứ tự rollout:
- Deploy core 5.5.1, bật
expiringOfflineTokentrên mọi block lấy token (bước 6), và setDEV_ZONE_STAFF_SHOPS. - Dry run all shops: đọc số lượng
would-migrate/already-expiring/no-token. Không ghi gì vào session. - Canary bằng “Migrate this shop” trên 3–5 shop (dev store trước, rồi vài shop thật), sau đó đợi hơn 1h:
accessTokenExpiresAttrên session của các shop đó phải nhích lên, và job nền của app vẫn chạy bình thường cho các shop đó. - Migrate all shops sau bước gõ xác nhận. Muốn chạy từng đợt thì gọi API kèm
limittrước (vd{dryRun: false, confirm: 'MIGRATE ALL', limit: 100}).limitđếm số shop được quét, và shop đã expiring không tốn lệnh gọi Shopify nào. - Chạy lại cho các shop
failedtrong vòng 7 ngày; token trên shop record chính là chìa khoá để lấy lại. Shopneeds-reauththì để lần mở app kế tiếp (autoMigrateOfflineToken) hoặc re-auth xử lý, đừng retry.
Pub/Sub giao message ít nhất một lần, nên một trang có thể chạy 2 lần; lần thứ hai chỉ thấy already-expiring và ghi lại trạng thái. Chuỗi run dừng giữa chừng (crash, deploy) thì run vẫn ở trạng thái running nhưng hết heartbeat sau 15 phút: khởi động run mới, run mới sẽ quét lại từ shop đầu tiên.
Test nhanh đường embedded cho 1 shop: xoá session doc shopifySession/offline_{shop} (hoặc field accessTokenHash) trên Firestore → mở lại embedded app → re-exchange với expiring:1 → doc xuất hiện lại kèm refreshTokenHash + accessTokenExpiresAt.
Mẫu: banner trạng thái session + nút Check shop
Section titled “Mẫu: banner trạng thái session + nút Check shop”Hai nút migrate ở trên chỉ làm migrate. Muốn thấy kết quả thì cần thêm ba thứ, giống Dev Zone của Joy:
- Banner trạng thái ở đầu Dev Zone: shop đang mở đã migrate chưa, token nào hết hạn lúc nào. Banner có nút Run token exchange để chạy lại token exchange embedded.
- Nút Check shop cho một shop bất kỳ, nhập theo domain. Nút này đọc session để biết shop có token expiring hay legacy, rồi gọi thử Admin API qua đúng
initShopifymà webhook và job nền dùng. Admin API OK ở đây thì job nền của shop đó cũng chạy được. - Ô Force refresh: ép refresh access token trước khi check. Nhờ vậy test được đường refresh ngay, khỏi đợi hết 1h.
Banner có 4 trạng thái:
| Trạng thái | Điều kiện trên shopifySession/offline_{shop} | Tone | Ý nghĩa / làm gì |
|---|---|---|---|
| No offline session found | không có doc | critical | App đã bị gỡ, hoặc session bị xoá. Mở lại embedded app để tạo lại |
| Not migrated | có accessTokenHash, không có refreshTokenHash | warning | Shop còn dùng token legacy. Bấm Run token exchange, hoặc chờ request embedded kế tiếp |
| Migrated | có refreshTokenHash | success | Hiện hạn access token (~1h), hạn refresh token (~90 ngày) và thời điểm session được ghi |
| Migrated (written just now) | như trên, nhưng updateTime < 2 phút | success | Chính lần mở trang này vừa migrate shop, hoặc shop vừa refresh token. Hai việc ghi cùng các field nên không phân biệt được |
⚠️ Shop đang mở Dev Zone gần như luôn hiện “migrated”. Mở admin là đã gửi request embedded, và
autoMigrateOfflineTokenmigrate shop ngay trên request đó, trước khi banner kịp đọc. Vì vậy banner chỉ dùng để kiểm tra sức khoẻ, không dùng để theo dõi rollout. Muốn xem một shop legacy thì nhập domain của shop đó vào Check shop, và đừng mở admin của nó.
Đọc kết quả Check shop:
| Kết quả | Ý nghĩa |
|---|---|
Legacy token + Admin API OK | Shop chưa migrate, token cũ vẫn sống. Có thể bấm Migrate this shop |
Expiring token + Admin API OK | Đã migrate và gọi API được. Đây là trạng thái đích |
Refreshed + hạn access token nhảy lên | Đường refresh chạy được (chỉ khi tick Force refresh) |
Cannot refresh: shop is still on a legacy token | Tick Force refresh trên shop legacy. Shop chưa có refresh token để refresh |
Admin API failed + 401, shop đã migrate | Có chỗ đang build client không qua getValidShopToken, hoặc refresh token đã chết. Xem lỗi đi kèm |
Merchant must re-authorize | Refresh token đã hết hạn hoặc bị thu hồi. Merchant mở lại app là có cặp token mới |
Endpoint status và exchangeThisShop chỉ đụng tới shop đang mở và chỉ trả hash, nên không cần check staff. check đọc session và gọi API của shop khác, nên phải qua rejectNonStaff ở server, giống migrateOne.
// packages/functions/src/services/legacyTokenMigrationService.js (add to the sample above)import {forceRefreshAccessToken} from '@avada/core';import {initShopify} from './shopifyService';
const sessionsRef = new Firestore().collection('shopifySession');
/** * What the offline session says, without decrypting anything: hashes and expiries only. */export async function getSessionSummary(shopifyDomain) { const doc = await sessionsRef.doc(`offline_${shopifyDomain}`).get(); if (!doc.exists) return {hasSession: false, migrated: false}; const data = doc.data(); return { hasSession: true, migrated: Boolean(data.refreshTokenHash), accessTokenExpiresAt: data.accessTokenExpiresAt?.toDate?.() || null, refreshTokenExpiresAt: data.refreshTokenExpiresAt?.toDate?.() || null, // updateTime lives on the snapshot, not in data(): it dates the last exchange or refresh. sessionUpdatedAt: doc.updateTime.toDate() };}
/** * Is this shop on an expiring token, and does the Admin API still answer through initShopify, * the same path webhooks and background jobs use? forceRefresh rotates the access token first, * so the refresh path is proven without waiting an hour. */export async function checkShopHealth(shopDoc, forceRefresh = false) { const {shopifyDomain} = shopDoc; const before = await getSessionSummary(shopifyDomain); const result = {shopifyDomain, before, refreshed: false, api: {ok: false}}; try { if (forceRefresh) { // A legacy shop has no refresh token: core would throw "Merchant must re-authorize". if (!before.migrated) throw new Error('Cannot refresh: shop is still on a legacy token'); await forceRefreshAccessToken(shopifyDomain, shopifyConfig); result.refreshed = true; } const shopify = await initShopify(shopDoc); const resp = await shopify.graphql('{ shop { name } }'); result.api = {ok: true, shopName: resp.shop.name}; } catch (e) { result.api = {ok: false, error: String(e.message).slice(0, 500)}; } result.after = await getSessionSummary(shopifyDomain); return result;}// packages/functions/src/controllers/devZone/legacyTokenMigrationController.js (add)import {migrateToExpiringToken} from '@avada/core';import shopifyConfig from '../../config/shopify';import {checkShopHealth, getSessionSummary} from '../../services/legacyTokenMigrationService';
/** * Banner data for the shop the Dev Zone is open on. Own shop only, so no staff check. */export async function status(ctx) { const {shopifyDomain} = getCurrentShopData(ctx); ctx.body = {success: true, data: {shopifyDomain, ...(await getSessionSummary(shopifyDomain))}};}
/** * Re-run the embedded token exchange for this shop. Uses the App Bridge session token on the * request; core returns reason "already-expiring" when there is nothing to do. */export async function exchangeThisShop(ctx) { const result = await migrateToExpiringToken(ctx, shopifyConfig); const {shopifyDomain} = getCurrentShopData(ctx); ctx.body = {success: true, data: {...result, ...(await getSessionSummary(shopifyDomain))}};}
/** * Health check for any shop: staff only, since it reads another shop's session and calls its API. */export async function check(ctx) { if (rejectNonStaff(ctx)) return; const {shopifyDomain = getCurrentShopData(ctx).shopifyDomain, forceRefresh = false} = ctx.req.body || {}; const shopDoc = await getShopDoc(shopifyDomain); if (!shopDoc) return fail(ctx, 404, `No shop record for ${shopifyDomain}`); const data = await checkShopHealth(shopDoc, Boolean(forceRefresh)); ctx.body = {success: data.api.ok, data, error: data.api.error};}// routes/api.js (the embedded API router)router.get('/dev-zone/session-token-status', legacyTokenMigrationController.status);router.post('/dev-zone/session-token-migrate', jsonType, legacyTokenMigrationController.exchangeThisShop);router.post('/dev-zone/legacy-token-migration/check', jsonType, legacyTokenMigrationController.check);import React from 'react';import {Badge, Banner, BlockStack, Button, InlineStack, Text} from '@shopify/polaris';import useFetchApi from '../../hooks/api/useFetchApi';import useCreateApi from '../../hooks/api/useCreateApi';
// Opening the admin fires embedded requests and autoMigrateOfflineToken runs on any of them.// A session written within this window migrated (or refreshed) because of this page load.const JUST_WRITTEN_MS = 2 * 60 * 1000;const formatDate = value => (value ? new Date(value).toLocaleString() : '-');
/** * Dev zone banner: is this shop on an expiring offline token, or still on the legacy one? */export default function SessionTokenStatus() { const {data, loading, reFetch} = useFetchApi('/dev-zone/session-token-status', {}); const exchange = useCreateApi({url: '/dev-zone/session-token-migrate', successCallback: () => reFetch()}); if (loading) return null;
const exchangeButton = ( <InlineStack> <Button loading={exchange.creating} onClick={() => exchange.handleCreate({})}>Run token exchange</Button> </InlineStack> );
if (!data.hasSession) { return ( <Banner tone="critical" title="Session token: no offline session found"> <Text as="p">No shopifySession/offline_{data.shopifyDomain} document. Re-open the embedded app to recreate it.</Text> </Banner> ); }
if (!data.migrated) { return ( <Banner tone="warning" title="Session token: not migrated"> <BlockStack gap="100"> <Text as="p">Legacy non-expiring offline token (no refresh token). It migrates on the next embedded request.</Text> {exchangeButton} </BlockStack> </Banner> ); }
const justWritten = Date.now() - new Date(data.sessionUpdatedAt).getTime() < JUST_WRITTEN_MS; return ( <Banner tone="success" title={justWritten ? 'Session token: migrated (written just now)' : 'Session token: migrated'}> <BlockStack gap="100"> <InlineStack gap="200"> <Badge tone="success">Token exchange</Badge> <Text as="span" tone="subdued">{data.shopifyDomain}</Text> </InlineStack> <Text as="p">Session updated: {formatDate(data.sessionUpdatedAt)}</Text> <Text as="p">Access token expires: {formatDate(data.accessTokenExpiresAt)}</Text> <Text as="p">Refresh token expires: {formatDate(data.refreshTokenExpiresAt)}</Text> {exchangeButton} </BlockStack> </Banner> );}// packages/assets/src/components/DevZone/LegacyTokenMigration.js: add a "Check shop" button// next to "Migrate this shop", reusing the same shop domain field.import {Badge, Checkbox} from '@shopify/polaris';
const [forceRefresh, setForceRefresh] = useState(false);const [health, setHealth] = useState(null);const checker = useCreateApi({url: '/dev-zone/legacy-token-migration/check', fullResp: true});const checkShop = async () => { const resp = await checker.handleCreate({...(shopifyDomain ? {shopifyDomain} : {}), forceRefresh}); setHealth(resp?.data || {api: {ok: false, error: resp?.error}});};
<Button loading={checker.creating} onClick={checkShop}>Check shop</Button><Checkbox label="Force refresh before the check (tests the refresh path without waiting 1h)" checked={forceRefresh} onChange={setForceRefresh}/>{health && ( <BlockStack gap="100"> <InlineStack gap="200"> <Badge tone={health.after?.migrated ? 'success' : 'warning'}> {health.after?.migrated ? 'Expiring token' : 'Legacy token'} </Badge> <Badge tone={health.api?.ok ? 'success' : 'critical'}> {health.api?.ok ? 'Admin API OK' : 'Admin API failed'} </Badge> {health.refreshed && <Badge tone="info">Refreshed</Badge>} </InlineStack> {health.api?.shopName && <Text as="p">Shop: {health.api.shopName}</Text>} {health.api?.error && <Text tone="critical">{health.api.error}</Text>} {health.after?.migrated && ( <Text as="p"> Access token expires: {formatDate(health.before?.accessTokenExpiresAt)} → {formatDate(health.after.accessTokenExpiresAt)} </Text> )} </BlockStack>)}// pages/DevZone/DevZone.js: both cards full width, at the top<Layout.Section variant="fullWidth"><SessionTokenStatus /></Layout.Section><Layout.Section variant="fullWidth"><LegacyTokenMigration /></Layout.Section>Kịch bản canary cho 1 shop (thay cho bước 3 của thứ tự rollout ở trên):
- Chọn shop có session chỉ có
accessTokenHash, và đừng mở admin của shop đó. - Check shop →
Legacy token+Admin API OK. - Migrate this shop →
migrated. - Check shop, tick Force refresh →
Expiring token+Admin API OK+Refreshed. - Sau hơn 1h, Check shop không tick → vẫn
Admin API OK. Lần nàyinitShopifytự refresh, giống đường của job nền. - Mở admin của shop đó → banner hiện
migrated, app không chạy lại flow cài đặt, gói giữ nguyên.
90-day refresh token & recovery
Section titled “90-day refresh token & recovery”- Mỗi lần refresh, Shopify trả refresh token mới với hạn 90 ngày mới (sliding window) và vô hiệu hoá refresh token cũ ngay. Shop nào còn được app gọi thường xuyên thì refresh token không bao giờ hết hạn.
- Nếu refresh token hết hạn (sau 90 ngày không hoạt động): không refresh được nữa → merchant mở lại app để token exchange cấp cặp token mới (không cần reinstall, không cần duyệt scope lại).
forceRefreshAccessTokensẽ throw “Merchant must re-authorize”. - Code chạy nền (cron/pubsub/webhook) phải dùng
getValidShopToken/getShopifyApiWithValidToken, nếu không sẽ 401 ~1h sau khi shop migrate.
Verify sau khi deploy
Section titled “Verify sau khi deploy”- Session doc của shop đã migrate có
refreshTokenHash+accessTokenExpiresAt. - Embedded request: hoạt động bình thường, token tự refresh.
- Background job gọi sau mốc 1h vẫn chạy (do
getValidShopTokenrefresh).
Checklist
Section titled “Checklist”- Bump
@avada/corelên 5.5.1 (tối thiểu 5.3.0) — dưới 5.2.0, nhận diện cài đặt nhầm sẽ reset shop đang trả tiền về free; dưới 5.3.0 còn lỗi bảo mật OAuth; app bậtexpiringOfflineTokenmà dưới 5.4.1 thì lỗi 403 token non-expiring chạy install hooks, dưới 5.5.0 thì callback OAuth embedded vẫn cấp token non-expiring; dưới 5.5.1 thì grant OAuth non-expiring vẫn giữ refresh metadata cũ. -
app/uninstalled,shop/update(+themes/publishnếu dùng) và 3 webhook quyền riêng tư đã khai báo trongshopify.app.tomlcủa từng môi trường (prod + mọi staging), đãshopify app deploy, và gỡ thử thấy logHandling uninstalling for(xem Khai báo webhook trongshopify.app.toml). - Toml đã đủ topic thì đặt
registerLifecycleWebhooks: falsetrên cảshopifyAuthvàverifyEmbedRequest; chưa đủ thì giữtrue(xem Core cũng đăng ký qua API (5.4.0)). - Code app không tự ghi
isInstalled: false/hasUninstalled: true— khi gặp 401 thì refresh, phân loại lỗi, và chỉ dừng việc đang làm (xem Code app coi 401 là “đã gỡ app”). -
initShopifyasync +getValidShopToken. -
awaittất cả call sites (grep = 0 bare calls); không cóawaittrong default param. - Bật
expiringOfflineTokentrênverifyEmbedRequest+shopifyAuth(shopifyChargeKHÔNG cần). - Route các bypass
new Shopify({accessToken})quagetValidShopToken. - Bật
autoMigrateOfflineToken(hoặc gọimigrateToExpiringToken) để migrate shop cũ khi merchant mở app. - Migrate headless phần còn lại bằng
migrateLegacyOfflineToken(5.5.0): dry run → canary vài shop bằng nút 1 shop, đợi qua mốc 1h → migrate all (xem Mẫu: nút DevZone). - Trước khi “Migrate all”, canary từng shop bằng Check shop (legacy → migrate → force refresh → qua mốc 1h không refresh), và lần mở admin kế tiếp banner DevZone hiện
migrated(xem Mẫu: banner trạng thái session + nút Check shop). - Endpoint migrate trong DevZone kiểm tra staff ở server (route DevZone merchant nào cũng gọi được), và run thật cho cả fleet bắt buộc có cụm xác nhận ở server, không chỉ ở modal.
- Chạy lại grep bước 3 sau lần merge master CUỐI CÙNG — build xanh không chứng minh được gì.
- Test stub nào mock
@avada/corethì phải export thêmgetValidShopToken. - Build/CI sạch; deploy staging; kiểm tra session có
refreshTokenHash. -
commands//scripts/và dev/mock tooling (build client từ token truyền vào) — làm sau, rủi ro thấp.
Claude Skill (download)
Section titled “Claude Skill (download)”Có sẵn bản Claude/agent skill cho migration này. Tải về và đặt vào repo của bạn tại .claude/skills/expiring-offline-tokens-migration/SKILL.md (và .agent/skills/... nếu dùng), rồi agent sẽ tự dùng làm runbook khi bạn migrate.
Nội dung skill (copy nhanh bằng nút copy ở góc phải):
---name: expiring-offline-tokens-migrationdescription: Migrate an Avada Shopify app to @avada/core v5 Shopify expiring offline access tokens. Use when bumping @avada/core to 5.x, adopting getValidShopToken, enabling expiringOfflineToken, or when background jobs 401 ~1h after the last admin visit. Covers the initShopify async conversion, the flag, the audit, build verification, the existing-shop migration gap, and the headless bulk migration (`migrateLegacyOfflineToken` behind a Dev Zone button).---
# Migrate an app to expiring offline access tokens (@avada/core v5)
Shopify requires expiring offline access tokens for public apps (new apps since 2026-04-01; all public apps by 2027-01-01). Access tokens live ~1h (`expires_in: 3600`), rotated by a 90-day `refresh_token`. `@avada/core` ≥ `5.0.0-alpha.6` provides the machinery; **use 5.5.1** (never below 5.3.0) — earlier v5 releases can reset a paying merchant to the free `initialPlan` when install detection misfires (see *Do the install hooks reset the plan?*), and below 5.5.0 the embedded OAuth callback ignores `expiringOfflineToken` (see step 5). This skill is the per-app adoption runbook.
## The two-switch mental model (BOTH are required)
| Switch | What | Without it ||---|---|---|| `expiringOfflineToken: true` on auth options | **acquire** — Shopify mints an expiring token + refresh_token | tokens stay non-expiring; nothing to refresh || `getValidShopToken` in `initShopify` | **consume** — refresh before expiry | expiring tokens lapse after ~1h → `401` |
Shipping one without the other is the #1 mistake: flag-only → 401s after 1h; getValidShopToken-only → silent no-op.
## Source of truth: session vs shop record
- **Session** (`shopifySession/offline_{shop}`) = credentials. It holds `accessTokenHash`, `refreshTokenHash`, `accessTokenExpiresAt`. `getValidShopToken`/`getValidAccessToken` refresh here. Deleted on uninstall.- **Shop record** (`shops`) = lifecycle/install state (`isInstalled`). It does NOT store the refresh token. Never read the refresh token from it.
## Step-by-step
### 1. Bump @avada/coreEdit `packages/functions/package.json` → `"@avada/core": "5.5.1"`, then `yarn install`. Don't go below 5.3.0: anything below 5.2.0 can downgrade a paying shop on a misdetected install, and anything below 5.3.0 has an OAuth security flaw (SOLAR-593). With `expiringOfflineToken` on, use 5.5.1: below 5.4.1 the non-expiring-token 403 was handled as a revoked token and ran the install hooks (a fake `installed` event, a reset `lastInstalledAt`), and below 5.5.0 the embedded OAuth callback mints non-expiring tokens (step 5), and below 5.5.1 an OAuth grant that returns no refresh token left the earlier `refreshTokenHash` and expiries in the session, so `getValidShopToken` kept refreshing with a dead refresh token (COURSE-136). Coming from 5.2.x, also choose `registerLifecycleWebhooks` from the app's toml (*Core registers them too (5.4.0)*); core now defaults to Admin API `2026-04` unless the app passes `apiVersion`. Coming from 5.4.x nothing is required: 5.5.0 adds `migrateLegacyOfflineToken` (*Headless / bulk migration*) and deletes leftover plaintext `accessToken`/`refreshToken` fields whenever it rewrites a session or shop, so read tokens through core, never through those plaintext fields. Coming from 5.5.0, 5.5.1 is a drop-in fix; it matters most for an app that turns `expiringOfflineToken` on and later off again. The sandbox link step may EACCES on root-owned `node_modules`; the **lockfile still updates correctly** and CI installs clean.
### 2. Make `initShopify` async via `getValidShopToken``getValidShopToken(shop, shopifyConfig)` returns `{shopifyDomain, accessToken}` (same shape as `prepareShopData`), refreshing via the session. `shopifyConfig` already has `{apiKey, secret, accessTokenKey}`.
```jsimport {getValidShopToken} from '@avada/core';
export async function initShopify(shop, apiVersion = API_VERSION) { const {shopifyDomain, accessToken} = await getValidShopToken(shop, shopifyConfig); return new Shopify({shopName: shopifyDomain, accessToken, apiVersion, autoLimit: true});}```Keep the app's own `shopify-api-node` — core no longer bundles it (v5). `getValidShopToken` refreshes a session that holds a refresh token; for a legacy session or no session it returns the shop-record token, so it's backward-safe. Before 5.4.0 a legacy session's own token won, which could be stale after a legacy OAuth wrote a new one to the shop record.
### 3. Convert ALL `initShopify(` call sites to `await``initShopify` is now async, so every caller must `await`. Three call forms exist — convert all:`= initShopify(` , `shopify: initShopify(` (object prop) , `fn(initShopify(` (arg).
Safe per-file regex (excludes `commands/`/`scripts/`, protects the `function initShopify` def, avoids double-await):```bashgrep -rl "initShopify(" packages/functions/src --include="*.js" \ | grep -vE "/commands/|/scripts/" \ | while read -r f; do perl -i -pe 's/(?<!function )(?<!await )\binitShopify\(/await initShopify(/g' "$f" done# verify: 0 bare calls leftgrep -rn "initShopify(" packages/functions/src --include="*.js" \ | grep -vE "/commands/|/scripts/" \ | grep -vE "function initShopify|import |from '|await initShopify\(" | wc -l # → 0```
### 4. Client factories that build via initShopify (e.g. makeGraphQlApi)If a helper like `makeGraphQlApi` does `shopify = initShopify(shop)` internally and is already `async`, the regex in step 3 already fixed its internal call → its own (many) callers need no change. Just confirm such factories are `async`.
### 5. Enable the flag on token-acquisition option blocksSet `expiringOfflineToken: true` on **`verifyEmbedRequest`** (token-exchange, embedded apps) and **`shopifyAuth`** (OAuth install). `verifyRequest()` does no token exchange — skip it.
**Below 5.5.0 the embedded OAuth callback ignores the flag.** `/shopify/embed/callback` and `/shopify/embed/callback/updateScopes` (also reached for optional scopes and scope changes) exchanged the code without `expiring`, so every embedded OAuth install, reinstall or scope update received a non-expiring token. On a public app created on or after 2026-04-01 Shopify rejects that token at once with the 403: the callback's own `shop.json` lookup fails before the install hooks run, and the shop only recovers on its next embedded request (COURSE-129). 5.5.0 sends `expiring: 1` there. If such an app ran an older core, shops it installed through embedded OAuth may be missing their install side effects (initial plan, lifecycle webhooks, `afterInstall`) — check them.
**`shopifyCharge` needs nothing either.** Verified against 5.0.1's build: the flag is read in exactly three files — `controllers/authController.js`, `services/shopifyAuthService.js`, `helpers/verifyEmbedRequest/verifyToken.js` — and none of `charge.js` / `shopifyCharge.js` / `chargeRepository.js` touches a token or an exchange. Setting it there is a harmless no-op; don't go hunting for it. Confirm for your own version with:```bashgrep -rln "expiringOfflineToken" node_modules/@avada/core/build | grep -v '\.d\.ts'```
### 6. Build-verify (catches the await-in-non-async trap)```bashcd packages/functions && node esbuild.config.js --production```esbuild fails on `await` in a **default parameter** — e.g. `function f(shop, shopify = await initShopify(shop))`. Fix by moving it into the body:```jsexport async function f(shop, shopify) { if (!shopify) shopify = await initShopify(shop); ...}```A "Failed to write to output file … permission denied" on `lib/*` is the root-owned-output sandbox issue, NOT a code error — it means parsing/bundling already succeeded. CI builds clean.
> ⚠️ **The build does NOT catch a _missing_ `await`.** It only rejects `await` in a position the> parser forbids (a default param, a non-async function). `const shopify = initShopify(shop)` is> valid JavaScript, so esbuild bundles it happily — it does no type analysis at all. Verified by> deleting an `await` from a converted call site: the full production build still reported> "Build completed!" across all 13 bundles.>> **The grep in step 3 is the only detector.** Never treat a green build as proof the conversion> is complete.
### 7. Audit token-read BYPASSES (the part the regex can't catch)Anything that builds a client WITHOUT `initShopify` will 401 ~1h after a shop migrates:```bashgrep -rn "new Shopify(" packages/functions/src --include="*.js" | grep -vE "/commands/|/scripts/"grep -rn "X-Shopify-Access-Token" packages/functions/src --include="*.js"grep -rn "prepareShopData" packages/functions/src --include="*.js"```Triage (real vs false positive):- **Real:** `new Shopify({accessToken})` where `accessToken` comes from the shop record / a stale payload → route through `getValidShopToken`.- **False positives:** the app's OWN local `prepareShopData` (e.g. a customer.io profile builder); `X-Shopify-Access-Token: partnerKey` (Partner API key, not a shop token); the line inside `makeGraphQlApi` (already covered).
### 8. Deploy to staging and verifyPush the branch to the staging branch/slot the app's `.gitlab-ci.yml` deploys from (repoint the slot's `only:` to your branch if needed). Then confirm a migrated session shows **`refreshTokenHash` + `accessTokenExpiresAt`** in Firestore.
## CRITICAL: existing shops do NOT migrate just by logging in
`verifyToken` only re-acquires a token when the current one is invalid (`checkIfActiveAccessToken` → false). A **non-expiring** token is always valid → the re-exchange never runs → the session never upgrades. So with `expiringOfflineToken` alone, already-installed shops keep their non-expiring token forever (only *new* installs/re-auths get expiring tokens). Confirm by inspecting a session doc: if it has `accessTokenHash` but no `refreshTokenHash`/`accessTokenExpiresAt`, it hasn't migrated.
Migration needs a token exchange. Shopify accepts **two subjects**, and that choice decides whether a merchant must be in the browser:
| Subject | `subject_token_type` | Embedded request? | In core? ||---|---|---|---|| App Bridge session token | `...oauth:token-type:id_token` | **yes** | yes — `migrateToExpiringToken` || The old non-expiring offline token | `urn:shopify:params:oauth:token-type:offline-access-token` | **no** — server-to-server | yes, from 5.5.0 — `migrateLegacyOfflineToken` |
The three triggers below use the first, so they all need an embedded request (all require `@avada/core` ≥ 5.0.0-alpha.7). The second is *Headless / bulk migration*.
- **Automatic (config):** set `autoMigrateOfflineToken: true` **and** `expiringOfflineToken: true` on the auth option blocks (`verifyEmbedRequest`, `shopifyAuth`). `verifyToken` re-exchanges a shop with a token but no refresh token on its next embedded request — one-time per shop, no app code, no merchant action.
The re-exchange runs only **after** `checkIfActiveAccessToken` confirms the current token is still live. That ordering is the guard: a session holding a *revoked* non-expiring token has an access token and no refresh token, byte for byte the migration signature. Check the token first and the two are distinguishable; check the signature first and a reinstall silently becomes a "migration", skipping `initialPlan`/`registerWebhooks`/`afterInstall`. Core's own comment calls this out as a bug it already hit: *"Letting needsMigration short-circuit it meant a session holding a REVOKED non-expiring token took the migration branch."*
From **5.4.1** the liveness check also accepts the 403 `Non-expiring access tokens are no longer accepted for the Admin API` as live when it is about to migrate (`checkIfActiveAccessToken(session, needsMigration)`), so a shop whose legacy token Shopify already rejects is migrated instead of being run through the install hooks. A 401 still means revoked.
### Which branch runs the install hooks
`isInstalled` is **not** consulted on the migration path — but it is not irrelevant to `verifyToken` either, and knowing exactly where it applies is what answers "why did/didn't my install hooks fire":
```checkIfActiveAccessToken(session, needsMigration)├─ live → needsMigration ? re-exchange (migration) → hooks NEVER run, isInstalled not read│ (from 5.4.1 the non-expiring-token 403 counts as live when needsMigration)└─ dead → recoverInvalidToken(session) → one of four outcomes: ├─ transient → 503, decide nothing and let the client retry ├─ refreshed → routine expiry; rotated tokens carried forward ├─ revoked → genuine (re)install → hooks RUN, isInstalled not read └─ refresh-expired → 90-day dormant shop → hooks run ONLY IF the shop record is absent or isInstalled === false```
Two consequences worth knowing:
- **`revoked` is webhook-independent, by design.** `isInstalled: false` is written by exactly one thing — the `APP_UNINSTALLED` webhook — which cannot fire if it was never registered. So a reinstall on a shop whose uninstall webhook never arrived is still detected. That is why this branch deliberately does *not* consult `isInstalled`.- **`refresh-expired` consults it deliberately.** A shop dormant past the 90-day refresh cliff needs a new token but never uninstalled; treating that as an install would re-create the initial plan for a live merchant. Here the webhook's silence is the correct answer.
A legacy non-expiring shop whose token is dead classifies as **`revoked`**, not `refresh-expired` — core's reasoning: *"A non-expiring offline token never expires, so one the API rejected can only have been revoked."*- **Explicit (function):** `await migrateToExpiringToken(ctx, {apiKey, secret, accessTokenKey})` from an embedded handler (e.g. `afterLogin`) for app-controlled timing. No-op if already expiring.- **Force one shop (to test):** delete its `shopifySession/offline_{shop}` doc (or its `accessTokenHash`) in Firestore, then reload the embedded app → it re-exchanges with `expiring:1` → the doc reappears with `refreshTokenHash` + `accessTokenExpiresAt`.
**Background-only shops** (never opened in admin) can't be migrated by the three above — no session token reaches them. Use the headless path, which also migrates the rest of the fleet without waiting for merchants.
### Do the install hooks reset the plan? (fixed in 5.2.0)
"Hooks run" used to mean `builder.create(initialPlan)`, which **cancels the live subscription and sets `shop.plan` to the initial (free) plan**. With expiring tokens the `revoked` / `refresh-expired` outcomes can fire for a shop that never uninstalled:
- a refresh whose response was lost after Shopify rotated the token, so the stored refresh token is already dead (Shopify replays the response for ~1h, not longer);- a refresh token past its 90 days on a quiet shop, plus a stale `isInstalled: false` — which app code writes itself when it treats a 401 as an uninstall (see *Treating a 401 as an uninstall in app code*).
**Below 5.2.0 that silently downgrades a paying merchant while Shopify keeps billing them.** `Builder.create` has a guard ("Refusing to reset active paid subscription"), but it requires the subscription's `endsAt` to be in the future, and `endsAt` is written once at creation and never renewed. On Joy production (2026-10-01) **58 of 59 paying shops** were not protected by it. Nothing restores the plan afterwards: the reset adds a new *free* subscription doc, so "restore from the newest subscription" logic sees free.
**5.2.0** routes all three install paths (`handleAfterInstall`, the OAuth callback, `handleInstall`) through `applyInitialPlan`: the initial plan is created **only when the shop has no live subscription** (no doc, or the newest one cancelled). A genuine reinstall still lands on the initial plan, because the uninstall and store-closed webhooks already cancelled the subscription. A skip logs `Skipped initial plan "<id>" for shop <id>`.
Accepted trade-off: a reinstall whose `APP_UNINSTALLED` webhook never arrived keeps its previous plan, unbilled. That errs towards the merchant on purpose — and is why that webhook must be declared in `shopify.app.toml` in every environment (*Declare the webhooks in `shopify.app.toml`*). If it matters for your app, reconcile plans against Shopify billing (`currentAppInstallation.activeSubscriptions`) in a separate job.
`builder.create()` itself is unchanged in 5.2.0 — app code that downgrades a shop on purpose must still pass `allowDowngrade = true`.
**Non-embedded OAuth callback (5.4.0).** A shop with a recorded uninstall (`isInstalled: false`) that comes back through `shopifyAuth`'s OAuth callback with `expiringOfflineToken` goes straight to install and runs `afterInstall`. A transient refresh error still returns an error rather than reinstalling. From 5.4.0 hooks can read `ctx.state.isShopifyInstall` to tell whether the current request is an install.
## Headless / bulk migration (no merchant needed)
Shopify: *"Use this migration flow if you don't want to wait for users to open your app and trigger a new token acquisition."* It exchanges the **old offline token itself** with the app's client credentials — no ID token, no user session:
```bashcurl -X POST https://{shop}/admin/oauth/access_token \ -H 'Content-Type: application/x-www-form-urlencoded' -H 'Accept: application/json' \ -d 'client_id={client_id}' -d 'client_secret={client_secret}' \ -d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \ -d 'subject_token={non_expiring_offline_token}' \ -d 'subject_token_type=urn:shopify:params:oauth:token-type:offline-access-token' \ -d 'requested_token_type=urn:shopify:params:oauth:token-type:offline-access-token' \ -d 'expiring=1'```
From **5.5.0** core sends that request for you — don't hand-roll it:
```jsimport {migrateLegacyOfflineToken} from '@avada/core';
// shopifyConfig = {apiKey, secret, accessTokenKey}const result = await migrateLegacyOfflineToken(shopifyDomain, legacyAccessToken, shopifyConfig);// {migrated: true, accessTokenExpiresAt, refreshTokenExpiresAt}// {migrated: false, reason: 'already-expiring'} when the session already holds a refresh token```
What core guarantees:- it validates the `*.myshopify.com` domain before sending credentials, and refuses a shop that has no shop record;- it writes the encrypted pair to `shopifySession/offline_{shop}` **first**, then the shop's token and expiry mirrors. A retry after a half-finished run repairs the shop record from the session, and it never overwrites a shop token that is neither the legacy token nor the session's;- it never runs install hooks;- concurrent calls for one shop **inside one process** share a single exchange. Errors are thrown with `statusCode`, and Shopify's body is in `message`.
What stays yours:- **Pass the token on the shop record:** `prepareShopData(shop.id, shop, accessTokenKey).accessToken`, the token `getValidShopToken` uses for a legacy shop. A legacy session's own token can be older (*Source of truth*).- **`400 {"error":"invalid_subject_token"}`** means the old token is no longer active (uninstalled, revoked, already exchanged). Only a re-auth fixes it, so don't retry it in a loop.- **Serialize per shop across processes.** Run one worker at a time (`maxInstances: 1`) and one shop at a time, and run it off-peak: an embedded request with `autoMigrateOfflineToken` can acquire a token for the same shop at the same moment, and core does not lock across the two.
**IRREVERSIBLE, one shot per shop.** The exchange permanently invalidates the old token for GraphQL Admin API requests (REST keeps accepting it); the replacement lives 1h. Shopify replays a **lost** migration response when you repeat the exchange with the original token within 7 days, and re-running `migrateLegacyOfflineToken` with the same shop-record token is exactly that retry: the shop record keeps the legacy token until the run that saved the session also updates the shop. Past those 7 days an unsaved pair locks the shop out. Prove a small batch survives past the 1h expiry (its session's `accessTokenExpiresAt` moves forward, so a refresh worked) before widening. Only shops whose old token still works can migrate this way.
### Sample: Dev Zone buttons — migrate one shop, or every shop behind a confirmation
Four pieces: staff-only Dev Zone endpoints (migrate one shop now; start a fleet run), a Pub/Sub worker that pages through installed shops and republishes itself before the function timeout, a per-shop result log in Firestore, and the Dev Zone card. A real fleet run needs a typed confirmation phrase, checked by the **server** as well as the UI, so a stray API call cannot start one. Paths and helpers (`publishTopic`, `getCurrentShopData`, `useCreateApi`, `@functions/config/shopify`) follow Joy's layout; swap in your app's equivalents.
```js// packages/functions/src/services/legacyTokenMigrationService.jsimport {FieldPath, Firestore} from '@google-cloud/firestore';import {firestoreSession, migrateLegacyOfflineToken, prepareShopData} from '@avada/core';import shopifyConfig from '@functions/config/shopify';import publishTopic from '@functions/helpers/pubsub/publishTopic';
export const MIGRATION_TOPIC = 'legacyTokenMigration';export const CONFIRM_PHRASE = 'MIGRATE ALL';export const ALL_SHOPS = Number.MAX_SAFE_INTEGER;const PAGE_SIZE = 50;const TIME_BUDGET_MS = 6 * 60 * 1000; // well inside the 540s function timeoutconst ACTIVE_RUN_MS = 15 * 60 * 1000; // a run with no heartbeat for this long has died
const firestore = new Firestore();const shopsRef = firestore.collection('shops');export const runsRef = firestore.collection('legacyTokenMigrationRuns');
/** * The shop document for a domain, or null. */export async function getShopDoc(shopifyDomain) { const {docs} = await shopsRef.where('shopifyDomain', '==', shopifyDomain).limit(1).get(); return docs[0] || null;}
/** * Whether a fleet run is still working. A run that died halfway keeps status `running`, so trust * its heartbeat rather than its status. */export async function hasActiveRun() { const {docs} = await runsRef.where('status', '==', 'running').get(); return docs.some(doc => Date.now() - doc.get('heartbeatAt').toMillis() < ACTIVE_RUN_MS);}
/** * Migrate one shop or, in a dry run, only classify it. Never throws: the outcome is the result. */export async function migrateShop(shopDoc, dryRun) { const {shopifyDomain} = shopDoc.data(); try { const {accessToken} = prepareShopData(shopDoc.id, shopDoc.data(), shopifyConfig.accessTokenKey); if (!accessToken) return {status: 'no-token'}; if (dryRun) { const session = await firestoreSession.loadCallback(`offline_${shopifyDomain}`, shopifyConfig.accessTokenKey); return {status: session?.refreshToken ? 'already-expiring' : 'would-migrate'}; } // Call core even for already-expiring sessions: it skips Shopify for them, but repairs a // shop record that an earlier, half-finished run left on the legacy token. const result = await migrateLegacyOfflineToken(shopifyDomain, accessToken, shopifyConfig); return {status: result.migrated ? 'migrated' : result.reason}; } catch (e) { const status = e.statusCode === 400 && /invalid_subject_token/.test(e.message) ? 'needs-reauth' : 'failed'; return {status, error: String(e.message).slice(0, 500)}; }}
/** * Pub/Sub worker. Handles shops one at a time and republishes itself with a cursor before the * time budget runs out, so a fleet of any size finishes without hitting the function timeout. */export async function handleLegacyTokenMigration({runId, dryRun = true, limit = ALL_SHOPS, cursor = null, processed = 0}) { const startedAt = Date.now(); let done = false; while (!done && Date.now() - startedAt < TIME_BUDGET_MS) { const pageSize = Math.min(PAGE_SIZE, limit - processed); let query = shopsRef.where('isInstalled', '==', true).orderBy(FieldPath.documentId()).limit(pageSize); if (cursor) query = query.startAfter(cursor); const {docs} = await query.get();
for (const doc of docs) { const outcome = await migrateShop(doc, dryRun); await runsRef.doc(runId).collection('shops').doc(doc.id) .set({shopifyDomain: doc.get('shopifyDomain'), ...outcome, at: new Date()}); console.log(`Legacy token migration ${runId}: ${doc.get('shopifyDomain')} -> ${outcome.status}`); } processed += docs.length; if (docs.length) cursor = docs[docs.length - 1].id; done = docs.length < pageSize || processed >= limit; }
if (!done) { await runsRef.doc(runId).update({processed, heartbeatAt: new Date()}); await publishTopic(MIGRATION_TOPIC, {runId, dryRun, limit, cursor, processed}); return; } await runsRef.doc(runId).update({status: 'done', processed, finishedAt: new Date()});}```
```js// packages/functions/src/controllers/devZone/legacyTokenMigrationController.jsimport {getCurrentShopData} from '../../helpers/auth';import publishTopic from '../../helpers/pubsub/publishTopic';import { ALL_SHOPS, CONFIRM_PHRASE, MIGRATION_TOPIC, getShopDoc, hasActiveRun, migrateShop, runsRef} from '../../services/legacyTokenMigrationService';
// Dev Zone routes live in the embedded API, which every merchant can call. These act on any shop,// so check staff on the server; hiding the buttons is not a guard.const STAFF_SHOPS = (process.env.DEV_ZONE_STAFF_SHOPS || '').split(',').filter(Boolean);
function fail(ctx, status, error) { ctx.status = status; ctx.body = {success: false, error};}
function rejectNonStaff(ctx) { if (STAFF_SHOPS.includes(getCurrentShopData(ctx).shopifyDomain)) return false; fail(ctx, 403, 'Forbidden'); return true;}
/** * Migrate one shop now. Defaults to the shop the Dev Zone is open on. */export async function migrateOne(ctx) { if (rejectNonStaff(ctx)) return; const {shopifyDomain = getCurrentShopData(ctx).shopifyDomain, dryRun = false} = ctx.req.body || {}; // The fleet worker may be on this very shop, and core does not lock across instances. if (await hasActiveRun()) return fail(ctx, 409, 'A fleet run is in progress. Wait for it to finish.'); const shopDoc = await getShopDoc(shopifyDomain); if (!shopDoc) return fail(ctx, 404, `No shop record for ${shopifyDomain}`); ctx.body = {success: true, data: {shopifyDomain, ...(await migrateShop(shopDoc, dryRun))}};}
/** * Start a fleet run. A real run needs the confirmation phrase: checked here, not only in the UI. */export async function start(ctx) { if (rejectNonStaff(ctx)) return; const {dryRun = true, limit = ALL_SHOPS, confirm} = ctx.req.body || {}; if (!dryRun && confirm !== CONFIRM_PHRASE) return fail(ctx, 400, `Type "${CONFIRM_PHRASE}" to start a real run.`); if (await hasActiveRun()) return fail(ctx, 409, 'Another run is in progress.'); const runRef = runsRef.doc(); const now = new Date(); await runRef.set({dryRun, limit, status: 'running', startedAt: now, heartbeatAt: now}); await publishTopic(MIGRATION_TOPIC, {runId: runRef.id, dryRun, limit}); ctx.body = {success: true, data: {runId: runRef.id}};}
export async function summary(ctx) { if (rejectNonStaff(ctx)) return; const run = await runsRef.doc(ctx.params.runId).get(); const {docs} = await run.ref.collection('shops').select('status').get(); const counts = {}; docs.forEach(doc => { counts[doc.get('status')] = (counts[doc.get('status')] || 0) + 1; }); ctx.body = {success: true, data: {...run.data(), counts}};}```
```js// routes/api.js (the embedded API router)router.post('/dev-zone/legacy-token-migration/shop', jsonType, legacyTokenMigrationController.migrateOne);router.post('/dev-zone/legacy-token-migration', jsonType, legacyTokenMigrationController.start);router.get('/dev-zone/legacy-token-migration/:runId', legacyTokenMigrationController.summary);
// index.js: one worker at a time, so two migrations of one shop never overlapexport const legacyTokenMigration = functions .runWith({timeoutSeconds: 540, memory: '512MB', maxInstances: 1}) .pubsub.topic(MIGRATION_TOPIC) .onPublish(message => handleLegacyTokenMigration(message.json));```
```jsx// packages/assets/src/components/DevZone/LegacyTokenMigration.jsimport React, {useState} from 'react';import {BlockStack, Button, Card, InlineStack, Modal, Text, TextField} from '@shopify/polaris';import useCreateApi from '../../hooks/api/useCreateApi';import {fetchAuthenticatedApi} from '../../helpers';
const CONFIRM_PHRASE = 'MIGRATE ALL'; // must match the server's
/** * Dev zone: migrate legacy offline tokens headlessly, for one shop now or for every shop in the * background. The fleet run sits behind a typed confirmation. * * @returns {JSX.Element} */export default function LegacyTokenMigration() { const [shopifyDomain, setShopifyDomain] = useState(''); const [result, setResult] = useState(null); const [run, setRun] = useState(null); const [confirming, setConfirming] = useState(false); const [phrase, setPhrase] = useState(''); const one = useCreateApi({url: '/dev-zone/legacy-token-migration/shop', successCallback: resp => setResult(resp.data)}); const fleet = useCreateApi({url: '/dev-zone/legacy-token-migration', successCallback: resp => setRun(resp.data)});
const closeConfirm = () => { setConfirming(false); setPhrase(''); }; const startFleetRun = async () => { await fleet.handleCreate({dryRun: false, confirm: phrase}); closeConfirm(); }; const refreshRun = async () => { const resp = await fetchAuthenticatedApi(`/dev-zone/legacy-token-migration/${run.runId}`); setRun({runId: run.runId, ...resp.data}); };
return ( <Card> <BlockStack gap="400"> <Text as="h2" variant="headingMd">Legacy token migration</Text> <InlineStack gap="200" blockAlign="end"> <TextField label="Shop domain (empty: this shop)" value={shopifyDomain} onChange={setShopifyDomain} autoComplete="off" /> <Button loading={one.creating} onClick={() => one.handleCreate(shopifyDomain ? {shopifyDomain} : {})}> Migrate this shop </Button> </InlineStack> {result && <Text as="p">{result.shopifyDomain}: {result.status} {result.error || ''}</Text>} <InlineStack gap="200"> <Button loading={fleet.creating} onClick={() => fleet.handleCreate({dryRun: true})}>Dry run all shops</Button> <Button tone="critical" variant="primary" onClick={() => setConfirming(true)}>Migrate all shops</Button> {run && <Button onClick={refreshRun}>Refresh run</Button>} </InlineStack> {run && <Text as="p">Run {run.runId}: {run.status || 'running'} {JSON.stringify(run.counts || {})}</Text>} </BlockStack> <Modal open={confirming} onClose={closeConfirm} title="Migrate every shop?" primaryAction={{ content: 'Start migration', destructive: true, disabled: phrase !== CONFIRM_PHRASE, loading: fleet.creating, onAction: startFleetRun }} secondaryActions={[{content: 'Cancel', onAction: closeConfirm}]} > <Modal.Section> <BlockStack gap="200"> <Text as="p"> This exchanges the legacy offline token of every installed shop and cannot be undone: the old tokens stop working for the GraphQL Admin API. Do a dry run and a few single-shop migrations first. </Text> <TextField label={`Type ${CONFIRM_PHRASE} to confirm`} value={phrase} onChange={setPhrase} autoComplete="off" /> </BlockStack> </Modal.Section> </Modal> </Card> );}```
Roll it out in this order:1. Deploy core 5.5.1 with `expiringOfflineToken` on every token-acquisition block (step 5), and set `DEV_ZONE_STAFF_SHOPS`.2. **Dry run all shops**: read the `would-migrate` / `already-expiring` / `no-token` counts. Nothing is written to sessions.3. **Canary with "Migrate this shop"** on 3–5 shops (dev stores first, then a few real ones), then wait **more than 1h**: those sessions' `accessTokenExpiresAt` must have moved forward, and the app's background jobs must still work for those shops.4. **Migrate all shops** behind the typed confirmation. To stage it, call the API with a `limit` first (e.g. `{dryRun: false, confirm: 'MIGRATE ALL', limit: 100}`); `limit` counts scanned shops, and already-expiring shops cost no Shopify call.5. Re-run for `failed` within 7 days; the same shop-record token is the recovery key. Leave `needs-reauth` to the next app open (`autoMigrateOfflineToken`) or a re-auth, and don't retry it.
Pub/Sub delivers at least once, so a page can run twice; the second pass sees `already-expiring` and only re-saves state. A chain that stops halfway (a crash, a deploy) leaves its run `running`, but its heartbeat goes stale after 15 minutes; then start a new run, which rescans from the first shop.
### Sample: Dev Zone status banner + "Check shop"
The migrate buttons above *do* the migration. Seeing the result takes three more pieces, the way Joy's Dev Zone has them:
- A **status banner** at the top of Dev Zone. It shows whether the shop you are on has migrated and when each token expires, plus a **Run token exchange** button that re-runs the embedded exchange.- A **Check shop** button for any shop, by domain. It reads the session (expiring or legacy), then calls the Admin API through the **same** `initShopify` that webhooks and background jobs use. OK here means OK there.- A **Force refresh** option that rotates the access token before the check. This proves the refresh path now, without waiting for the 1h expiry.
The banner has four states:
| State | `shopifySession/offline_{shop}` | Tone | Meaning / action ||---|---|---|---|| No offline session found | no doc | critical | Uninstalled, or the session was deleted. Re-open the embedded app || Not migrated | `accessTokenHash`, no `refreshTokenHash` | warning | Still on the legacy token. Run token exchange, or wait for the next embedded request || Migrated | `refreshTokenHash` present | success | Shows the access-token (~1h) and refresh-token (~90d) expiries and when the session was written || Migrated (written just now) | same, `updateTime` < 2 min | success | This page load migrated the shop, **or** it just refreshed. Both write the same fields |
> ⚠️ The shop you have Dev Zone open on **almost always reads "migrated"**. Opening the admin sends embedded requests, and `autoMigrateOfflineToken` migrates on that same request before the banner reads the session. Treat the banner as a health check, not a rollout tracker. To look at a legacy shop, enter its domain in **Check shop** and **do not** open its admin.
Reading a Check shop result:
| Result | Meaning ||---|---|| `Legacy token` + `Admin API OK` | Not migrated yet and the old token is alive. Ready for "Migrate this shop" || `Expiring token` + `Admin API OK` | Migrated and working. This is the target state || `Refreshed` + the access-token expiry moves forward | The refresh path works (Force refresh only) || `Cannot refresh: shop is still on a legacy token` | Force refresh was ticked on a legacy shop. There is no refresh token yet || `Admin API failed` + 401 on a migrated shop | Something builds a client without `getValidShopToken`, or the refresh token is dead. Read the error || `Merchant must re-authorize` | The refresh token expired or was revoked. Opening the app mints a new pair |
`status` and `exchangeThisShop` only touch the shop the request comes from and return hashes, so they need no staff check. `check` reads another shop's session and calls its API, so it goes through `rejectNonStaff` on the server, like `migrateOne`.
```js// packages/functions/src/services/legacyTokenMigrationService.js (add to the sample above)import {forceRefreshAccessToken} from '@avada/core';import {initShopify} from './shopifyService';
const sessionsRef = new Firestore().collection('shopifySession');
/** * What the offline session says, without decrypting anything: hashes and expiries only. */export async function getSessionSummary(shopifyDomain) { const doc = await sessionsRef.doc(`offline_${shopifyDomain}`).get(); if (!doc.exists) return {hasSession: false, migrated: false}; const data = doc.data(); return { hasSession: true, migrated: Boolean(data.refreshTokenHash), accessTokenExpiresAt: data.accessTokenExpiresAt?.toDate?.() || null, refreshTokenExpiresAt: data.refreshTokenExpiresAt?.toDate?.() || null, // updateTime lives on the snapshot, not in data(): it dates the last exchange or refresh. sessionUpdatedAt: doc.updateTime.toDate() };}
/** * Is this shop on an expiring token, and does the Admin API still answer through initShopify, * the same path webhooks and background jobs use? forceRefresh rotates the access token first, * so the refresh path is proven without waiting an hour. */export async function checkShopHealth(shopDoc, forceRefresh = false) { const {shopifyDomain} = shopDoc; const before = await getSessionSummary(shopifyDomain); const result = {shopifyDomain, before, refreshed: false, api: {ok: false}}; try { if (forceRefresh) { // A legacy shop has no refresh token: core would throw "Merchant must re-authorize". if (!before.migrated) throw new Error('Cannot refresh: shop is still on a legacy token'); await forceRefreshAccessToken(shopifyDomain, shopifyConfig); result.refreshed = true; } const shopify = await initShopify(shopDoc); const resp = await shopify.graphql('{ shop { name } }'); result.api = {ok: true, shopName: resp.shop.name}; } catch (e) { result.api = {ok: false, error: String(e.message).slice(0, 500)}; } result.after = await getSessionSummary(shopifyDomain); return result;}```
```js// packages/functions/src/controllers/devZone/legacyTokenMigrationController.js (add)import {migrateToExpiringToken} from '@avada/core';import shopifyConfig from '../../config/shopify';import {checkShopHealth, getSessionSummary} from '../../services/legacyTokenMigrationService';
/** * Banner data for the shop the Dev Zone is open on. Own shop only, so no staff check. */export async function status(ctx) { const {shopifyDomain} = getCurrentShopData(ctx); ctx.body = {success: true, data: {shopifyDomain, ...(await getSessionSummary(shopifyDomain))}};}
/** * Re-run the embedded token exchange for this shop. Uses the App Bridge session token on the * request; core returns reason "already-expiring" when there is nothing to do. */export async function exchangeThisShop(ctx) { const result = await migrateToExpiringToken(ctx, shopifyConfig); const {shopifyDomain} = getCurrentShopData(ctx); ctx.body = {success: true, data: {...result, ...(await getSessionSummary(shopifyDomain))}};}
/** * Health check for any shop: staff only, since it reads another shop's session and calls its API. */export async function check(ctx) { if (rejectNonStaff(ctx)) return; const {shopifyDomain = getCurrentShopData(ctx).shopifyDomain, forceRefresh = false} = ctx.req.body || {}; const shopDoc = await getShopDoc(shopifyDomain); if (!shopDoc) return fail(ctx, 404, `No shop record for ${shopifyDomain}`); const data = await checkShopHealth(shopDoc, Boolean(forceRefresh)); ctx.body = {success: data.api.ok, data, error: data.api.error};}```
```js// routes/api.js (the embedded API router)router.get('/dev-zone/session-token-status', legacyTokenMigrationController.status);router.post('/dev-zone/session-token-migrate', jsonType, legacyTokenMigrationController.exchangeThisShop);router.post('/dev-zone/legacy-token-migration/check', jsonType, legacyTokenMigrationController.check);```
```jsx// packages/assets/src/components/DevZone/SessionTokenStatus.jsimport React from 'react';import {Badge, Banner, BlockStack, Button, InlineStack, Text} from '@shopify/polaris';import useFetchApi from '../../hooks/api/useFetchApi';import useCreateApi from '../../hooks/api/useCreateApi';
// Opening the admin fires embedded requests and autoMigrateOfflineToken runs on any of them.// A session written within this window migrated (or refreshed) because of this page load.const JUST_WRITTEN_MS = 2 * 60 * 1000;const formatDate = value => (value ? new Date(value).toLocaleString() : '-');
/** * Dev zone banner: is this shop on an expiring offline token, or still on the legacy one? */export default function SessionTokenStatus() { const {data, loading, reFetch} = useFetchApi('/dev-zone/session-token-status', {}); const exchange = useCreateApi({url: '/dev-zone/session-token-migrate', successCallback: () => reFetch()}); if (loading) return null;
const exchangeButton = ( <InlineStack> <Button loading={exchange.creating} onClick={() => exchange.handleCreate({})}>Run token exchange</Button> </InlineStack> );
if (!data.hasSession) { return ( <Banner tone="critical" title="Session token: no offline session found"> <Text as="p">No shopifySession/offline_{data.shopifyDomain} document. Re-open the embedded app to recreate it.</Text> </Banner> ); }
if (!data.migrated) { return ( <Banner tone="warning" title="Session token: not migrated"> <BlockStack gap="100"> <Text as="p">Legacy non-expiring offline token (no refresh token). It migrates on the next embedded request.</Text> {exchangeButton} </BlockStack> </Banner> ); }
const justWritten = Date.now() - new Date(data.sessionUpdatedAt).getTime() < JUST_WRITTEN_MS; return ( <Banner tone="success" title={justWritten ? 'Session token: migrated (written just now)' : 'Session token: migrated'}> <BlockStack gap="100"> <InlineStack gap="200"> <Badge tone="success">Token exchange</Badge> <Text as="span" tone="subdued">{data.shopifyDomain}</Text> </InlineStack> <Text as="p">Session updated: {formatDate(data.sessionUpdatedAt)}</Text> <Text as="p">Access token expires: {formatDate(data.accessTokenExpiresAt)}</Text> <Text as="p">Refresh token expires: {formatDate(data.refreshTokenExpiresAt)}</Text> {exchangeButton} </BlockStack> </Banner> );}```
```jsx// packages/assets/src/components/DevZone/LegacyTokenMigration.js: add a "Check shop" button// next to "Migrate this shop", reusing the same shop domain field.import {Badge, Checkbox} from '@shopify/polaris';
const [forceRefresh, setForceRefresh] = useState(false);const [health, setHealth] = useState(null);const checker = useCreateApi({url: '/dev-zone/legacy-token-migration/check', fullResp: true});const checkShop = async () => { const resp = await checker.handleCreate({...(shopifyDomain ? {shopifyDomain} : {}), forceRefresh}); setHealth(resp?.data || {api: {ok: false, error: resp?.error}});};
<Button loading={checker.creating} onClick={checkShop}>Check shop</Button><Checkbox label="Force refresh before the check (tests the refresh path without waiting 1h)" checked={forceRefresh} onChange={setForceRefresh}/>{health && ( <BlockStack gap="100"> <InlineStack gap="200"> <Badge tone={health.after?.migrated ? 'success' : 'warning'}> {health.after?.migrated ? 'Expiring token' : 'Legacy token'} </Badge> <Badge tone={health.api?.ok ? 'success' : 'critical'}> {health.api?.ok ? 'Admin API OK' : 'Admin API failed'} </Badge> {health.refreshed && <Badge tone="info">Refreshed</Badge>} </InlineStack> {health.api?.shopName && <Text as="p">Shop: {health.api.shopName}</Text>} {health.api?.error && <Text tone="critical">{health.api.error}</Text>} {health.after?.migrated && ( <Text as="p"> Access token expires: {formatDate(health.before?.accessTokenExpiresAt)} → {formatDate(health.after.accessTokenExpiresAt)} </Text> )} </BlockStack>)}```
```jsx// pages/DevZone/DevZone.js: both cards full width, at the top<Layout.Section variant="fullWidth"><SessionTokenStatus /></Layout.Section><Layout.Section variant="fullWidth"><LegacyTokenMigration /></Layout.Section>```
Canary one shop (this replaces step 3 of the rollout order above):1. Pick a shop whose session has only `accessTokenHash`, and **do not open its admin**.2. Check shop → `Legacy token` + `Admin API OK`.3. Migrate this shop → `migrated`.4. Check shop with Force refresh → `Expiring token` + `Admin API OK` + `Refreshed`.5. More than an hour later, Check shop without Force refresh → still `Admin API OK`. This time `initShopify` refreshes on its own, the background-job path.6. Open that shop's admin → the banner reads `migrated`, no install hooks ran, and the plan is unchanged.
## Long-lived branch: re-audit after EVERY merge from the main branch
The conversion is a cross-cutting contract change, so it collides with ongoing work in thenastiest possible way: **adding `await` is a pure insertion**, so a new `initShopify(` callsite written on `master` merges into your branch with **no conflict marker**, un-awaited andinvisible. Textual merge success ≠ semantic correctness.
Combined with the build blindness above, nothing in the toolchain will tell you. Re-run thestep 3 grep after every merge, and scope it to the whole `packages/` tree — new packages getadded while your branch is open:
```bashgrep -rn "initShopify(" packages --include="*.js" --include="*.mjs" --include="*.cjs" --include="*.ts" \ | grep -v node_modules | grep -vE "/commands/|/scripts/" \ | grep -vE "function initShopify|import |from '|await initShopify\(" \ | grep -vE "^[^:]+:[0-9]+: *(\*|//)" # drops JSDoc mentions; must print nothing```
Real numbers from Joy: **five** un-awaited sites arrived this way across three merges — three ofthem in a single day's catch-up of ~900 commits. Treat this as certain, not possible.
**The worst shape is silent.** One of them sat inside a bare `try { … } catch { }`:
```jstry { const shopify = initShopify(shop); // Promise, not a client const info = await shopify.shop.get({fields: 'domain'}); …} catch { // Admin API unreachable — fall back to what we already have}```
The `TypeError` on `shopify.shop` was swallowed whole. No log, no 500 — the function justquietly degraded to its fallback and rejected valid input. **Never assume a missed `await`shows up as an error in logs.**
## Test doubles that stub `@avada/core`
Any suite that replaces `@avada/core` with a hand-written stub will break the moment`initShopify` starts calling `getValidShopToken`, usually with`(0, import_core.getValidShopToken) is not a function`.
Add it to the stub — but **mirror core's real no-session branch** rather than returning adummy token. With no offline session, core falls back to the shop record's own token via`prepareShopData`, and throws when there isn't one:
```jsconst prepareShopData = (id, shop) => ({...shop, id});module.exports = { prepareShopData, getValidShopToken: async shop => { const {accessToken} = prepareShopData(shop.id, shop); if (!accessToken) throw new Error('No access token available for ' + shop.shopifyDomain); return {shopifyDomain: shop.shopifyDomain, accessToken}; }};```
A stub that just returns a token would green-light calls the real implementation rejects.
## What changes at runtime once `initShopify` is async
The conversion is mechanical, but it changes two properties of a function that used to befree and infallible. Every call site inherits both. Audit for these *before* the firstproduction deploy — none of them show up in tests, a build, or a staging smoke test.
### 1. Every call now costs up to one Firestore read
`getValidShopToken` reads the offline session document to tell an expiring shop from a legacy one:
| Shop | Firestore reads per call (5.4.0) ||---|---|| expiring (session holds a refresh token) | 1 || legacy, or no session | 1, then 0 for the next 60 seconds on the same instance |
That 60-second cache holds only the **classification** "this shop is legacy", never a token — thetoken always comes from the shop passed in. It lives in each instance's memory, so a cold start oranother instance reads again; don't count on it when estimating cost. Before 5.4.0 every call readthe session once.
Before the migration `initShopify` was a constructor call costing nothing. After it, an appwith 150+ call sites has added one Firestore read to every one of them. This is a cost andlatency change, not a correctness one, but it compounds in fan-out paths.
**The fix is a parameter, not a cache.** Functions that may be called repeatedly for one shopshould accept an optional client and only build one when they weren't given one:
```jsexport async function updateMetafields({shopId, shopify}) { if (!shopify) { const shop = await getShopById(shopId); shopify = await initShopify(shop); // once, not once per item } …}```
Callers in a loop then build the client once and pass it down. Grep for call sites where thesame shop's client is rebuilt inside an iteration and hoist those; don't try to add a cacheinside `initShopify`, because a stale cached token is far worse than an extra read. Core cachesonly the classification for the same reason.
### 2. `initShopify` can now throw, and a swallowing `catch` turns that into silent data loss
This is the one that actually bites. Two new error shapes reach every call site:
| Thrown by | Message | Means ||---|---|---|| `getValidShopToken` | `No access token available for {shop}. Merchant must re-authorize.` | no expiring session *and* no usable shop-record token || `performRefresh` | `Failed to refresh access token for {shop}. Merchant may need to re-authorize.` | refresh grant failed; `error.isRefreshTokenRevoked` distinguishes a dead authorization from an exhausted transient retry |
Before the migration, `initShopify` could not fail — it built a client from whatever token theshop record held, and a bad token only surfaced later as a `401` from the actual API call.Afterwards the failure moves *earlier*, into the client construction itself.
That matters because of where it lands. A webhook handler shaped like this looks harmless:
```jstry { const shopify = await initShopify(shop); // ← can now throw const customer = await getShopifyCustomer(shopify, id); … return (ctx.body = {success: true});} catch (e) { console.error(`Error handling the order webhook ${e?.message}`); return (ctx.body = {success: false, error: e.message}); // ← HTTP 200}```
Koa sends **200** for that body. Shopify treats 200 as delivered and **never retries**. So atransient token-refresh failure silently drops that order: no points, no retry, nothing but one`console.error` line to explain it to the merchant three weeks later.
Note the blast radius *widened* rather than appearing from nothing — a genuinely dead tokenalready reached this catch via a `401`. What is new is that a perfectly healthy shop can landthere during a momentary refresh failure.
**Audit for this shape:** any `catch` around a converted call site that returns a 2xx. For each,decide deliberately:
- **Token-class errors should be retryable.** Return a non-2xx for them so Shopify redelivers — the retry will usually succeed, because the refresh that failed will have completed by then.- **At minimum, make it loud.** Log token failures at a level you actually alert on. A silent 200 is indistinguishable from success in every dashboard you have.
### 3. Refresh inside a webhook's response budget
Shopify gives a webhook ~5 seconds. If a converted call site sits in the synchronous part of awebhook handler, the refresh now happens inside that budget, and the refresh path is not bounded:
- `performRefresh` retries transient failures **twice**, with 500 ms and 1000 ms backoff (`TRANSIENT_REFRESH_RETRIES = 2`, `TRANSIENT_REFRESH_BACKOFF_MS = 500`) — 1.5 s of sleeping before the attempts are even counted.- The underlying `fetch` has **no `AbortController` and no timeout**. A slow Shopify token endpoint is not bounded by anything in core.
Normal case is one Firestore read (tens of ms). Worst case is three HTTP attempts to a degradedendpoint plus 1.5 s of backoff — comfortably past 5 s.
Two properties make this worth designing around rather than ignoring:
- It is **rare per shop** — a refresh happens roughly hourly, and only calls landing in the 5-minute pre-expiry window can trigger one.- It is **correlated across shops** — the trigger is usually Shopify's token endpoint being unwell, which affects every shop refreshing in that window simultaneously.
Prefer resolving the client in the background consumer rather than in the webhook's synchronouspath. Where the handler genuinely needs a live API call before it can answer (an eligibilitycheck, say), accept the risk knowingly and make sure §2's failure mode is a retry rather than asilent 200.
### 4. Concurrency: what core handles, and the one gap it names
Refresh tokens are one-time-use **across the whole fleet**, so parallel refreshes for one shopare a real hazard. Core handles more of this than you would expect — don't build your own lockbefore reading what is already there.
**Per-process:** concurrent callers collapse onto one in-flight promise, keyed by shop. Since 5.4.0this also covers concurrent `forceRefreshAccessToken` calls (before, two callers that both read thesession could spend the same refresh token).
**Cross-instance:** when a refresh loses the race, core recovers instead of failing:
```ts// Another instance may have rotated this token first… Re-read the session: if a// different instance has since persisted a fresh, still-valid token, use it instead// of forcing the merchant through re-auth. This collapses a swarm of expired-token// callers into one real refresh + N cheap re-reads.const latest = await sessionRepository.findOne(sessionId, accessTokenKey);if (latest && latest.refreshToken !== refreshToken && /* still valid */) { return latest.accessToken; // "Refresh lost the race … using token refreshed by another instance"}```
So a fan-out of workers hitting one shop at expiry does **not** need an app-level lock.
**The gap core names itself:** *"Scope is per-process. Multi-instance deployments can still race;the retry-on-401 path is the safety net for that case."* That safety net covers a token revokedor rotated **out of band**, where the cached `accessTokenExpiresAt` still says "valid" so nothingtriggers a refresh — the call just 401s. Core exports `forceRefreshAccessToken` for exactly this;wrap Admin API calls so a 401 forces one refresh and retries once. Multi-instance apps shouldhave it; it is not optional dressing.
### Pre-deploy checklist for this class of risk
- [ ] Call sites that rebuild a client per item in a loop — hoist, or thread an optional `shopify` param.- [ ] Every `catch` around a converted call site that returns 2xx — token errors made retryable, or at minimum alert-loud.- [ ] Converted call sites in a webhook's synchronous path — moved to the background consumer, or knowingly accepted.- [ ] Retry-on-401 via `forceRefreshAccessToken` for multi-instance deployments.- [ ] After deploy, watch Firestore read volume: at most one extra read per call site invocation (fewer for legacy shops, thanks to the 60-second cache) is the expected shape.
## Declare the webhooks in `shopify.app.toml` (required)
From 5.2.0 **only the `app/uninstalled` webhook puts a shop back on the free plan** — the same model as Shopify's own template (`shopify-app-react-router`): an uninstall is known only from the webhook, never inferred from a dead token. If the webhook does not arrive, a shop that really uninstalled and reinstalls **keeps its previous plan** (see *Do the install hooks reset the plan?*). So this webhook must reliably arrive, in **every** environment.
**Do not rely on core registering it through the API.** Core's `registerWebhooks` calls `webhookSubscriptionCreate` per shop, but:
- **from 4.7.0 to 5.3.0 every registration failed**: the old library sent `Content-Type: application/graphql`, Shopify answers that with 415 from API 2025-01, and core logged nothing. 5.4.0 fixes it (JSON) and logs failures;- it runs only when core recognises a genuine install — shops installed earlier **never** get it;- the address comes from that request's `ctx.host`, which can be the wrong domain.
Seen for real on Joy staging 21 (2026-10-04): uninstall then reinstall, no `Handling uninstalling for ...` line in the logs, and the shop kept its Advanced plan. That matches the 415 failure above.
A webhook declared in the toml (**app-specific**) is applied by Shopify to **every shop** as soon as the config is deployed, including shops already installed, with no token and no dependence on which code path ran. Shopify recommends this.
Full block — every environment (production and **each** staging) has its own config file, so edit **each** one. Replace `<host>` with that environment's backend domain, the one core's `/auth` routes are served from (Joy prod: `joy.avada.io`; Joy staging N: `avada-joy-staging-N.firebaseapp.com`):
```toml[webhooks]api_version = "2026-04"
# The 3 privacy compliance webhooks: MUST be declared here (cannot be created through the API) [[webhooks.subscriptions]] uri = "https://<host>/auth/webhook/customers/data_request" compliance_topics = [ "customers/data_request" ]
[[webhooks.subscriptions]] uri = "https://<host>/auth/webhook/customers/redact" compliance_topics = [ "customers/redact" ]
[[webhooks.subscriptions]] uri = "https://<host>/auth/webhook/shop/redact" compliance_topics = [ "shop/redact" ]
# Uninstall: the only thing that puts a shop back on the initial plan [[webhooks.subscriptions]] uri = "https://<host>/auth/webhook/app/uninstalled" topics = [ "app/uninstalled" ]
# Plan change / store closed: core syncs the plan and cancels a closed store's subscription [[webhooks.subscriptions]] uri = "https://<host>/auth/webhook/shop/update" topics = [ "shop/update" ]```
- Those paths are core's built-in routes (`/auth/webhook/...`, HMAC-verified with the app secret); no new handler is needed.- `api_version` only sets the payload format; use the app's API version (core 5.4.0 defaults to `2026-04`; `2025-10` stopped being served on 2026-10-16).- If your app uses `afterThemePublish`, add a `topics = [ "themes/publish" ]` block pointing at `/auth/webhook/theme/publish` (needs the `read_themes` scope).
### Core registers them too (5.4.0)
From 5.4.0 core's API registration **works again**. With both the toml and the API active, every new shop gets two subscriptions per topic and receives **every webhook twice**: `afterUninstall` and the plan cancel run twice. Turn the API side off with `registerLifecycleWebhooks: false`, passed to **both** `shopifyAuth` and `verifyEmbedRequest`:
```jsshopifyAuth({...authOptions, registerLifecycleWebhooks: false});verifyEmbedRequest({...embedOptions, registerLifecycleWebhooks: false});```
`false` turns off **all three** topics core registers, so the toml must declare every topic the app needs:
| Topic | What core does with it ||---|---|| `app/uninstalled` | records the uninstall (`isInstalled: false`), cancels the subscription, runs `afterUninstall` || `shop/update` | syncs the Shopify plan, domain and timezone; for a frozen, cancelled or fraudulent store, marks it closed, cancels its subscription and runs `afterShopClosed` || `themes/publish` | runs `afterThemePublish` |
| Production `shopify.app.toml` declares | Setting ||---|---|| `app/uninstalled` + `shop/update` (+ `themes/publish` if the app uses `afterThemePublish`) | `false` || none of the topics | keep the default `true` || only some, e.g. just `app/uninstalled` | add the missing topics, deploy the config, then `false` |
The last row has no safe value as it stands: `true` doubles the declared topics, and `false` drops the others.
Handlers should still tolerate duplicates: shops installed on core ≤ 4.6 may keep old per-shop subscriptions (Joy dedupes in `shopRepository.js`). The Admin API `webhookSubscriptions` query lists only per-shop subscriptions, so they can be found and deleted shop by shop.
Deploy and verify:
1. `shopify app deploy -c <config name>` for **each** environment (or edit it in the Dev Dashboard). Editing the toml without deploying changes nothing at Shopify.2. Dev Dashboard → app → **Versions**: the released version lists all 5 subscriptions above (plus `themes/publish` if used).3. Uninstall on a test store of that environment. Logs must show `Handling uninstalling for <shop>` and `Cancelling subscription for <shop>`. Reinstall → the shop is on the initial plan.4. No log line → check the `uri` (backend domain, exactly `/auth/webhook/app/uninstalled`) and that the version was released.5. An app that keeps `registerLifecycleWebhooks: true`: a `Lifecycle webhook registration failed` log (with shop, topic and HTTP status) means that shop will not receive that topic.
## Treating a 401 as an uninstall in app code
Pre-v5 apps commonly wrote `isInstalled: false` (or `hasUninstalled: true`) whenever a background Shopify call returned 401 — reasonable when offline tokens never expired. With expiring tokens a 401 can just mean the token rotated mid-flight, and a stale `isInstalled: false` is exactly the input the `refresh-expired` branch above uses to run the install hooks. Joy had two of these (`checkInstalledApp`, `chargeShop`); the rules it ended up with:
0. **App code never writes `isInstalled: false` / `hasUninstalled: true`. Only the `app/uninstalled` webhook does.** Background code uses the classification below only to **stop what it is doing** (skip the email, don't charge). Writing the flag turns one wrong classification into a permanent one and opens the way for the install hooks to run on a live shop. The cost: a truly uninstalled shop whose webhook was lost keeps `isInstalled: true`, so each run spends one Shopify call plus a refresh before stopping — far cheaper than a wrong uninstall. It is also why the webhook must be declared in the toml (*Declare the webhooks in `shopify.app.toml`*).1. **Use the HTTP status, never the message.** `codes.some(c => e.message.includes(c))` matches "timed out after 4012ms". Read `e.response?.statusCode` (shopify-api-node / got).2. **Only a 401 can mean uninstalled. A 403 never does** — it is a missing scope on a valid token. Core's `isAuthFailureError` excludes 403 for the same reason. One 403 is special: `Non-expiring access tokens are no longer accepted for the Admin API` — Shopify rejecting a non-expiring token (apps created from 2026-04-01, or every public app from 2027-01-01). That is **not** an uninstall either; the token needs migrating or re-auth. Core 5.4.0 treated it as an inactive token and took install recovery; from 5.4.1 `verifyToken` migrates instead when `autoMigrateOfflineToken` and `expiringOfflineToken` are both on and the session has no refresh token. Background code that hits it should skip the shop and leave the token to the headless runner or the next app open.3. **On a 401, call `forceRefreshAccessToken(shopDomain, shopifyConfig)`.** If it succeeds the app is installed — an uninstalled app cannot refresh. No need to re-probe.4. **If the refresh throws, classify it like core does:** - no offline session / no `refreshToken` → the shop is on a legacy non-expiring token, which cannot rotate, so the 401 *does* mean uninstalled; - `e.isRefreshTokenRevoked === true` **and** the session's `refreshTokenExpiresAt` is in the future → uninstalled; - revoked but `refreshTokenExpiresAt` past or missing → an idle shop past its 90 days, **not** an uninstall (core calls this `refresh-expired`); - anything else (`isRefreshTokenRevoked === false`: network, 5xx, 429) → transient, not an uninstall. Read `refreshTokenExpiresAt` from the **offline session** (`shopifySession/offline_<domain>`), not the shop record: the token-exchange path writes it only to the session. If the session read itself fails, answer "not uninstalled".5. **Errors thrown by `initShopify` itself** (its own `getValidShopToken` refresh failing) carry `isRefreshTokenRevoked` and **no** `response` — classify them with rule 4 directly. Checking only `e.response.statusCode` silently never detects a truly uninstalled shop, because by the time a missed-webhook uninstall is noticed the access token has expired and the refresh is what fails.6. **Retrying a billing call after a successful refresh:** a 401 means Shopify charged nothing, so retrying once on the refreshed token is safe — but only if no charge was created earlier in the same run, and only once.
Joy's implementation: `packages/functions/src/services/shopifyService.js` (`isUninstallRefreshFailure`, `isUninstalledAfterRefresh`, `isUninstalledError`) with tests in `shopifyService.checkInstalledApp.test.js` / `shopifyService.chargeShop.test.js`.
## Out of scope by default`commands/` and `scripts/` (one-off/manual), and dev/mock tooling that builds its own client from a passed-in token (e.g. mock-order generators) — convert later; low production risk.
## Gotchas checklist- [ ] BOTH switches set (flag + getValidShopToken) — not one.- [ ] All three call forms awaited; `grep` shows 0 bare calls.- [ ] No `await` in default params (esbuild catches; move to body).- [ ] Re-ran the step 3 grep after the LAST merge from master — a green build proves nothing.- [ ] Any test stub of `@avada/core` also exports `getValidShopToken`.- [ ] No `catch` around a converted call site silently returns 2xx on a token error.- [ ] Converted call sites are out of webhook synchronous paths (refresh is unbounded).- [ ] Retry-on-401 via `forceRefreshAccessToken` if the app runs multi-instance.- [ ] Bypass `new Shopify({accessToken})` sites routed through `getValidShopToken`.- [ ] Local same-named `initShopify` (e.g. in a command) NOT swept up.- [ ] Don't commit the repo's pre-existing dirty files — stage only your paths.- [ ] Existing shops won't migrate on the flag alone — also set `autoMigrateOfflineToken: true` (or call `migrateToExpiringToken(ctx)`); verify a session gains `refreshTokenHash`.- [ ] Shops that never open the admin need the **headless** exchange (old token as subject): `migrateLegacyOfflineToken` from 5.5.0. Don't assume they need re-auth. Dry run, canary single shops past the 1h mark, then migrate all (*Sample: Dev Zone buttons…*).- [ ] Before "Migrate all", canary single shops with **Check shop** (legacy → migrate → force refresh → past the 1h mark without refresh) and confirm the Dev Zone banner reads `migrated` on the shop's next admin visit (*Sample: Dev Zone status banner + "Check shop"*).- [ ] Dev Zone migration endpoints check staff **on the server** (Dev Zone routes are reachable by every merchant), and a real fleet run needs the confirmation phrase server-side, not only in the modal.- [ ] On **5.5.1** (never below 5.3.0) — below 5.2.0 a misdetected install resets a paying shop to the free `initialPlan`; below 5.3.0 the OAuth flow has a security flaw; with the flag on, below 5.4.1 the non-expiring-token 403 runs the install hooks and below 5.5.0 the embedded OAuth callback mints non-expiring tokens; below 5.5.1 a non-expiring OAuth grant keeps the stale refresh metadata.- [ ] `app/uninstalled`, `shop/update` (+ `themes/publish` if used) and the 3 compliance webhooks are declared in **each** environment's `shopify.app.toml` (prod + every staging), deployed with `shopify app deploy`, and a test uninstall logs `Handling uninstalling for` (*Declare the webhooks in `shopify.app.toml`*).- [ ] Once the toml covers the topics, `registerLifecycleWebhooks: false` is set on **both** `shopifyAuth` and `verifyEmbedRequest`; until then it stays `true` (*Core registers them too (5.4.0)*).- [ ] App code never writes `isInstalled: false` / `hasUninstalled: true` itself — on a 401, refresh, classify the failure, and only stop the current work (*Treating a 401 as an uninstall in app code*).- [ ] Any per-shop "is it migrated?" indicator reads `migrated` the moment you look, because `autoMigrateOfflineToken` fires on the same request that renders it. Use it as a health check, not a rollout tracker.