Custom Shopify app development goes wrong at the first decision: which kind of app you're building. Here's how the app types differ, why the GraphQL rate limiter is your real architecture, and how to build webhooks that survive Black Friday.
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.
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.
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.
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.
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.
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.
A software studio that ships and maintains its own products — KeepChats, Gwora and MoveProof — and builds the same way for clients. Founded and led by codewithumar.
Talk to the studio →Send the problem. You get one fixed number and a plan back within a business day.
We build software worth keeping — for clients, and for ourselves.
Founded & led by codewithumar