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: 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/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 — 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 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 (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 → softDeleteProduct found nothing on a full-synced store, so the deleted product stayed try-on-enabled;
  • archiveStaleProductDuplicates could 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, inside waitUntil) and the embedded session-token exchange (/api/auth/session).
  • Uninstall — wired in handleShopifyUninstallWebhook (lib/server/shopify.ts), the app/uninstalled handler 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 &lt; 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 in getStoreByDomain’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): every status = 'uninstalled' store whose stores.uninstalled_at is ≥ 29 days old (the #531 clock, so a daily run never lands on day 31), or that has no platform_connections row (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):

  1. Guard: an installed store (reinstalled before the redact), a store with legal_hold_reason set, or an unknown shop → a skipped record, nothing deleted. An installed store is logged at error.
  2. count_store_rows over every planned table + capture_platform_monthly_totals(), then platform_monthly_carry(store).
  3. The store_erasures record, before any delete (counts + carry). It is data, so it survives app_config.log_level = error; after 90 days reduceStoreErasureRecords clears domain, store id, note, error and carry. The counts stay.
  4. Stage 0: platform_connections, store_integrations.
  5. 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.
  6. detach_store_financial_records(store, RETAINED_FINANCIAL_TABLES): see below.
  7. Stages 1–5 of STORE_ERASURE_STAGES through erase_store_rows(store, table, batch): one bounded DELETE … 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 against pg_constraint). The five tables without store_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_logs is reached by shop_domain.
  8. The stores row, only while it still reads uninstalled. Then the record is marked completed.

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:

KeptWhyRule
billing_events, charged (settled) order_fees, affiliate_commission_entriesAccounting records, pending the accountant’s confirmationstore_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 storesdetached; name → Former merchant; email, company and notes are cleared
platform_monthly_totalsAnonymous monthly totals across all stores (test stores excluded)See Data Model
store_erasuresThe record that the erasure happenedIdentity is cleared after 90 days
install_events#531 reinstall resilienceDetached; its own 29-day purge applies
email_unsubscribesSuppression listUntouched (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

  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.