Shopify is brilliant at the moment someone pays. A letter-subscription brand in the UAE showed me what happens after that moment.
One order means months of work: letters written, printed, labelled and delivered on schedule, to recipients who aren't the buyer, in English and Arabic. Addresses change, people cancel halfway, and refunds depend on which day of the month they asked. Shopify sees one order. The business sees months of promises that someone has to keep.
So I built them an HQ: an admin app embedded in Shopify, its own Postgres database, and a customer page on the storefront that talks to it in both directions.
The split that made everything else easy
Shopify owns the money. Our database owns the work.
Orders, payments and customers stay in Shopify. Subscriptions, letters, recipients, cancellations, refunds, flags, staff roles and a full audit trail live in Postgres. Every change is written as {who, action, before, after, source}, so nobody has to ask "who changed this?" ever again.
orders/paid is the only way in, and it's idempotent: the webhook ID gets stored inside the same transaction that creates the subscription, so a retried delivery can't create a second one. Messy data doesn't block an order. It raises a flag in the team's queue.
The 16-second bug
The first live build worked. It was also painfully slow: 7,084 – 16,309 ms per screen inside Shopify Admin.
The queries were fine. The geography wasn't. The response header gave it away:
x-vercel-id: sin1::iad1::...
The function ran in US East while the database sat in Mumbai, and every page made several round trips across the planet. One line fixed it:
{ "regions": ["bom1"] }
Then I cached a Shopify staff lookup that always fails on non-Plus stores (it was costing 400–700 ms on every page) and ran two loaders in parallel.
After: 229 – 1,711 ms. Same code, same database. Now when an app feels slow, the first thing I check is the region in that header.
Tests that click buttons, not tests that load pages
"Every page loads" told me almost nothing. I wrote a suite that runs against the live app: it sends signed webhooks with fake order IDs it cleans up afterwards, then clicks every real action (prepare, dispatch, deliver, label, cancel, refund, role checks, CSV exports) and checks the database after each one.
It found bugs that screenshots never would have:
- Subscriptions never left "active", even after the last letter
- Cancelling did nothing to letters not yet sent
- The refund cutoff was hard-coded and calculated in UTC instead of Dubai time
- Staff roles were cosmetic: the badge showed, the permission didn't exist
Final count: 74/74 end-to-end, 53/53 app-proxy and backend, 62/62 UI in English and Arabic on desktop and mobile. 189 checks, all passing before handover.
The customer side, the hard way
Shopify's customer account extension was the obvious choice. On this store it never rendered, not even a minimal one. I proved the bundle itself was fine by running it in Node, then dropped the approach rather than keep fighting it.
What shipped instead:
- A storefront account page that reads one merchant-owned order metafield (Liquid can't read
$app:ones) - After every staff action, a sync rewrites only what changed since the last run, plus a daily cron as a safety net
- Customers request changes (new address, cancellation, refund estimate) through Shopify's app proxy. It's signature-verified, checks that the customer owns the record, and only accepts JSON, which blocks cross-site form posts for free. Each request lands in a Requests queue in HQ with approve, decline and apply.
The rule for this build was simple: no "email us" links. There are none.
What I'd tell anyone building this
- If the business lives after checkout, give that work its own database.
- Make the webhook the only entry point, and make replaying it safe.
- Test by doing things on the live app, not by looking at it.
- When it's slow, check where the code runs before you touch a query.
I build systems like this around Shopify. More at fahadazhar.site. If your store runs on spreadsheets once the order comes in, tell me in the comments what breaks first.
Top comments (0)