Navigating Shopify App States: Lessons from the Community on OAuth, Billing & Beyond
When you're building a Shopify app, the initial setup can seem pretty straightforward, right? You think about the basics: install, OAuth, billing approval, maybe an initial data sync, and then you're ready to go. That's certainly what Lars_Mayer, a fellow developer in the Shopify community, thought when he started mapping out his app's lifecycle. But as he quickly discovered, what looks simple on paper can become a fascinatingly complex web of interconnected states in reality.
Lars shared his journey in the Shopify community forum, titled originally "I thought OAuth + billing would be easy :-) then I started mapping the states." He realized he didn't have just one app state, but several dimensions operating simultaneously: installation, billing, data readiness, sync status, and the tricky dance of uninstall, reinstall, and recovery. Picture it:
- A shop can be installed, but billing isn't active yet.
- Billing might be active, but the initial data sync is still chugging along.
- Or, even worse, the app is installed, billing is fine, but local data is only partial because a sync failed halfway through.
The real kicker, as Lars highlighted, isn't the individual states, but their combinations. An "installed + billing active + data partial + initial sync running" shop is a vastly different beast from an "installed + billing active + data active + sync successful" one. This realization sparked a fantastic discussion, and the community chimed in with some truly invaluable insights. Let's dive into the key takeaways that every Shopify app developer should know.
The Truth About State Ownership: Shopify as the Source
One of the most recurring themes was about defining who "owns" the truth for certain pieces of data. For critical things like billing and authorization, the consensus is clear: Shopify is the single source of truth. You might cache this data locally for performance, but your local copy should never be the definitive answer without reconciliation.
Canonical Accessors and Race Conditions
Luca_Bartoccini and Lars_Mayer both highlighted a subtle but critical point: even if Shopify is the source, how you read your cached local copy matters. Luca shared a painful experience where different code paths read the cached plan differently, leading to merchants appearing upgraded in the UI but still being capped. The fix? "Since then every cached state has exactly one accessor and nothing else touches the raw field." This means all parts of your app (UI, API, worker) should go through one single, canonical function to interpret that raw cached value. No independent interpretations!
Ian_Chechin added another layer with billing race conditions. If multiple elements on your app's first screen can trigger a plan lookup, designate one of them as the "owner" of that lookup. Otherwise, you might find your paid plan handle losing a race to the free default, leading to confused merchants seeing "Free" even after paying.
Demoting Webhooks: Signals, Not Sole Truth-Tellers
This was a huge "aha!" moment for many. Webhooks are fantastic for real-time notifications, but relying on them as the sole source of truth for your app's state transitions can lead to nightmares, especially with out-of-order delivery or failures.
As lumine wisely put it, "I stopped treating them as state transitions and started treating them as ‘something changed, go re-read the source’." For billing, this means querying currentAppInstallation activeSubscriptions when the merchant opens the app, instead of blindly trusting the last webhook. Adamcharvat echoed this, suggesting that this change removed most ordering bugs.
The Silent Failure: Lost Webhook Subscriptions
Adamcharvat also brought up a nasty edge case: Shopify retries failing webhook endpoints for about two days, then silently removes the subscription. Your app might look perfectly installed and healthy in your database, but it's receiving nothing! His solution? "We run a cheap periodic reconciliation against the Admin API now, mostly so that gap gets discovered by us and not by the merchant." This proactive check can save you from merchant support tickets.
Navigating the Unpredictable: Uninstall, Reinstall, and Scopes
The lifecycle events around installing and uninstalling an app are rarely as clean as we'd like.
Uninstall Webhooks: Independent Authentication is Key
Luca_Bartoccini got burned by an uninstall webhook handler that required a valid shop token – a token that's often invalid the moment a shop uninstalls! The fix is crucial: "I verify the HMAC by hand now and never touch a session inside a webhook." This ensures your webhook handler can process the uninstall request even when the shop's token is gone.
Reinstall and Scopes: Don't Assume
Lumine pointed out that on reinstall, the old token simply dies, resulting in a 401 on any API call. So, you don't need to predict "reauth required" from webhook ordering; you discover it on the next API call and recover. Lars_Mayer takes a more defensive stance for apps with async background jobs, tagging an "installation generation" to prevent old jobs from becoming valid again on a reinstall.
Another critical dimension lumine added is scopes. Your app can be installed, billed, and synced, but if the stored token doesn't carry a new scope you added last week, features will silently fail with 403 errors. Lumine's robust approach: "I now store the granted scopes at auth time and compare them against the required set on every app load, then send the shop through reauth when they differ."
Robust Syncs and "Partial Data" Nightmares
Lars_Mayer's initial post highlighted the headache of an initial sync failing halfway through. This "partial data + billing active + worker gave up" scenario is a prime generator of support tickets, as lumine confirmed.
Adamcharvat offered a powerful solution: make your initial syncs resumable. "A cursor per resource, written after every page, means partial is never a state you have to classify, the data is just behind, and a worker that died at 3am is a job you run again rather than a shop in a special condition." This means instead of restarting a massive sync from scratch, your app can pick up exactly where it left off.
What I am still thinking about is how far to go with resumable syncs for really large shops versus just restarting a failed section. Your support-ticket example is a good argument for making this more explicit.
The Ultimate Fallback: The Error Screen
Finally, Ian_Chechin shared a truly foundational insight: "One state nobody mentions until it burns them: the error screen itself." If your failure page relies on the same embedded stack (App Bridge, your API, your session) that just failed, the merchant sees a blank iframe. When you most need to communicate, you can't.
His advice, which Lars_Mayer immediately adopted, is a "dependency-minimal fallback for bootstrap/auth failures." This means a plain static HTML page with zero dependencies. It's not glamorous, but it's essential for telling the merchant what's going on when everything else has gone sideways.
The community discussion around Lars's initial observation really hammered home that building a robust Shopify app involves much more than just the happy path. It's about meticulously mapping out all the possible states, understanding who owns what data, treating webhooks as signals, and building resilient systems that can recover gracefully from the unexpected. By adopting these strategies, you'll not only build a more stable app but also provide a much smoother experience for your merchants on the Shopify platform.