Shopify Merch Integration

How the Shopify theme app extension personalizes merch on a merchant storefront, and how to run it locally

What this is

Zooly’s Shopify app lets a merchant sell personalized merch on their own Shopify store. A shopper personalizes on the product page (selfie → AI art → mockup), adds to the Shopify cart, checks out through Shopify, and Zooly fulfills the order through the existing supplier pipeline.

Tracked on ZLY-1526. The Shopify Partner app shell lives in apps/zooly-shopify/; almost all product logic lives in packages and is served by apps/zooly-app.

Design decisions

These were locked early and still drive the shape of the code:

DecisionWhy
No iframeTheme app extensions that load an iframe fail App Store review. The UI ships as a JS/CSS bundle in the theme extension assets/.
Shadow DOM deferredPlanned for theme isolation; currently the block mounts in the light DOM. Theme CSS can still interfere — acceptable until we harden for listed apps.
Backend is zooly-appIt already owns the merch DB, merch APIs, and fulfillment dispatch. Cross-app DB access is not allowed, so there is no separate Shopify server.
App proxy for storefront APIsThe bundle calls /apps/zooly/* on the merchant’s domain. Shopify proxies to us with an HMAC signature → same-origin (no CORS), and we know which shop called.
Zooly fulfillsorders/create creates an External Order in our system and feeds the existing Printful / Riverr / Apliiq drivers. Shopify only collects payment and shipping.
We auto-create Shopify productsMerchants enable product × design combinations in the embedded admin. We create variants via Admin GraphQL productSet so sizes always match what we can fulfill.
Sellable unit = product × design“T-Shirt — Design 1” and “T-Shirt — Design 2” are separate Shopify products with different rendered previews. shopify_product_map stores design_id.
Driver seam for the blockMerchBlockDriver (upload / verify / generate / select / render). Mock and proxy implementations share one UI; the theme editor can still select the mock for demos.
Managed install + token exchangeNo hand-rolled OAuth callback. Scopes are granted at install; the embedded admin exchanges an App Bridge session token for an offline access token.
Merchant-managed fulfillment for trackingWe do not register as a full Shopify fulfillment service. Tracking is pushed with fulfillmentCreate under write_merchant_managed_fulfillment_orders.
External Order = merch_order + satelliteShopify orders become normal merch_order / merch_item rows (source = 'shopify') plus a shopify_order satellite. Dispatch, admin, and crons stay unchanged.

Things we deliberately did not do

  • One Shopify product per personalization — would fix cart/checkout images everywhere, but would publish other shoppers’ art on the Online Store.
  • Full fulfillment-service registration — unnecessary once we own orders/create and only need to push tracking on merchant-managed fulfillment orders.
  • Checkout thumbnail override — not available on non-Plus plans; Plus can only append after a line, not replace the image.

Architecture

Merchant storefront
  App block (Liquid) → loader JS (≤10 KB) → React IIFE bundle
       │ same-origin

  /apps/zooly/*  (Shopify app proxy, HMAC-signed)

  apps/zooly-app  /api/shopify/proxy/*
       │ allowlisted forward

  /api/merch/*  ·  /api/media/*  ·  /api/shopify/storefront/*

Add to cart → Shopify checkout → orders/create webhook

  createMerchOrderFromShopifyOrder → finalizeMerchOrder → supplier dispatch

When status → shipped → pushTrackingForMerchOrder → Shopify FULFILLED

Packages and apps

PieceLocation
Partner app + theme / UI extensionsapps/zooly-shopify/
Theme block Liquid + loadersapps/zooly-shopify/extensions/zooly-merch/
Thank-you UI extension (parked)apps/zooly-shopify/extensions/zooly-order-preview/
Block React UI + driverspackages/merch/client/src/shopify/
Shopify HMAC, Admin GraphQL, storefront context, product sync, trackingpackages/shopify/srv/
Embedded merchant admin (App Bridge)packages/shopify/client/
Thin API routesapps/zooly-app/app/api/shopify/
Order intake + finalizepackages/merch-fulfillment/srv/ (shopify-order-intake.ts, finalize-order.ts)
DB tables + accesspackages/dbshopify_shop, shopify_product_map, shopify_order; merch_order.source

How the storefront bundle builds

  1. Vite library target in @zooly/merch-client emits an IIFE + CSS (zooly-merch-app.js / .css) from packages/merch/client/src/shopify/.
  2. Output is copied into the theme extension assets/ (gitignored build artifacts).
  3. Liquid names only the small loader (zooly-merch.js) in the block schema — theme check hard-caps that file at 10 KB. The loader lazy-loads the real bundle via IntersectionObserver and asset_url.
  4. Total theme-extension budget is 10 MB. Stripe / checkout pages are excluded from this target.
npm run bundle --workspace zooly-shopify          # once
npm run bundle:watch --workspace zooly-shopify    # alongside app dev

Shopify CLI watches assets/ and re-uploads on change; it does not know how to produce the React bundle, so bundle:watch must run in parallel with app dev.

App proxy allowlist

The proxy verifies HMAC, then only forwards paths under:

  • merch/ (excluding admin / cron / seller / sync / webhooks)
  • media/ (same-origin generated art)
  • shopify/storefront/ (public product→campaign mapping)

Without the allowlist, a storefront could reach privileged merch admin routes on our origin.

Cart and preview properties

Line item properties on add-to-cart (underscore = hidden from the shopper UI):

PropertyRole
_zooly_design_idDesign identity for intake
_zooly_catalog_product_idCatalog product
_zooly_sessionMerch session
_zooly_art_keySelected art for print
_zooly_previewAbsolute mockup URL for cart thumbnail swap
Preview (optional public)Human-visible label where themes show properties

The Zooly cart previews app embed (zooly-cart-preview) reads /cart.js and swaps thumbnails. It is off by default (Shopify rule for app embeds). Native Add to cart / Buy it now are hidden by Liquid :has() when the personalizer is present on a Zooly product; fail-open via data-zooly-unavailable if storefront context says the product is not ours.

Product gating

syncShopifyProduct writes metafield zooly.personalizable. Liquid gates the block on that value so non-Zooly products on the same template do not flicker buy buttons. In the theme editor only, a non-personalizable product shows an explanation instead of an empty slot.

Order intake quirks worth knowing

  • Synthetic cart — DB still enforces Phase-6 cart NOT NULL constraints, so External Orders get a converted cart owned by shopify:{shopId}:{orderId}.
  • Test ordersmode: PREVIEW so they never dispatch to a real supplier.
  • Missing art → item goes to needsAttention instead of a null print file.
  • Idempotency — unique (shop_id, shopify_order_id) on shopify_order.
  • New scopes often need uninstall + reinstall on an existing store; reopening the app alone may not grant them.
  • Compliance webhooks (customers/redact, shop/redact) are still acknowledge-only — fine for private/unlisted installs; must delete External Order PII before App Store submit.

Local setup

Prerequisites

  • Shopify Partner account and a development store (Online Store 2.0 theme, e.g. Dawn).
  • Node 20.10+.
  • cloudflared for the backend tunnel.
  • Monorepo deps installed; local Postgres migrated (npm run db:migrate with .env.local — never migrate remote unless asked).
  • AWS profile zooly if media / generation paths need it.

shopify.app.toml is gitignored (each developer links their own app). Run interactively, one command at a time — do not paste the next command while the CLI is prompting:

cd apps/zooly-shopify
npx @shopify/cli@latest app config link

Always pass --skip-dependencies-installation to Shopify CLI commands in this monorepo. Without it the CLI finds bun.lock and runs bun install across the repo.

Environment (root .env.local)

npm run env:show --workspace zooly-shopify

Copy into the root .env.local (and restart zooly-app after changes):

SHOPIFY_API_KEY=...          # client id
SHOPIFY_API_SECRET=...       # server-only; verifies HMAC + session tokens
SHOPIFY_PROXY_TARGET_ORIGIN=http://localhost:3004   # local only

SHOPIFY_PROXY_TARGET_ORIGIN prevents the proxy from forwarding back through the Cloudflare tunnel (which deadlocks and 502s). Leave it unset in production.

Optional: SHOPIFY_API_VERSION (defaults to 2026-04).

Terminals to run every session

Order matters: tunnel before app dev. Deploying config while app dev is live can crash the preview and leave a stale proxy.

# 1 — backend
npm run dev --workspace zooly-app          # :3004

# 2 — Cloudflare tunnel + rewrite shopify.app.toml URLs + deploy config
npm run tunnel --workspace zooly-shopify

# 3 — React bundle for the theme extension
npm run bundle:watch --workspace zooly-shopify

# 4 — theme extension / embedded app preview
npx shopify app dev --skip-dependencies-installation
# (from apps/zooly-shopify, or: npm run dev --workspace zooly-shopify)

automatically_update_urls_on_dev is false on purpose so the CLI’s own tunnel does not overwrite application_url (which must point at zooly-app via cloudflared).

Pass --no-deploy to tunnel if you only want to rewrite the toml without releasing a version.

Merchant admin (first open)

Open the embedded app from the Shopify admin. The first open exchanges the session token and stores shopify_shop.accessToken. Until then, Admin API calls fail with a clear error rather than a raw Shopify 401.

Link a Zooly campaign and enable product × design combinations so maps and metafields exist.

Theme editor

  1. Online Store → Themes → Customize.
  2. Product template → add Zooly Personalizer (Apps) on a Zooly-enabled product.
  3. Enable the Zooly cart previews app embed (App embeds) if you want cart thumbnails.
  4. Save.

How to test locally

Proxy without a storefront

Signs requests the way Shopify does:

npm run probe --workspace zooly-shopify merch/campaigns     # expect 200
npm run probe --workspace zooly-shopify merch/admin/orders  # expect 404 (allowlist)

Storefront happy path

  1. Open an in-stock Zooly product (gift cards / sold-out variants refuse /cart/add.js).
  2. Confirm native Add to cart / Buy it now are hidden; the personalizer is visible.
  3. Upload → verify → generate → pick art → add to cart.
  4. Cart should show the personalized thumbnail if the cart-preview embed is on.
  5. Complete a test checkout.
  6. In Shopify admin, the order line should carry the _zooly_* properties.
  7. In Zooly fulfillment admin, an External Order appears with a link to the Shopify order; test orders stay PREVIEW (no real supplier dispatch).
  8. When you mark the order shipped in Zooly (or supplier sync reaches shipped), Shopify should move to FULFILLED with tracking.

Variant / gating checks

CheckPasses when
Non-Zooly product, same templatePersonalizer absent; buy buttons stay
Theme editor on non-Zooly productExplanation text, not a blank block
Unmapped size selectedAdd refused (no orphan personalization)
Storefront context 404/409data-zooly-unavailable; buy buttons return (fail-open)
Two personalized addsTwo separate cart lines (unique design/session props)

Mock driver (offline UI)

In the block settings, set Data source to mock. Upload still looks real (canvas-filter candidates); nothing hits the proxy. Useful for demos without generation cost.

Known gaps while testing

  • Thank-you page extension is deployed but does not show the personalized image (underscore properties appear hidden from UI extensions). Prefer looking up the render from Zooly after webhook intake.
  • Checkout line images cannot be overridden on standard plans.
  • Compliance redact handlers do not yet delete External Order PII.

Troubleshooting

SymptomLikely fix
No shopify.app.tomlFinish app config link
bun install errors from CLIAdd --skip-dependencies-installation
/apps/zooly/* 404 on storefrontRestart app dev after tunnel/deploy so the preview has the proxy
Tunnel 502 on merch callsSet SHOPIFY_PROXY_TARGET_ORIGIN=http://localhost:3004 and restart zooly-app
New Admin API capability silently missingUninstall + reinstall the app (scopes stick to the old token)
onlineStoreUrl nullPassword storefront — check resourcePublicationsV2, not the URL
Block missing from theme AppsTheme section does not accept app blocks; try Dawn
Storefront password promptDev store password under Online Store → Preferences

Deploying beyond your dev store

npm run deploy --workspace zooly-shopify

That creates an app version other stores (and eventually App Store review) can install. Local app dev only affects your development preview.