← Back to Blog

A Shopify app is finished when the unhappy paths are boring

6 min read

TL;DR

The feature works on a dev store, then a merchant hits a blank admin, a billing error, or a webhook with no session. Shopify apps fail in the seams. Here is how we treat those paths as part of the product.

A Shopify app is finished when the unhappy paths are boring

The demo of a Shopify app is usually the happy path. Install, click around, watch the feature work, ship.

The version merchants actually live with is the other path. The admin iframe goes blank after a back click. A development store is offered a subscription it cannot pay. A compliance webhook arrives the day after uninstall, when the offline token is already gone. None of that shows up in a screen recording. All of it shows up in support.

After shipping RWA: Waitlist Flow, Shiftify, True Profit Analytics, and Rollback Hero, I have started treating "done" as a boring unhappy path, not a working happy one. Four seams keep earning that standard.

The embedded app that navigates itself out of Shopify

An embedded app is not a website with a Shopify login. It is a document inside the admin, and the admin only trusts it while shop and host stay on the URL.

Drop either one and the failure is quiet. Client-side routing looks fine in your terminal. In the merchant's browser the iframe loads admin.shopify.com directly, or the next click renders a white page because App Bridge no longer has a session. The back button is a favourite way to get there. So is any <a href> or navigate() that builds a path from scratch and forgets the search params you were given on the way in.

The fix is dull, which is the point. Read shop and host once, thread them through every in-app link, and recover them if a request comes back without them. Do not treat the embedded flag as optional decoration. If the app thinks it is standalone, it will try to be, and the admin will not play along.

We lost hours to this class of bug because the feature under test never moved between routes. Reviewers and merchants do. If your click-through script only loads /app and stops, you have not tested the app.

Billing that assumes every store can pay

Shopify will let almost any shop install your app. It will not let every shop start a charge.

Partner development stores, and shops whose plan is development, paused, or inactive, are not live merchant accounts. If your pricing screen still offers "Start trial" on those shops, the merchant clicks into an error, or worse, into a charge flow that cannot complete. Support then reads as "billing is broken" when the store was never eligible.

Gate the charge before you call the Billing API. Load the shop plan, refuse partner development and non-live plan names, and say so in the UI. An allowlist for the one store you use to test billing is fine. A blanket "everyone can subscribe" is not.

This is also a review problem. A reviewer on a dev store who is pushed into a paid charge will fail the session, then fail you. The happy path is a merchant on Basic or above. The unhappy path is every other shop, and there are more of those in the Partner ecosystem than you think.

Webhooks that arrive after you have nothing left to authenticate with

orders/create is the webhook you build the demo around. shop/redact is the one that pages you.

Compliance webhooks fire because the shop uninstalled, or because Shopify is asking you to delete data. By then the offline token may already be invalid. Refreshing it fails. If your handler assumes authenticate.webhook plus a live session, or if a missing session throws on the way to "there is nothing to delete," you return 500. Shopify retries. Your error tracker fills up with probes that were never going to have a session.

Handle these as sessionless on purpose. Verify the webhook, then delete what you can by shop domain. If the row is already gone, respond 200. Malformed probes from scanners are not application bugs. Logging them as production exceptions hides the one redact that actually failed.

The same instinct applies to any webhook you might see twice. Delivery is at least once. If creating a waitlist claim, a schedule run, or a product snapshot is not idempotent, the retry is a duplicate in the merchant's admin. Idempotency keys are less exciting than the feature. They are the feature, once you have real volume.

Admin API fields that still work until they do not

Shopify deprecates in public, then removes on an API version boundary. The dangerous window is the middle, where the old field still returns data and your app looks healthy.

Inventory updates are a recent example. ignoreCompareQuantity used to mean "write this quantity, do not compare." Newer Admin API versions want compare-and-swap: changeFromQuantity, or an explicit opt-out, plus an idempotency directive. An app that keeps sending the old argument works on the version it was generated against and fails the moment you bump versions to clear a review or adopt a new field.

Pin the API version you test. When you move it, search for the deprecated names instead of trusting that codegen will shout. A green typecheck against last quarter's schema is not a green typecheck against the version you just shipped.

What we actually put in the definition of done

A feature is not done when I can complete it on bundles-test-store with a warm database and a single tab.

It is done when:

  • Every in-app link keeps the embedded params, including back and refresh.
  • Billing refuses shops that cannot pay, with a sentence a merchant can understand.
  • Webhooks tolerate a missing session and a second delivery.
  • The Admin API calls match the version in shopify.app.toml, not the version in my head.

None of that is a differentiator on the App Store listing. Merchants do not install an app because the redact handler is polite. They leave when the iframe goes white, the trial button errors, or a retry double-charges a workflow. The happy path gets you the install. The unhappy path decides whether they are still there next week.

If you are about to submit, run the path you hope the reviewer skips. That is the one they will take.