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 | unknownDefined 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.
Mechanism: the ttn_attr cookie
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 onapp.tryvio.ai.attributionCookieOptions()scopes the cookie to the parent domain.tryvio.aiwhenever the host istryvio.aior ends in.tryvio.ai; elsewhere (localhost, previews) it’s host-only. Clearing the cookie must reuse the same options (withmaxAge: 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;refcarries 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:
| Channel | Strength |
|---|---|
outreach | 4 (a signed token identifying the exact lead) |
landing / direct | 3 (explicit param) |
app_store | 2 (inferred from Referer) |
unknown | 1 |
channelStrength() implements the ordering; equal strength never overwrites.
Stamping points
Two places observe a signal and set/refresh the cookie:
/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.GET /api/auth/install— reads the allowlistedref/utm_*query params, or infersapp_storefrom anapps.shopify.comReferer (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 tounknownon 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.recordInstallAttributionstamps it once, guarded by anacquisition_channel IS NULLupdate predicate, so a concurrent duplicate callback can never re-attribute an already- attributed store (AC3).acquisition_refholds the verified cookie payload (ornull).install_events— append-only history table: one row per completed install, reinstall, and uninstall (kindcheck constraint:install | reinstall | uninstall).channel/refon each row are the signal observed at that event, not the store’s authoritative first touch.shop_domainis denormalized (in addition to thestore_idFK) 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_idis nullable andON DELETE SET NULL(it wasCASCADE, which deleted the history with the row), andbilling(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.recordInstallAttributiononly stampsstores.acquisition_*once and leaves it untouched afterwards — the original first-touch attribution is preserved. - Uninstall —
handleShopifyUninstallWebhook(lib/server/shopify.ts) appends aninstall_eventsrow withkind: "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 asunknownwhen 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.