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: status='uninstalled', a live billing status settles to cancelled with a billing_events row (#531, see Billing Pipeline), ESP integrations deleted; also appends an install_events row (kind uninstall, see Install Attribution) and sends an owner Telegram alert |
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 — erase a former merchant (deleteAllStoreData → eraseStore, #656 — see below) |
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 (see below). The full sync stays authoritative and does not merge — it must be able
to drop keys that disappeared from Shopify.
3. external_id is canonically a product GID — write one form, read both (#193).
The full GraphQL sync writes gid://shopify/Product/<n>; the REST webhook path used to write the bare
numeric <n>, and the upsert keys on (store_id, slug), so whichever writer ran last decided the
row’s format. Prod on 2026-07-30: 3,608 numeric vs 1,592 GID rows, mixed inside every store. Every
.eq("external_id", …) therefore saw only half the catalogue:
products/delete→softDeleteProductfound nothing on a full-synced store, so the deleted product stayed try-on-enabled;archiveStaleProductDuplicatescould not see the renamed-handle sibling — 58 duplicate groups still held 115 live rows for the same Shopify product (the ghost rows in the related strip);getStorefrontProductByExternalId(analytics ingest fallback + the Shopify-complementary bundle lookup) resolved to null for webhook-written rows.
The rule now: canonicalize on WRITE, accept BOTH forms on READ. normalizeShopifyProductGid and
productExternalIdCandidates live in the pure lib/catalog/product-external-id.ts (imported by both
supabase-admin and shopify.ts; it must stay free of server-only). Webhook writes go through the
normalizer; getProductByExternalId, archiveStaleProductDuplicates and findStorefrontProduct use
.in("external_id", productExternalIdCandidates(id)). No migration and no coordinated backfill are
needed — legacy numeric rows keep resolving, and each row flips to the GID the next time either writer
touches it. A non-Shopify value yields a single exact candidate, so a lookup never widens.
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.
Install & uninstall Telegram alerts (#665)
Until #665 the only install/uninstall signals were the weekly ops digest (a COUNT of new
installs) and the daily portfolio watchdog (#514), which only watches stores that are still
installed — an uninstall reached nobody (the Kylyan churn, #512/#513/#514).
lib/server/install-alerts.ts now sends the owner a Telegram message through the existing
ops bot (sendOpsDigestToTelegram, TELEGRAM_BOT_TOKEN + TELEGRAM_OPS_CHAT_ID) on two events:
- Install — wired in two places, because there are two install paths: the OAuth callback
(
/api/auth/callback, see Auth → Shopify OAuth, insidewaitUntil) and the embedded session-token exchange (/api/auth/session). - Uninstall — wired in
handleShopifyUninstallWebhook(lib/server/shopify.ts), theapp/uninstalledhandler above.
The gate that prevents a blast. completeShopifyInstall returns a ShopifyInstallOutcome
with previousStatus — the store row’s status before the upsert:
null→ new merchant → alert."uninstalled"→ returning merchant → alert, and the message says how long they were away.- anything else → the app was already installed and Shopify merely re-issued the grant (e.g. a scope-bump re-consent, #491) → no alert.
Uninstall dedupe. handleShopifyUninstallWebhook reads store.status before flipping it
to "uninstalled". A Shopify webhook redelivery therefore sends nothing — the status
transition itself is the dedupe (the route does not read x-shopify-webhook-id). An uninstall
for a shop with no store row still sends a message naming the domain — that path used to be
silent.
Test/demo stores never alert. A store with is_test = true, or whose shop matches
TRYVIO_DEMO_SHOP_DOMAIN (default teststoretryon.myshopify.com), is skipped.
Message content. Plain text in Bulgarian — no parse mode, so a shop name containing & or
< can’t break the message.
- Install: shop name, domain, Shopify plan, country, locale, acquisition channel.
- Uninstall: shop name, domain, how long the store was installed (derived from
stores.created_at, now included ingetStoreByDomain’s select), plan, and try-ons used.
Failure mode. Every send is best-effort with a 3-second timeout and never throws — an install
or a webhook 200 must never fail because Telegram is down. OUTBOUND_DISABLED=true (staging)
silences it like every other outbound send.
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. shop/redact answers 500 when the erasure fails so Shopify retries.
shop/redact and the day-30 sweep (#656)
Shopify API License §6.2(3): delete the Merchant Data within 30 days of an uninstall. Two paths run
the same routine, eraseStore (lib/billing/store-erasure.ts, pure; its port is in supabase-admin.ts):
shop/redact(~48 h after the uninstall),maxDuration = 300. A big store outlasts Shopify’s 5 s webhook timeout; Shopify retries and the batched erasure resumes where it stopped.- the day-30 sweep in
/api/cron/log-maintenance(03:30 UTC): everystatus = 'uninstalled'store whosestores.uninstalled_atis ≥ 29 days old (the #531 clock, so a daily run never lands on day 31), or that has noplatform_connectionsrow (the first thing an erasure deletes, so a redact started and did not finish, e.g. ab0644-ce). A 180 s budget; the rest is deferred (logged at error) to tomorrow.
stores.uninstalled_at is set once by app/uninstalled (guarded on IS NULL, so a retried webhook
cannot push the clock) and cleared by any install. Never updated_at: later writes move it.
Order (each step checks error and throws a StoreErasureError naming the step and table):
- Guard: an installed store (reinstalled before the redact), a store with
legal_hold_reasonset, or an unknown shop → askippedrecord, nothing deleted. An installed store is logged at error. count_store_rowsover every planned table +capture_platform_monthly_totals(), thenplatform_monthly_carry(store).- The
store_erasuresrecord, before any delete (counts + carry). It is data, so it survivesapp_config.log_level = error; after 90 daysreduceStoreErasureRecordsclears domain, store id, note, error and carry. The counts stay. - Stage 0:
platform_connections,store_integrations. - Files: shopper photos and generated images (found through the session, since those tables have no
store_id), share images, and the merchant bucket prefixes<store>/,shade-refs/<store>/,calibration/<store>/,support/<store>/. File first, then its row, page by page. detach_store_financial_records(store, RETAINED_FINANCIAL_TABLES): see below.- Stages 1–5 of
STORE_ERASURE_STAGESthrougherase_store_rows(store, table, batch): one boundedDELETE … WHERE ctid = ANY(… LIMIT n)per call, looped until empty. Tables in a stage run 4 at a time. A table is emptied only after every table that references it (the real seam checks this againstpg_constraint). The five tables withoutstore_id(tryon_outputs,tryon_input_images,tryon_provider_jobs,catalog_product_images,product_tryon_settings) are reached through their session or product (store_erasure_predicate).app_logsis reached byshop_domain. - The
storesrow, only while it still readsuninstalled. Then the record is markedcompleted.
Why it used to half-wipe. analytics_events.provider_job_id and tryon_outputs.provider_job_id
are ON DELETE SET NULL FKs with no index. Every provider job deleted scanned both tables. On
staging, 100 sessions took 4.2–4.9 s without the index and 21–98 ms with it. The errors were never read and
the route answered 200. Migration 20260915b_store_erasure.sql adds those two indexes plus
tryon_sessions.product_id, product_tryon_settings.primary_reference_image_id, and
webhook_events.store_id / audit_events.store_id. The largest store on the prod copy (24 564
sessions, 177 288 analytics events) erases in 193 statements; the slowest takes 626 ms.
What survives, and nothing else:
| Kept | Why | Rule |
|---|---|---|
billing_events, charged (settled) order_fees, affiliate_commission_entries | Accounting records, pending the accountant’s confirmation | store_id → NULL. Domain, Shopify ids, free text and metadata are removed. Unique keys become erased:<row id>. To delete one instead, remove it from RETAINED_FINANCIAL_TABLES (the store cascade then deletes it) |
the store’s own affiliates row (merchant referral) | Its cascade would delete commissions and payouts it earned on other, still-installed stores | detached; name → Former merchant; email, company and notes are cleared |
platform_monthly_totals | Anonymous monthly totals across all stores (test stores excluded) | See Data Model |
store_erasures | The record that the erasure happened | Identity is cleared after 90 days |
install_events | #531 reinstall resilience | Detached; its own 29-day purge applies |
email_unsubscribes | Suppression list | Untouched (0 rows on prod); open question for the lawyer |
Legal hold: update stores set legal_hold_reason = '<why>' where shop_domain = …. Null releases it.
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.