Webhooks

Webhooks

All webhooks verify the Shopify HMAC (verifyShopifyWebhookHmac) before processing and are registered in shopify-app/shopify.app.toml.

Registered topics

TopicRoutePurpose
app/uninstalled/api/webhooks/app/uninstalledClean up store on uninstall
products/create,update,delete/api/webhooks/productsKeep catalog_products in sync
app_subscriptions/update/api/webhooks/app/subscriptions/updateBilling 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_requestGDPR — data request
customers/redact/api/webhooks/customers/redactGDPR — delete shopper data (deleteShopperData)
shop/redact/api/webhooks/shop/redactGDPR — 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 text

That 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

  1. Add the subscription to shopify-app/shopify.app.toml.
  2. Implement the route under app/api/webhooks/… with HMAC verification.
  3. cd shopify-app && npx shopify app deploy --allow-updates.