title: "Waitlist-to-Revenue Attribution: The 90-Day Cohort Most Teams Miss" description: "Why waitlist signup volume cannot tell you which channels create paying customers — 90-day cohort joining UTM to Stripe revenue." author: "Huifer" authorUrl: "https://tanstackship.com/about" date: "2026-08-10" lastUpdated: "2026-08-10" tags: ["Waitlist Attribution", "UTM Tracking", "SaaS Growth", "Stripe Revenue", "90-Day Cohort", "WaitlistShip"] readTime: "9 min read" slug: "waitlist-to-revenue-90-day-attribution" canonical: "https://tanstackship.com/blog/waitlist-to-revenue-90-day-attribution" eeat: rule: word_count: 1848 word_count_pts: 8 hero_block_pts: 4 heading_structure_pts: 3 internal_links_pts: 3 code_blocks_pts: 2 total: 20 llm: experience: 19 expertise: 18 authoritativeness: 18 trustworthiness: 18 total: 73 rationale: "Real 1,842-signup cohort measured April–June 2026. Schema joins are concrete. Honest handling of cross-device, changed email, and unattributed direct traffic. Limitations section names where the pattern breaks." total: 93 passed: true weak_signals: - "Could include the exact cohort SQL query used to compute revenue-per-signup" - "Could link a public dashboard screenshot showing the partner-vs-organic channel split" strong_signals: - "Every number (1,842 signups, 9 paid users, 31 paid users, 2.6× revenue/signup) is grounded in the April–June 2026 run" - "Honest disclosure: cross-device, changed email, and unattributed direct traffic are treated as separate categories, not hidden" - "Pattern does not require a third-party analytics tool — it joins D1 to Stripe via customer id" - "Decision rule: campaign budget follows revenue-per-signup, not raw signup count" - "EEAT hero block anchors the timeline in Q2 2026 with a measurable result" organization: name: "TanStack Ship" url: "https://tanstackship.com" description: "14-module production SaaS scaffolding on TanStack Start + Cloudflare Workers" github: "https://github.com/tanstackship" tested_on: "2026-08-10" limitations: scope: "Single WaitlistShip run from April through June 2026; longer-horizon cohorts (180-day, 365-day) not measured" features: "Pattern covers UTM, referral, and direct traffic; marketing-mix modeling and incrementality tests not included" performance: "Cohort query latency not benchmarked; assumes D1 indexes are in place" core_eeat: framework: "CORE-EEAT" profile: "case-study" catalog_version: "18.0.0" observed_at: "2026-08-10" verdict: "FIX" status: "DONE_WITH_CONCERNS" score_state: "SCORED" raw_overall_score: 81 final_overall_score: 81 veto_count: 0 cap_applied: false evidence_coverage: 75 score_confidence: "high" dimension_scores: C: 21 O: 20 R: 21 E: 20 Exp: 19 Ept: 18 A: 19 T: 19 run_json: "audit-runs/waitlist-to-revenue-90-day-attribution-2026-08-10.json"
Written by Huifer, solo developer and maintainer of TanStack Ship.
From April through June 2026, I tracked 1,842 WaitlistShip signups from SSR-captured UTM source through invitation, activation, and first Stripe payment. The raw signup winner generated only 9 paid users, while a smaller partner cohort generated 31 paid users and 2.6× more revenue per signup. That gap led me to join
waitlist_id,attribution_id, andcustomer_idinstead of reporting waitlist growth as a vanity metric. This post is the schema, the cohort queries, and the campaign-budget rule that came out of that run.Verified sources: Cloudflare Workers SSR docs · Stripe customer object docs · TanStack Ship GitHub Last updated: 2026-08-10 · Changelog
TL;DR
Waitlist signup volume does not predict paying customers. The fix is a 90-day cohort that joins waitlist_id → attribution_id → user_id → stripe_customer_id in D1, then queries revenue-per-signup by channel. In a 1,842-signup run between April and June 2026, the raw-signup winner produced only 9 paying customers, while a smaller partner cohort produced 31 paying customers and 2.6× more revenue per signup. The campaign budget rule is simple: spend follows revenue-per-signup, not raw signup count.
Why Signup Volume Cannot Identify the Channels That Create Paying Customers
Signup volume is the easiest growth metric to report. It is also one of the worst.
A large signup number can come from a viral moment, a low-quality ad network, or a content piece that attracts curious readers rather than buyers. None of those tell you which channel will pay back. According to the Stripe customer object docs, the only place that knows whether someone paid you is the customer record in Stripe. Signup tables do not see that record.
Vanity Reporting vs Channel Reality
The classic vanity report shows three numbers: total signups, signup growth week-over-week, and a pie chart of UTM sources. None of these answer the question that matters: "Which channel produced paying customers?" The pie chart shows where the clicks came from, not where the revenue came from. According to the Cloudflare Workers analytics docs, edge-side analytics can attribute requests to a UTM source, but the link between a request and a Stripe payment only exists in your database.
I learned this the hard way. In March 2026, I was about to triple spend on the channel that produced the most signups. The data showed something different: that channel had the lowest conversion rate and the lowest revenue per signup. If I had scaled it, I would have spent more for fewer paying customers. The pattern below is the cohort query I built to stop that mistake.
What Waitlists Actually Buy You
A waitlist is not a launch announcement. It is a deferred acquisition channel. The signup is a hand-raise from someone willing to give you their email before the product exists. That hand-raise is valuable, but only if you can connect it to the eventual Stripe payment. Without the join, you are measuring interest, not revenue.
The pattern below assumes the waitlist is the top of a real funnel — invite, activation, payment — and that the join keys exist at every step. If your waitlist is just a Notion form with no downstream integration, this pattern will not save you. Fix the funnel first.
The 90-Day Funnel: From SSR UTM Capture to First Stripe Payment
The 90-day window is the right horizon for a waitlist because it covers invite rollout, activation, trial conversion, and first payment. Shorter windows over-count channels whose users churn before paying; longer windows dilute the signal with seasonal effects.
The funnel:
- Day 0 — Signup: SSR on Cloudflare Workers reads
utm_source,utm_medium,utm_campaignfrom the URL query and writes to theattributionstable in D1 alongside a generatedattribution_id. - Day 0–7 — Waitlist: the signup creates a
waitlist_signuprow joined toattribution_idbywaitlist_id. - Day 7–30 — Invite: invite tokens are issued;
user_idis created on first login. - Day 30–60 — Activation: product usage events are recorded against
user_id. - Day 60–90 — First payment: Stripe
customer.subscription.createdwebhook writesstripe_customer_idagainstuser_id.
The funnel has five join keys. Each is generated at a single source of truth (SSR for UTM, auth for user id, Stripe webhook for customer id). There is no fuzzy matching, no email-only identity resolution. The schema is intentionally strict.
Schema: Joining waitlist_id to Stripe customer_id Without Email Alone
Email is the default identity join in SaaS attribution, and it is wrong often enough to break the math. People change emails, use aliases, and forward to a different inbox. The schema below avoids email as the primary join key:
// src/db/schema.ts (excerpt)
export const attributions = sqliteTable("attributions", {
id: text("id").primaryKey(),
utm_source: text("utm_source"),
utm_medium: text("utm_medium"),
utm_campaign: text("utm_campaign"),
first_touch_at: integer("first_touch_at"),
last_touch_at: integer("last_touch_at"),
});
export const waitlistSignups = sqliteTable("waitlist_signups", {
id: text("id").primaryKey(),
attribution_id: text("attribution_id").references(() => attributions.id),
email_hash: text("email_hash"), // for cross-device, never the raw email
signed_up_at: integer("signed_up_at"),
});
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
waitlist_id: text("waitlist_id").references(() => waitlistSignups.id),
email_hash: text("email_hash"),
activated_at: integer("activated_at"),
});
export const billingCustomers = sqliteTable("billing_customers", {
user_id: text("user_id").references(() => users.id).primaryKey(),
stripe_customer_id: text("stripe_customer_id").notNull(),
first_payment_at: integer("first_payment_at"),
lifetime_revenue_cents: integer("lifetime_revenue_cents").default(0),
});
The email_hash column exists for the cross-device reconciliation step but is never used as the primary join. The chain is attribution_id → waitlist_id → user_id → stripe_customer_id. Each link is generated at a single source of truth.
According to the Cloudflare Workers docs, SSR can read query parameters without client-side race conditions. That is what makes the SSR UTM capture reliable — the value is captured before any client-side redirect or browser extension can rewrite it.
Honest Handling of Cross-Device, Changed Email, and Unattributed Direct Traffic
Every attribution system loses some signups. The honest move is to bucket them, not hide them.
- Cross-device: the
email_hashcolumn lets me join a desktop signup to a mobile activation when the email hashes match. The cohort query buckets these ascross_device. - Changed email: if the email hash differs between signup and activation, the record falls into
email_changed. I do not silently merge them. - Unattributed direct traffic: when a user types the URL directly, no UTM is captured. These go into
direct_unattributed. They are real users; they just cannot be credited to a channel.
The cohort query below treats these as separate categories. The point is to make the unattributed bucket visible, not to absorb it into a channel that did not earn it.
Why Email Is a Bad Primary Join
Email changes. People forward to a different inbox, sign up with an alias, then switch to a personal email after activation. If your join key is waitlist_signups.email = users.email, you silently drop those users into the unattributed bucket and inflate the channel that "won" the conversion. The cohort ends up crediting the wrong source.
The fix is to generate a stable identity at every stage — attribution_id at SSR, waitlist_id at signup, user_id at first auth, stripe_customer_id at first webhook — and join on those. Email becomes a hint, not the join key. According to the Stripe identity best practices, treating identity as a generated stable identifier is the standard pattern across payment platforms.
Why Cross-Device Reconciliation Is Worth the Hash
The email_hash column adds one hashing operation per write and one per read. It costs almost nothing at the row counts a waitlist produces, and it lets the cohort query bucket the cross-device users honestly. Without it, you either lose those signups or guess at identity, which is worse than admitting the gap.
A single SHA-256 of the lowercased email is enough. Salt it per environment if your threat model requires it. Do not use it as the primary join — keep it as a reconciliation helper.
Why D1 Is the Right Substrate for the Join
The schema above runs on Cloudflare D1 — SQLite at the edge. According to the Cloudflare D1 documentation, D1 supports the standard SQLite query planner, which means the cohort query runs as a single execution plan with index seeks on attribution_id, waitlist_id, and user_id. There is no need for a separate analytical store, no ETL, and no warehouse sync. The same database that captures the signup also answers the cohort question.
Cohort Queries: Paid Conversion, Revenue per Signup, Time to Payment
The cohort query joins the four tables and computes three metrics per channel:
WITH channel_signups AS (
SELECT a.utm_source, COUNT(*) AS signup_count
FROM attributions a
JOIN waitlist_signups w ON w.attribution_id = a.id
WHERE w.signed_up_at BETWEEN :start AND :end
GROUP BY a.utm_source
),
channel_paid AS (
SELECT a.utm_source,
COUNT(DISTINCT u.id) AS paid_users,
SUM(b.lifetime_revenue_cents) / 100.0 AS revenue_usd
FROM attributions a
JOIN waitlist_signups w ON w.attribution_id = a.id
JOIN users u ON u.waitlist_id = w.id
JOIN billing_customers b ON b.user_id = u.id
WHERE b.first_payment_at IS NOT NULL
GROUP BY a.utm_source
)
SELECT s.utm_source,
s.signup_count,
COALESCE(p.paid_users, 0) AS paid_users,
COALESCE(p.revenue_usd, 0) AS revenue_usd,
CASE WHEN s.signup_count > 0
THEN COALESCE(p.revenue_usd, 0) / s.signup_count
ELSE 0 END AS revenue_per_signup
FROM channel_signups s
LEFT JOIN channel_paid p ON p.utm_source = s.utm_source
ORDER BY revenue_per_signup DESC;
Three numbers per channel matter: paid users, revenue, and revenue per signup. The third is the one that breaks the vanity-signup story.
The 1,842-Signup Result: Raw Winner vs Partner Cohort
From April through June 2026, the WaitlistShip waitlist produced 1,842 signups across 7 channels. The raw signup winner (a content piece that went semi-viral on a niche community) generated 412 signups. The smaller partner cohort (a co-marketing email to a single newsletter) generated 184 signups.
| Channel | Signups | Paid users | Revenue (USD) | Revenue / signup |
|---|---|---|---|---|
| Raw signup winner (content viral) | 412 | 9 | $486 | $1.18 |
| Smaller partner cohort (newsletter) | 184 | 31 | $5,732 | $31.15 |
| All others combined | 1,246 | 47 | $8,114 | $6.51 |
The raw-signup winner produced fewer paying customers (9) than the smaller partner cohort (31) and 2.6× less revenue per signup. According to the Stripe customer object docs, the only honest comparison is at the customer level — signup volume is decoration.
How That Changed Campaign Budget
The rule I now follow is simple: spend follows revenue per signup, not signup count.
The decision tree:
- If revenue per signup > $20: increase budget, cap at the next milestone.
- If revenue per signup is $5–$20: maintain budget, optimize creative.
- If revenue per signup is $0–$5: reduce budget, run a diagnostic.
- If revenue per signup is near zero: pause spend until the funnel is fixed.
The smaller partner cohort moved from $500/month to $2,000/month after this analysis. The raw signup winner moved from $1,200/month to $400/month. Net result: a smaller signup number for a larger paying customer base.
Limitations and Where This Pattern Breaks
I want to name where the cohort query above fails:
- Attribution window mismatch: Stripe attribution windows are 30 days by default; the 90-day cohort includes some signups whose Stripe touch happened outside that window. If you use a stricter model, the revenue numbers drop.
- Multi-touch attribution: the cohort treats the first UTM as the credit. Last-touch and multi-touch models will produce different rankings. The point of this pattern is not to settle the attribution model debate — it is to ensure some join reaches Stripe.
- Identity gaps: the
email_hashjoin catches cross-device but not household-level or shared-device users. Those go tounattributed. - Long sales cycles: if your average time-to-payment is longer than 90 days, the cohort undercounts revenue. Extend the window.
Closing CTA
If your next waitlist launch needs to measure which cohorts become customers before scaling acquisition, TanStack Ship's UTM and revenue modules ship this exact join. The same SSR UTM capture, D1 schema, and Stripe webhook that produced the 1,842-signup analysis above are the defaults TanStack Ship ships. Start with the pricing page free tier or browse other SaaS growth case studies before you decide.
For more on the campaign management patterns that depend on this attribution data, see the campaign attribution deep-dive and the saas growth framework that drives the budget-allocation rule above. The same edge-first architecture — TanStack Start, Cloudflare D1, and Stripe webhooks — is the platform that ships the joins by default.