Your Shopify app's real OAuth problem isn't Shopify - it's the other API
Every Shopify tutorial covers the same OAuth dance: merchant installs, you get a token, done. The framework handles 100% of it.
Then you build a real app. A real app connects to something - Xero, QuickBooks, Klaviyo, an ERP, a shipping API - because an app that only talks to Shopify is rarely worth $19/month. And that second OAuth integration is 100% yours. No framework, no guardrails.
I shipped a Shopify-to-Xero sync app. Here are the four failure modes that only showed up days or weeks after things "worked", and the patterns that survive them.
1. Refresh tokens rotate - persist BOTH tokens, every refresh
Many providers (Xero included) issue a new refresh token every time you use one. If your refresh handler only saves the new access token, everything works... until the next refresh, when your stored refresh token turns out to be single-use and already spent. The connection dies silently. The merchant blames your app.
const updated = await db.partnerConnection.update({
where: { shop },
data: {
accessToken: encryptSecret(tokens.access_token),
refreshToken: encryptSecret(tokens.refresh_token), // rotation!
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
},
});
2. Refresh tokens die of loneliness - keep-alive cron
Xero's refresh tokens expire after ~60 idle days. Think about who goes idle: a merchant with a seasonal shop installs in January, goes quiet, comes back for the summer rush - dead connection, angry merchant, support ticket.
Fix: a weekly cron that force-refreshes every stored connection, regardless of expiry. Our getValidConnection(shop, { force: true }) flag exists purely for this keep-alive job.
3. Refresh proactively, not on 401
Refreshing 60 seconds before expiry instead of reacting to a 401 saves you a failed call + retry - which matters a lot during a webhook burst (a flash sale means dozens of order webhooks, each wanting an API call).
if (!opts.force && conn.expiresAt.getTime() - 60_000 > Date.now()) {
return decrypted(conn); // still fresh
}
// otherwise refresh now, before the API call
4. Rate limits: evenly-spaced throttle + Retry-After
Partner APIs limit per tenant (Xero: 60/min). You don't control your traffic - Shopify webhooks do. Two layers:
// Layer 1: never hit the limit - evenly space calls
let nextSlot = 0;
async function throttle() {
const gapMs = Math.ceil(60_000 / CALLS_PER_MINUTE) + 100;
const wait = Math.max(0, nextSlot - Date.now());
nextSlot = Math.max(Date.now(), nextSlot) + gapMs;
if (wait > 0) await new Promise(r => setTimeout(r, wait));
}
// Layer 2: if a 429 slips through, honour Retry-After (up to 3 attempts)
if (res.status === 429 && attempt < 3) {
const retryAfter = parseInt(res.headers.get("Retry-After") ?? "2", 10);
await new Promise(r => setTimeout(r, retryAfter * 1000));
}
Bonus gotchas
- The consent screen can't run inside the Shopify admin iframe. Open it in a new tab, and carry the shop domain through the round-trip in the
stateparam - the callback lands in a bare tab with no session you can trust. - Encrypt tokens at rest. Shopify's protected-customer-data expectations (and basic hygiene) mean your partner's refresh token shouldn't sit in plaintext in Postgres. AES-256-GCM with a versioned prefix lets you turn encryption on after launch without a migration.
- Design the reconnect UX on day one. Connections die (revoked, expired, account closed). A null connection should render a "Reconnect" button, not crash a dashboard - and failed jobs should self-heal via a retry cron once the merchant reconnects.
Where this comes from
These patterns are lifted from the production code of my Shopify-to-Xero app, and they're the core of chapter 4 of ApprovedKit - a boilerplate that layers production-tested modules (billing, GDPR webhooks, this whole OAuth module, a $5 VPS deploy stack) on top of Shopify's official template, plus a live log of our App Store review as it happens. Early access is $99 for the first 30 seats at approvedkit.com.
And even if you never buy anything: persist both tokens. Future you will thank present you.
Comments
No comments yet. Start the discussion.