Install Attribution

Install-Source Attribution (#313)

Records which acquisition channel brought each merchant to install. Before #313 nothing recorded this: the 2026-08-10 prod funnel audit found 24 merchants and zero attribution — no way to tell an app-store browse from a cold-outreach install from a landing-page signup.

Channel taxonomy

app_store | outreach | landing | direct | unknown

Defined as ATTRIBUTION_CHANNELS in lib/attribution/install-attribution.ts.

⚠️

Missing or unverifiable signal is always unknown, never bucketed into direct (AC5). direct is reserved for an explicit future signal (e.g. a dedicated “type in the URL” capture) — today nothing sets it, so a gap in the mapping stays visibly unknown instead of silently inflating a channel the business would then trust.

A first-party, signed cookie (ttn_attr) carries the acquisition signal from wherever it was first observed to the one place an install is certain — the Shopify OAuth callback. Pure module: lib/attribution/install-attribution.ts (no I/O, no server-only) exports signing/verification, channel normalization, and cookie options.

  • Signing — HMAC-SHA256, base64url body.sig, signature truncated to 27 base64url chars (~162 bits). Same recipe and same secret (OUTREACH_LINK_SECRET) as #270’s outreach link tokens (lib/outreach/link-token.ts) — no new env var. An unverifiable cookie (wrong signature, tampered body, wrong shape) is treated as absent, never trusted.
  • Lifetime — 30 days (ATTRIBUTION_COOKIE_MAX_AGE_SECONDS): long enough for an outreach lead to read the email and install days later, short enough that the signal still plausibly caused the install.
  • Domain scope — the click that sets the cookie lands on tryvio.ai; the OAuth callback that reads it runs on app.tryvio.ai. attributionCookieOptions() scopes the cookie to the parent domain .tryvio.ai whenever the host is tryvio.ai or ends in .tryvio.ai; elsewhere (localhost, previews) it’s host-only. Clearing the cookie must reuse the same options (with maxAge: 0) or the domain won’t match and the cookie survives.
  • Payload — { ch, d?, s?, k?, ref?, ts }: d/s/k (lead domain / sequence step / link destination key) are outreach-only, from the /r/<token> payload; ref carries the raw ref/utm params observed on a non-outreach install (only the allowlisted keys: ref, utm_source, utm_medium, utm_campaign, utm_content, utm_term, each clamped to 200 chars).

First-touch strength

When a new signal is observed and a cookie already exists, the new one replaces it only if strictly stronger — first touch wins otherwise:

ChannelStrength
outreach4 (a signed token identifying the exact lead)
landing / direct3 (explicit param)
app_store2 (inferred from Referer)
unknown1

channelStrength() implements the ordering; equal strength never overwrites.

Stamping points

Two places observe a signal and set/refresh the cookie:

  1. /r/<token> outreach redirect (src/app/r/[token]/route.ts) — after a verified click (same token verification as #270’s click tracking), stamps { ch: "outreach", d: <lead domain>, s: <sequence step>, k: <destination key>, ts } on the redirect response, additive to the existing click-recording behavior.
  2. GET /api/auth/install — reads the allowlisted ref/utm_* query params, or infers app_store from an apps.shopify.com Referer (exact-hostname check, not a substring match — a lookalike Referer can’t mint the signal). Only overwrites an existing cookie per the first-touch strength rule above; no signal at all sets nothing (the callback resolves that to unknown on its own).

Consumption: /api/auth/callback

The callback reads and verifies the ttn_attr cookie BEFORE the install, passes the signal into completeShopifyInstall (whose lifecycle event carries it, #531), and afterwards calls recordInstallAttribution (lib/server/supabase-admin.ts) to stamp first-touch acquisition:

  • No cookie, or a cookie that fails verification → { channel: "unknown", ref: null }.
  • A verified cookie → { channel: signal.ch, ref: { ...signal } } (the full decoded payload).

This is non-fatal to the install — the OAuth flow completes and redirects the merchant into the app either way — but a DB error is caught and logged loudly (oauth.attribution_record_failed), never swallowed. The cookie is cleared on the response afterward (same options as the setter, so the parent-domain cookie actually clears) so a later reinstall records what it observes then, not a stale first-visit cookie.

Persistence

Migration 20260811b_install_attribution.sql:

  • stores.acquisition_channel / acquisition_ref / acquired_at — the first-touch attribution. recordInstallAttribution stamps it once, guarded by an acquisition_channel IS NULL update predicate, so a concurrent duplicate callback can never re-attribute an already- attributed store (AC3). acquisition_ref holds the verified cookie payload (or null).
  • install_events — append-only history table: one row per completed install, reinstall, and uninstall (kind check constraint: install | reinstall | uninstall). channel/ref on each row are the signal observed at that event, not the store’s authoritative first touch. shop_domain is denormalized (in addition to the store_id FK) because the shop/redact wipe removes the store row, and the lifecycle must survive without a join target. Since #531 (20260914b_install_lifecycle.sql) store_id is nullable and ON DELETE SET NULL (it was CASCADE, which deleted the history with the row), and billing (jsonb) holds the billing state before and after the event — see Billing Pipeline. RLS is enabled with zero policies — same deny-by-default posture as the rest of the schema; only the service-role client can read/write. Indexed on (store_id, occurred_at desc).
  • Backfill — every pre-existing store is set to acquisition_channel = 'unknown', acquired_at = created_at (never 'direct' — same AC5 rule as the live path).

Reinstalls and uninstalls

  • Install / reinstall — since #531 the event is written by completeShopifyInstall, the function both the OAuth callback AND the managed-install token exchange (/api/auth/session) call — before, only the callback logged, so almost no install was recorded. It is classified from the PRIOR row: no row → install; status='uninstalled' (or no row but earlier history) → reinstall; an installed row (a re-auth) → no event. recordInstallAttribution only stamps stores.acquisition_* once and leaves it untouched afterwards — the original first-touch attribution is preserved.
  • Uninstall — handleShopifyUninstallWebhook (lib/server/shopify.ts) appends an install_events row with kind: "uninstall" (no channel/ref — it isn’t an attribution signal; billing = the state before and after the #531 settlement) after the existing uninstall cleanup. Non-fatal: caught and logged (shopify.uninstall_event_record_failed), never blocks the webhook.

Admin surface

getAdminMetrics() (lib/server/supabase-admin.ts) exposes the channel breakdown and per-merchant source, rendered on /admin (admin-dashboard-client.tsx):

  • Overview tab — totals.sourceBreakdown: installs and current paid conversions per channel.
  • Merchants table — a “Source” column reading each row’s installSource (store.acquisition_channel ?? null, displayed as unknown when null).

See Admin, Landing & Demo for the rest of the admin dashboard.

Relationship to the activation chase (#312)

The hourly /api/cron/activation-chase reads install_events to find each active store’s latest install/reinstall timestamp, then chases stores still at zero catalog_products 24h and 72h after that event. See Architecture → Activation chase.

Relationship to the affiliate programme (#326, #337)

The affiliate programme reuses the same signed-cookie/first-party-attribution rails described here to carry both partner referral links (#326) and merchant-to-merchant referral links (#337, every store is a referrer too) through to install. Its own data model (affiliate_attributions) is separate and rides on top of this pipeline. See Affiliate & Referral Programme.