Webhooks
All webhooks verify the Shopify HMAC (verifyShopifyWebhookHmac) before processing and are
registered in shopify-app/shopify.app.toml.
Registered topics
| Topic | Route | Purpose |
|---|---|---|
app/uninstalled | /api/webhooks/app/uninstalled | Clean up store on uninstall |
products/create,update,delete | /api/webhooks/products | Keep catalog_products in sync |
app_subscriptions/update | /api/webhooks/app/subscriptions/update | Billing sync — maps status, resets usage on activation, clears custom on a standard plan, cancels any other active sub (single-active layer 2), and records a billing_events row (see Billing Pipeline) |
customers/data_request | /api/webhooks/customers/data_request | GDPR — data request |
customers/redact | /api/webhooks/customers/redact | GDPR — delete shopper data (deleteShopperData) |
shop/redact | /api/webhooks/shop/redact | GDPR — delete shop data |
products/* — staleness guard and metadata merge
syncSingleProductFromPayload (lib/server/shopify.ts) applies the payload through
upsertCatalogProducts. Two rules govern it, both from #190 — get them wrong and products silently
lose their images:
1. Out-of-order guard compares Shopify’s clock, as instants.
isStaleProductWebhook (lib/catalog/product-webhook-sync.ts) compares the incoming
payload.updated_at against catalog_products.shopify_updated_at — Shopify’s own stamp for the last
payload we applied, normalized to UTC. Never against catalog_products.updated_at, which is our
write time: our write always lands after Shopify’s change, so an update arriving inside the write
window would be dropped. And never as strings — Shopify sends the shop’s local time, so for a
shop behind UTC a text comparison inverts:
ours 2026-07-30T05:37:49.373+00:00
payload 2026-07-30T01:37:52-04:00 <- LATER instant, sorts EARLIER as textThat inversion discarded every products/update for stores behind UTC. Because productCreate media
processing is async, products/create always carries images: [] and the images arrive in the
following updates — so those stores kept products with 0 images and try-on was impossible, while
the webhooks were still recorded processed. A +03:00 shop compares correctly by luck, which is why
only non-BG merchants were affected.
shopify_updated_at is nullable with no backfill: NULL means “unknown” and resolves to
apply. Every uncertain input resolves the same way — dropping an update is silent and needs a full
sync to repair, while re-applying one is idempotent. The full catalog sync selects Shopify’s
updatedAt too, so a sync seeds the guard instead of blanking it.
2. The webhook MERGES metadata; the full sync REPLACES it.
A REST products/* payload carries only source + featuredImageUrl. Replacing the whole column
wiped the full sync’s variants (the per-variant try-on garment reference) and shopifyCategory (the
category resolver’s input) off every product a webhook ever touched — silently reverting both
features. So the webhook path passes mergeMetadata: true, which merges over the existing row’s
metadata. It reads that row by the real upsert conflict key (store_id,slug), not by
external_id: the full sync writes GID external_ids while webhooks write numeric ones, so an
external_id lookup can miss the very row about to be overwritten. The full sync stays authoritative
and does not merge — it must be able to drop keys that disappeared from Shopify.
app_subscriptions/update store lookup
The handler looks up the store by getStoreBySubscriptionId(subscriptionGid) first. During a
plan change, stores.subscription_id has already moved to the new subscription by the time
Shopify’s webhook for the old subscription arrives, so that lookup can miss. On a miss, it
falls back to getStoreByDomain(shop) (from the x-shopify-shop-domain header) and runs
reconcileStoreBilling — the authoritative sync — instead of skipping the event. Logged as
webhook.reconcile_by_domain (vs. webhook.store_not_found previously). See
Billing Pipeline → Single-active guarantee
for why this matters.
GDPR compliance
The three compliance_topics webhooks are mandatory for App Store approval. customers/redact
permanently deletes the shopper’s captured emails / try-on data by email + Shopify customer
id. All three verify HMAC and return 200 quickly.
App proxy
/api/proxy is the Shopify app proxy entrypoint (apps/tryvio subpath), used by the
storefront widget for same-origin signed requests.
Adding a webhook
- Add the subscription to
shopify-app/shopify.app.toml. - Implement the route under
app/api/webhooks/…with HMAC verification. cd shopify-app && npx shopify app deploy --allow-updates.