A client came to us last year with a Shopify app that had been in App Store review for eleven weeks. It synced one merchant's ERP. One merchant — their own. Somewhere in the first scoping call somebody had said "App Store," and that single word bought them a listing page, five screenshots at exact pixel dimensions, a billing API they would never charge a cent through, three mandatory compliance webhooks, and a reviewer asking why their embedded admin page took 4 seconds to paint. The integration itself — orders out, inventory levels back — was about 600 lines.
The hard part of a Shopify app is almost never the business logic. Mapping SKUs to variants is a Tuesday. What breaks in month three is the GraphQL cost budget you never measured, the webhook subscription Shopify silently deleted after 48 hours of 500s, and the ScriptTag holding up your entire storefront feature while Shopify deprecates it underneath you. Everything below is what we learned running these for B2B merchants doing four-figure daily order counts.
Three app types, and only one of them is cheap
Shopify has three distribution models and they are not interchangeable. An app created inside the merchant's own admin, under Apps and sales channels, hands you an Admin API access token in about four minutes: no OAuth dance, no Partner account, no review queue. It also cannot be embedded, cannot use App Bridge, and cannot ship a single extension. If what you need is a cron job on your own server talking to the Admin API, stop reading here — your platform work is done before lunch and the rest of this article is about problems you don't have.
The moment you need UI inside the merchant's admin, a checkout extension, a Shopify Function, or a theme app block, you need an app created in the Partner Dashboard. Set its distribution to custom and it installs on exactly one store through a link — extensions and all, still no App Store review. Pick public only if you are genuinely selling to many merchants, because that is the moment review, listing assets, performance budgets, and the billing API stop being optional. The eleven-week client above needed the Partner Dashboard for an admin page. They did not need the App Store. Switching them to custom distribution took an afternoon.
The rate limiter is your architecture
Shopify's GraphQL Admin API is metered in points, not requests. A standard shop gets a 100-point bucket refilling at 50 points per second; Plus gets roughly ten times that. Cost is computed from the shape of your query, so orders(first: 250) with line items and customer objects nested underneath can cost several hundred points and get rejected outright for exceeding the bucket — not throttled, rejected. Requesting 250 of anything is where most first drafts die. New API versions ship GraphQL-only, so counting REST requests is no longer a strategy.
Design around this on day one. Every response carries extensions.cost.throttleStatus with currentlyAvailable and restoreRate. Sleep on that number. Blind exponential backoff is worse than useless here, because the bucket is per shop, not per process — three workers backing off blindly all wake up together and drain it again, and your sync flatlines into a retry loop that never converges. For anything past a few thousand records, use bulk operations: submit the query, poll the operation, download a JSONL file, pay no per-page cost. We replaced a paginated nightly product sync that took six hours and regularly tripped the limiter with a bulk operation that finishes in about twenty minutes and costs a rounding error in points.
Webhooks are at-least-once, out of order, and occasionally absent
Shopify webhooks are not a queue you can trust. Delivery is at-least-once, so orders/create fires twice during a traffic spike and you write two ERP records unless you dedupe on the X-Shopify-Webhook-Id header — store it, make the column unique, let the second insert lose. Ordering is not guaranteed either: orders/updated routinely lands before the orders/create it followed, so compare the payload's updated_at against what you already have and drop anything older. Verify the HMAC over the raw bytes of the body with a timing-safe comparison, before any JSON parsing — Express with express.json() mounted globally, or any framework that re-serializes the body, will break your signature check on payloads containing emoji or non-ASCII customer names and nothing else. That bug reproduces only for the customers named Müller.
You get about five seconds to respond. HMAC, enqueue, return 200 — never process inline; an ERP that takes eight seconds to answer turns every webhook into a retry. Shopify retries a failing endpoint for roughly 48 hours, then deletes the subscription. Nobody gets an email. That is the exact mechanism behind "the app stopped syncing sometime last week." So run a reconciler regardless: nightly, query everything with updated_at after your last cursor and repair the diff. Webhooks are the fast path, the reconciler is the correct path, and every Shopify app we run in production has both.
Stop injecting scripts into the storefront
If your plan involves a ScriptTag or an edit to checkout.liquid, your plan has an expiry date printed on it. Shopify has been retiring checkout.liquid in favour of checkout UI extensions, and ScriptTags leave orphaned <script> calls in the theme after uninstall — merchants find them months later and open a ticket blaming whoever touched the theme last. Theme app extensions are the replacement: your app ships an app block, the merchant drags it into the theme editor where they want it, it's versioned alongside your app, and it disappears cleanly on uninstall. It also means a merchant can move your widget without emailing you.
Same story on the logic side. Discounts and shipping rules that used to be Shopify Scripts are now Shopify Functions: WebAssembly modules that execute inside Shopify's own checkout path against a fixed input/output contract and an instruction budget you can actually exceed. They're stricter to write and far faster at checkout, and they run on plans where Scripts were never available at all. Budget real time for your first one — a pure function that receives a cart and returns a list of discount operations is unlike anything else in the platform, and the debugging loop is shopify app function run against a fixture, not a console log in production.
The install flow you get wrong exactly once
For embedded apps, use token exchange: App Bridge hands your frontend a short-lived session token, you swap it server-side for an access token, and the merchant never watches a redirect chain resolve. Take an offline token for anything scheduled. Online tokens die with the user's session, which is the precise reason a nightly sync stops working the week the ops manager who installed the app goes on holiday. And scopes do not propagate — add one after install and every existing merchant must re-authorise through a prompt they have to notice and click, so decide scopes deliberately rather than asking for write_customers you'll strip out later.
Handle app/uninstalled by deleting the token and cancelling every scheduled job for that shop. Skip it and your workers keep polling a store that revoked you, collecting 401s until someone reads the logs. Public apps additionally need the three GDPR compliance webhooks answered for real — a reviewer will send a test payload and check that customers/redact actually redacts, not that you returned 200. None of this is hard. Each item is ten minutes to build correctly upfront and one production day to discover.