FREE SAMPLE — BLUEPRINT 00 · THE FULL FORMAT, NOTHING CUT
1. Product brief
The Validation Machine is not a product you sell. It is the instrument you build once and point at every idea you have afterwards. It answers a single question with numbers instead of vibes: does anyone want this enough to pay before it exists?
It has four parts and no fifth: a landing page making exactly one promise, a double-opt-in waitlist, a real Stripe pre-order checkout with an unconditional refund promise, and a readout that turns visits → emails → pre-orders into a verdict string.
Who this is for and why. The failure mode it kills is the expensive one: three months building, then launch, then silence. The usual "validation" substitutes — asking friends, posting a poll, a Typeform, counting GitHub stars — measure politeness, not demand. Email addresses are cheap to give. The only signal in the funnel that costs the visitor something real is a card charge. That is why the pre-order is load-bearing and why a blueprint that stops at "collect emails" is a worse instrument.
The ONE core loop.
one promise → visit measured → email captured → email confirmed
→ pre-order charged → funnel read → SHIP or KILL
Everything else is v2. The instrument's value is entirely determined by whether its numbers are trustworthy, which is why Milestones 3, 5 and 6 are about measurement integrity rather than features.
The honest hard part. Most people build the page and then discover they have no traffic, so the funnel reads 40 visits and nothing is learnable. The build is the easy half. Milestone 7 is the other half, and skipping it wastes the whole exercise.
Pricing
You are not selling this. You are pricing the pre-order on the page it hosts, and that number is the measurement itself.
Charge 40–60% of your intended launch price, in full, today, with a no-questions refund until launch day. If you intend to charge $20/month, pre-sell a year at $99. If it is a one-off tool at $79, pre-sell at $39.
Reasoning: a $1 "commitment" measures nothing — people spend $1 to be nice. A price that requires a two-second pause is the entire experiment. Charging in full (not $1 authorizations, not "reserve your spot") is what makes the number honest, and the unconditional refund is what makes it fair. Expect and budget for refunds; a refund is a successful measurement, not a loss.
Assumption, stated as such: somewhere around 40–60% of intended price is the zone where the decision is real but the buyer isn't gambling. This is judgement, not measured data. If your intended price is above ~$300, pre-sell a deposit instead and say plainly that it is a deposit.
2. Scope contract
v1 IN
- One landing page, one promise, one email form, one pre-order button.
- Double opt-in email capture: submit → confirmation email → explicit confirm click.
- One Stripe Payment Link (test mode, then live) with a webhook that records paid pre-orders.
- First-party visit measurement with no cookies and no third-party script.
- One private
/adminreadout: funnel counts, per-channel breakdown, computed verdict. - One transactional email template (the confirmation) and one plain thank-you page.
v1 OUT — these are prohibitions, not suggestions
- No user accounts, no login, no password reset. The only authenticated human is you.
- No A/B testing framework. One promise at a time. Comparing two promises at 200 visits each produces two meaningless numbers instead of one meaningful one.
- No CMS, no blog, no marketing site. One route.
- No Google Analytics, Plausible, PostHog, Segment, or any third-party tag. Your own events are the source of truth and cannot be blocked by an extension.
- No cookie banner, because no cookies. If you add a cookie you inherit a consent problem.
- No Stripe Checkout Session API, no custom payment form, no Stripe Elements. A dashboard-created Payment Link is fewer moving parts and cannot be broken by your own code.
- No subscriptions in v1. Pre-sell a one-time amount even for a subscription product.
- No email drip sequence, no broadcast tooling. Capture and confirm only. Talking to the list is a post-verdict activity.
- No "coming soon" countdown, no fake scarcity, no fake testimonials, no fake logos. Beyond being dishonest, they corrupt the measurement — you will not know whether the pressure or the promise converted.
- No custom design system. Tailwind defaults, system font stack, one accent color.
- No mobile app, no waitlist referral loop, no "share to skip the line".
3. Architecture
Decided, with reasons. Do not substitute.
- Next.js 16 (App Router) on Vercel. One deploy target, route handlers for the four endpoints, static-rendered landing page. Verified current stable: 16.3 (released 3 Aug 2026).
- Vercel Pro, not Hobby. Vercel's fair-use guidelines restrict Hobby to non-commercial personal use and explicitly list "any method of requesting or processing payment from visitors of the site" as commercial. A pre-order button makes this project commercial from day one. Budget $20/month; do not discover this after launch.
- Neon Postgres + Drizzle ORM. Free tier, branchable, serverless HTTP driver. The app queries via
drizzle-orm/neon-httpwith the pooled connection string;drizzle-kitneeds the unpooled string (DATABASE_URL_UNPOOLED, hostname without-pooler,?sslmode=require) because the CLI connects over plain TCP withnode-postgresregardless of your app driver. Getting this wrong is the single most common Neon+Drizzle failure. - Resend for transactional email. One confirmation template, no marketing platform. Free tier is 100 emails/day and 3,000/month, one verified domain, 10 requests/second per team (checked Aug 2026). A signup spike past 100/day silently stalls confirmations, which corrupts your confirm rate — Milestone 4 handles this explicitly.
- Stripe Payment Link, created in the dashboard. No checkout code to write or break. Attribution
rides on documented URL parameters:
client_reference_id(your visitor key, ≤200 chars, alphanumeric/dash/underscore) andprefilled_email. UTM parameters on the link are automatically carried through to your redirect URL. - The webhook is the source of truth, the redirect is only UX.
checkout.session.completeddelivered to/api/stripe/webhook, signature-verified. The success redirect can be closed, blocked, or replayed; the webhook cannot be forged. - Cookieless visitor identity.
visitor_key = sha256(daily_salt || ip || user_agent), truncated to 32 hex chars. The raw IP is never written anywhere. The salt lives in adaily_salttable — not in memory, not in an env var — because serverless instances do not share memory and per-instance salts would inflate your visit count by an unknown multiple. Salts older than 2 days are deleted, which makes retroactive re-identification infeasible. This is why there is no cookie and no banner. - Ingest path is
/api/e. Not/api/analytics, not/api/track. Common blocklists match those substrings; a blocked beacon reads as a lost visitor and skews every ratio below it. - Admin auth is one shared secret. Visit
/admin?k=<ADMIN_KEY>; a timing-safe compare sets an httpOnly signed cookie for 30 days. There is one user. Anything more is scope creep.
Data flow
browser ──GET /──────────► static page (no JS blocking render)
──POST /api/e ───► view event (visitor_key computed server-side)
──POST /api/wait─► subscriber(pending) + Resend confirmation
──POST /confirm ─► subscriber(confirmed) + confirm event
──GET buy.stripe.com/... ?client_reference_id=<visitor_key>&prefilled_email=...
Stripe ──POST /api/stripe/webhook ─► checkouts row + checkout_paid event
you ──GET /admin ─────► funnel + per-channel + verdict
4. Data model
Four tables. Every field earns its place.
-- Rotating salt so visitor hashes are stable within a day and unlinkable across days.
-- Shared state, because serverless instances do not share memory.
create table daily_salt (
day date primary key,
salt text not null -- 32 random bytes, hex
);
-- Every measured moment. One table, because the funnel is one ordered sequence.
create table events (
id bigserial primary key,
ts timestamptz not null default now(),
kind text not null check (kind in
('view','signup','confirm','checkout_start','checkout_paid')),
visitor_key char(32) not null, -- daily hash; never an IP
channel text not null default 'direct', -- normalized utm_source
dedupe_key text, -- stripe event id, token id, etc.
meta jsonb not null default '{}'::jsonb
);
create unique index events_dedupe on events (kind, dedupe_key) where dedupe_key is not null;
create index events_ts on events (ts);
create index events_kind on events (kind, visitor_key);
-- The waitlist. Lowercased text + unique beats citext: no extension to install.
create table subscribers (
id bigserial primary key,
email text not null unique, -- always stored lowercased, trimmed
status text not null default 'pending'
check (status in ('pending','confirmed','unsubscribed','bounced')),
token_hash char(64) not null, -- sha256 of the confirm token
token_expires timestamptz not null, -- now() + 7 days
visitor_key char(32), -- attribution, nullable
channel text not null default 'direct',
created_at timestamptz not null default now(),
confirmed_at timestamptz
);
-- Paid pre-orders, written only by the verified webhook.
create table checkouts (
id bigserial primary key,
stripe_session_id text not null unique, -- idempotency vs. Stripe retries
stripe_event_id text not null unique, -- idempotency vs. duplicate delivery
email text,
amount_total integer not null, -- minor units, as Stripe sends
currency char(3) not null,
visitor_key char(32), -- from client_reference_id
channel text not null default 'direct',
status text not null default 'paid'
check (status in ('paid','refunded')),
created_at timestamptz not null default now()
);
Why the raw confirm token is not stored: a leaked database read should not let anyone confirm addresses they do not control. The token exists in exactly one place, the email.
Why amount_total is an integer of minor units: Stripe sends minor units. Converting to a float on
the way in is how you end up with $38.99999 in your readout.
5. Milestones
M1 — Skeleton, schema, deployed on day one
Goal: A live URL on your real domain, backed by a real database, before any feature exists.
Agent prompt:
Create a new Next.js project using the App Router and TypeScript, styled with Tailwind, in this
directory. It will be deployed to Vercel and backed by a Neon Postgres database accessed through
Drizzle ORM using the Neon serverless HTTP driver.
Set up the four tables I will paste after this message as a Drizzle schema in TypeScript, and wire
drizzle-kit so I can push the schema to the database. Important: the app's runtime database
connection must use the pooled Neon connection string, but drizzle-kit must be configured to use a
separate unpooled connection string, because the CLI connects over plain TCP and fails against a
pooled URL.
Add a health endpoint that performs one trivial round-trip query against the database and returns
JSON containing an ok boolean, the current database time, and the count of rows in the events table.
It must return HTTP 503 with ok false if the query fails, never a 500 stack trace.
Read every secret from environment variables. Create an .env.example listing every variable name with
an empty value and a one-line comment. Never commit real values. Add a README section listing the
exact commands to install, push the schema, run locally, and deploy.
Produces: app/api/health/route.ts, db/schema.ts, db/client.ts, drizzle.config.ts,
.env.example, a Vercel project on your domain.
Verification gate:
npx drizzle-kit push # expect: 4 tables created, no pooled-URL warning
curl -s https://YOURDOMAIN/api/health | jq
# expect exactly: {"ok":true,"dbTime":"<ISO timestamp>","events":0}
curl -s -o /dev/null -w '%{http_code}\n' https://YOURDOMAIN/
# expect: 200
Then, in the Neon console, break it on purpose: pause the database, re-run the health curl, expect
503 and "ok":false. Resume. A health check that cannot fail is not a health check.
M2 — The one-promise landing page
Goal: A page that states one promise, offers one email field and one pre-order button, and is mechanically prevented from growing a second idea.
Agent prompt:
Build the single landing page for this project. Its entire job is to make one promise clearly enough
that a stranger decides in under fifteen seconds whether they want it.
Structure, in order: one headline stating the promise in twelve words or fewer; one subheading of at
most two sentences saying who it is for and what changes for them; three short bullets of concrete
specifics, no adjectives like "powerful" or "seamless"; the email capture form; the pre-order button
with the price and a refund promise directly beneath it in plain language; a one-line footer with a
mailto contact address. Nothing else. No navigation bar, no testimonials, no logo wall, no FAQ, no
countdown timer, no second call to action.
Constraints: it must render fully without JavaScript, use only the system font stack and Tailwind
defaults with a single accent color, and score well on mobile at 375px width. The page must be
statically rendered.
Also write an automated test asserting the page's structure so it cannot drift: exactly one h1,
exactly one form element, exactly one external payment link, no anchor tags to any other page on this
site, and a headline of twelve words or fewer. This test is a permanent guard, not a one-off check.
Produces: app/page.tsx, app/layout.tsx, tests/page-contract.test.ts.
Verification gate:
npm test -- page-contract
# expect: all assertions pass
curl -s https://YOURDOMAIN/ | grep -c '<h1' # expect: 1
curl -s https://YOURDOMAIN/ | grep -c '<form' # expect: 1
curl -s https://YOURDOMAIN/ | grep -oc 'buy.stripe.com' # expect: 1
Human gate, and it is not optional: open the page on a phone, hand it to someone who does not know what you are building, and ask "what does this do and who is it for?" If they cannot answer in one sentence, the promise is the defect — not the page, not the funnel. Fix it before Milestone 3.
M3 — Cookieless visit measurement you can trust
Goal: Every human visit is counted once per day, bots are excluded, and no IP is ever stored.
Agent prompt:
Add first-party visit measurement to this app. No third-party analytics service, no cookies, no
localStorage.
Server-side, derive a visitor key by hashing a per-day secret salt together with the request's client
IP and user-agent string, keeping the first 32 hex characters. The raw IP must never be written to the
database or to logs. The per-day salt must be stored in the database table provided, generated on
first use of each day, and salts older than two days must be deleted whenever a new one is created.
Expose a small POST endpoint at the path /api/e that records a view event. Call it from the landing
page with a beacon-style request after first paint. Record the traffic channel from the utm_source
query parameter, normalized to lowercase, defaulting to the string "direct". A given visitor key must
produce at most one view event per day — enforce that in the database with the dedupe column, not with
application logic that can race.
Exclude obvious non-humans: reject requests whose user-agent matches common crawler, preview, and
uptime-monitor patterns, and reject requests with no user-agent at all. Return 204 for both accepted
and rejected requests so a caller learns nothing about the filter.
The endpoint must never trust a client-supplied visitor identifier. It is computed on the server only.
Produces: app/api/e/route.ts, lib/visitor.ts, lib/bots.ts, components/beacon.tsx.
Verification gate:
# 1. Same client, twice, counts once.
for i in 1 2; do curl -s -o /dev/null -w '%{http_code} ' -X POST https://YOURDOMAIN/api/e \
-H 'content-type: application/json' -H 'user-agent: Mozilla/5.0 (gate-test)' \
-d '{"kind":"view"}'; done; echo
# expect: 204 204
# then, in the Neon SQL editor:
# select count(*) from events where kind='view' and ts > now() - interval '5 minutes';
# expect: exactly 1 — two identical requests, one counted visit
# 2. A crawler is not a visitor.
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://YOURDOMAIN/api/e \
-H 'user-agent: Mozilla/5.0 (compatible; Googlebot/2.1)' -d '{"kind":"view"}'
# expect: 204, and NO new row in events
# 3. No IP anywhere. In the Neon SQL editor:
# select column_name from information_schema.columns where table_name='events';
# expect: the returned list contains no column named ip, ip_address, or remote_addr
# select count(*) from events where meta::text ~ '\d+\.\d+\.\d+\.\d+';
# expect: 0
M4 — Double opt-in waitlist
Goal: An email address in the database means a human typed it and then clicked to confirm it.
Agent prompt:
Implement the waitlist with genuine double opt-in, using Resend for delivery from a domain I have
verified with SPF, DKIM and DMARC records.
On form submission: validate and normalize the address to lowercase and trimmed, insert or update a
subscriber row in pending status, generate a high-entropy random confirmation token, store only its
SHA-256 hash plus an expiry seven days out, and email the raw token as a confirmation link. Record a
signup event attributed to the same visitor key and channel used by the view events. Re-submitting an
address that is already pending must resend the same-shaped email with a fresh token rather than
creating a duplicate row, and re-submitting an already-confirmed address must respond identically to a
new signup so the endpoint cannot be used to test whether an address is on the list.
The confirmation link must open a page with a single visible confirm button that submits a POST. Do
not confirm on the GET. Corporate mail scanners and link-preview bots fetch every URL in an inbound
email, and a GET-confirm endpoint will silently mark those addresses confirmed, inflating the exact
number this whole project exists to measure. On successful confirm, mark the row confirmed, stamp the
time, invalidate the token, and record a confirm event. An expired, unknown, or already-used token
must render a plain "this link is no longer valid, request a new one" page, never an error page.
Abuse controls: a honeypot field that real users never see and bots fill, rejection of submissions
made under two seconds after page load, and a limit of five signups per visitor key per hour enforced
by a database count rather than an in-memory counter.
Resend's free tier allows 100 emails per day. If a send is rejected for quota or rate reasons, still
create the pending row, mark that the confirmation is undelivered in the row's metadata, log it
loudly, and return success to the user. Losing the address is worse than a delayed email.
Produces: app/api/wait/route.ts, app/confirm/[token]/page.tsx,
app/api/confirm/route.ts, lib/email.ts, emails/confirm.tsx.
Verification gate:
# 1. Signup creates a pending row and sends one email.
curl -s -X POST https://YOURDOMAIN/api/wait -H 'content-type: application/json' \
-d '{"email":"gate+1@yourdomain.com","hp":"","t":9999}'
# expect: {"ok":true}
# select status, confirmed_at from subscribers where email='gate+1@yourdomain.com';
# expect: pending | null
# 2. The scanner test — a GET must NOT confirm.
curl -s -o /dev/null "https://YOURDOMAIN/confirm/<TOKEN_FROM_EMAIL>"
# select status from subscribers where email='gate+1@yourdomain.com';
# expect: still pending
# 3. The button POST confirms exactly once.
curl -s -X POST https://YOURDOMAIN/api/confirm -H 'content-type: application/json' \
-d '{"token":"<TOKEN_FROM_EMAIL>"}' # expect: {"ok":true}
curl -s -X POST https://YOURDOMAIN/api/confirm -H 'content-type: application/json' \
-d '{"token":"<TOKEN_FROM_EMAIL>"}' # expect: {"ok":false,"reason":"invalid"}
# select status, count(*) from subscribers where email='gate+1@yourdomain.com' group by status;
# expect: confirmed | 1
# select count(*) from events where kind='confirm'; -- increased by exactly 1
# 4. Enumeration is not possible: the response to a brand-new address and to an
# already-confirmed one must be byte-identical.
M5 — The pre-order checkout and the webhook that proves it
Goal: A real card charge lands in Stripe and in your database exactly once, attributed to the visitor who made it.
Create the Payment Link in the Stripe dashboard, in test mode first: one-time price at your
pre-order amount, "After payment → Don't show confirmation page", redirect to
https://YOURDOMAIN/thanks?session_id={CHECKOUT_SESSION_ID}. Stripe substitutes the real session id,
and any UTM parameters on the inbound link are carried through to that redirect automatically.
Agent prompt:
Wire the pre-order flow around an existing Stripe Payment Link whose base URL comes from an
environment variable.
On the landing page, build the outbound link by appending the current visitor key as the
client_reference_id parameter, and, when the visitor has already submitted an email this session,
their address as the prefilled_email parameter, percent-encoded. Preserve any utm_source from the
inbound URL. Record a checkout_start event at click time. Note that client_reference_id accepts only
alphanumerics, dashes and underscores up to 200 characters, and that invalid values are silently
dropped — the visitor key format must comply.
Implement the webhook endpoint that receives checkout.session.completed. It must run on the Node
runtime, read the request body as raw unmodified text before any JSON parsing, and verify the Stripe
signature header against the endpoint's signing secret using the SDK's asynchronous verification
helper. A missing, malformed, or invalid signature returns 400 and writes nothing. Only after
successful verification may the payload be trusted.
On a verified completed session, insert a checkout row recording the session id, the Stripe event id,
the customer email, the total in minor units exactly as sent, the currency, and the visitor key taken
from client_reference_id. Both the session id and the event id are unique keys: a duplicate delivery
of either must be a no-op that still returns 200, because Stripe retries and will send the same event
again if you do not. Record a matching checkout_paid event. Unhandled event types return 200 without
writing.
Also write an offline test that constructs a signed payload with the SDK's test-header helper and
asserts three things: a valid signature results in exactly one checkout row; replaying the identical
event results in still exactly one row; and a payload whose body has been altered by a single
character is rejected with 400 and writes nothing. This test must require no network and no Stripe
account.
Finally, add the thank-you page the redirect points at. It reads the session id from the query string,
displays a confirmation and the refund promise, and must not itself write any payment record — the
webhook is the only writer.
Produces: app/api/stripe/webhook/route.ts, app/thanks/page.tsx, lib/stripe.ts,
tests/webhook.test.ts.
Verification gate:
# 1. Offline, deterministic, no Stripe account needed:
npm test -- webhook
# expect 3 passing: valid → 1 row; replay → still 1 row; tampered body → 400 and 0 rows
# 2. Live against test mode, in two terminals:
stripe listen --events checkout.session.completed --forward-to localhost:3000/api/stripe/webhook
stripe trigger checkout.session.completed
# expect in the listen terminal: [200 POST /api/stripe/webhook]
# select count(*) from checkouts; -- increased by exactly 1
# select count(*) from events where kind='checkout_paid'; -- increased by exactly 1
# 3. Signature enforcement against the deployed URL:
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://YOURDOMAIN/api/stripe/webhook \
-H 'content-type: application/json' -H 'stripe-signature: t=1,v1=deadbeef' \
-d '{"type":"checkout.session.completed"}'
# expect: 400, and no new row in checkouts
# 4. Attribution end to end: open your own landing page, click the pre-order button, pay with
# test card 4242 4242 4242 4242, then:
# select visitor_key, amount_total, currency from checkouts order by id desc limit 1;
# expect: a 32-char visitor_key matching a view event from the same browser, and the exact
# minor-unit amount you configured (e.g. 3900 for $39.00)
M6 — The readout
Goal: One private page that shows the funnel, splits it by channel, and prints a verdict you did not get to argue with.
Agent prompt:
Build the private readout page at /admin, protected by a single shared secret supplied as a query
parameter on first visit, compared in constant time, and then held in a signed httpOnly cookie for
thirty days. No login form, no user table. Any unauthenticated request gets a 404, not a 401 — an
unauthenticated visitor should not learn the page exists.
The page shows, for a date range defaulting to the current run:
- The funnel as absolute counts and as conversion percentages between adjacent steps: unique
visitors, email signups, confirmed emails, checkout starts, paid pre-orders.
- The same funnel broken down by channel, sorted by visitor count, so a single referral spike is
visible as its own row rather than hidden in the total.
- Days elapsed in the run, number of distinct channels that produced at least twenty visitors, and
gross pre-order revenue in the currency Stripe reported.
- A single verdict banner produced by a pure, unit-tested function that takes the counts and returns
one of SHIP, KILL, or INCONCLUSIVE plus a one-sentence reason.
The verdict function's rules, which must live in code as named constants with a comment marking every
threshold as an assumption rather than a measured fact:
INCONCLUSIVE if fewer than 400 unique visitors, or fewer than 7 days elapsed, or fewer than two
channels reached twenty visitors. Reason names the specific shortfall.
KILL if the sample is sufficient and confirmed emails are under 2% of unique visitors.
SHIP if the sample is sufficient and confirmed emails are at least 8% of unique visitors and there
are at least five paid pre-orders.
INCONCLUSIVE otherwise, with the reason naming which of the two conditions fell short.
Write a seed script that wipes and inserts a fixed fixture dataset, plus unit tests over the verdict
function covering each branch and the boundary values exactly at the thresholds.
Produces: app/admin/page.tsx, lib/funnel.ts, lib/verdict.ts, scripts/seed.ts,
tests/verdict.test.ts, middleware.ts.
Verification gate:
npm test -- verdict
# expect: every branch and both boundary values pass
npm run seed -- --scenario=kill
# fixture: 900 visitors, 12 confirmed, 0 paid, 10 days, 3 channels
curl -s "https://YOURDOMAIN/admin?k=$ADMIN_KEY" | grep -o 'KILL' # expect: KILL
npm run seed -- --scenario=ship
# fixture: 900 visitors, 95 confirmed, 7 paid, 10 days, 3 channels
curl -s "https://YOURDOMAIN/admin?k=$ADMIN_KEY" | grep -o 'SHIP' # expect: SHIP
npm run seed -- --scenario=thin
# fixture: 120 visitors, 30 confirmed, 4 paid, 3 days, 1 channel
curl -s "https://YOURDOMAIN/admin?k=$ADMIN_KEY" | grep -o 'INCONCLUSIVE' # expect: INCONCLUSIVE
curl -s -o /dev/null -w '%{http_code}\n' https://YOURDOMAIN/admin # expect: 404
curl -s -o /dev/null -w '%{http_code}\n' "https://YOURDOMAIN/admin?k=wrong" # expect: 404
npm run seed -- --scenario=clean # wipe fixtures before the real run; verify counts are 0
M7 — Launch, the 10-day run, and the kill/ship protocol
Goal: Real strangers, real money, a real verdict, and refunds honored if the answer is no.
Agent prompt:
Prepare this project for its live run and make the run itself operable.
Switch every Stripe value from test to live: the Payment Link URL, the secret key, and a webhook
endpoint registered in live mode whose signing secret differs from the test one. Confirm the live
Payment Link's redirect still points at the thank-you page with the session id placeholder intact,
because that setting does not carry over between modes.
Add a command that prints the current funnel and verdict as a compact line of text so I can check the
run from a terminal without opening a browser.
Add a refund command that lists every paid pre-order with its Stripe payment identifier and amount,
and, given explicit confirmation, refunds them all in full and marks each checkout row refunded. This
is not an optional convenience: the page promises unconditional refunds, and a KILL verdict obligates
me to issue them. It must be idempotent and must never refund the same charge twice.
Add a public plain-text page at /promise stating the refund terms, and link it beneath the pre-order
button.
Produces: live Stripe wiring, scripts/status.ts, scripts/refund-all.ts, app/promise/page.tsx.
Verification gate:
# The only gate that matters: buy your own product with a real card, in live mode.
# Then, immediately:
npm run status
# expect one line, e.g.: visits=1 signups=0 confirmed=0 starts=1 paid=1 rev=39.00USD
# days=0 channels=0 verdict=INCONCLUSIVE(sample: 1/400 visitors)
# select status, amount_total from checkouts order by id desc limit 1; -- paid | 3900
npm run refund-all -- --dry-run # expect: lists exactly that one charge, refunds nothing
npm run refund-all -- --confirm # expect: 1 refunded
npm run refund-all -- --confirm # expect: 0 refunded (idempotent)
# select status from checkouts order by id desc limit 1; -- refunded
# and the refund appears in the Stripe dashboard within a minute
The run itself: 10 days minimum, no changes to the page mid-run. Changing the headline on day 4
does not give you two data points, it gives you zero. Check npm run status once a day and otherwise
leave it alone.
After the verdict:
- SHIP — email every confirmed address the same day with a real date and a way to reply. The pre-order buyers are now your first users and your spec. Keep their money.
- KILL — run
refund-all, then email the list one honest paragraph saying you are not building it and their money is back. Reuse the machine on the next idea; the only thing that changes is the copy on one page and the Payment Link. - INCONCLUSIVE — one re-run, changing exactly one variable: the promise, the price, or the channel. Never two. Two re-runs is the maximum before the honest read is that you cannot get traffic for this idea, which is itself a kill.
Assumptions in the rubric, stated plainly: the 400-visitor floor, the 2% and 8% confirmed-email bands, and the five-pre-order minimum are judgement calls chosen so the sample is large enough that a few friends cannot swing it and the gap between KILL and SHIP is wide enough to be meaningful. They are not derived from an industry benchmark, and any benchmark you are quoted for "average landing page conversion" is measuring a different page, a different price, and different traffic. Write your own thresholds down before the run starts, in the code, where you cannot renegotiate them at 2am on day nine when the number comes in at 6%.
6. Security & production checklist
- Webhook signature verification is mandatory and happens before parsing. Read the raw body as text; a JSON round-trip changes the bytes and the HMAC will not match. Route runs on the Node runtime. An unverified payload writes nothing, ever.
- Webhook idempotency on two keys. Unique constraints on both
stripe_event_idandstripe_session_id. Stripe retries on any non-2xx and occasionally delivers twice on a 2xx. Without this, one retry storm doubles the number your whole decision rests on. - Test and live webhook secrets are different values, as are the Payment Links. Store them as distinct environment variables and never share one slot.
- No secret reaches the browser. Only the Payment Link URL is public. The Stripe secret key, the
webhook secret,
RESEND_API_KEY,ADMIN_KEY, and both database URLs are server-only and never prefixed for client exposure. Instantiate the Stripe client only in modules no client component imports. - Confirm tokens are stored hashed, expire in 7 days, and are single-use. The raw token exists only in the email body.
- Confirmation is a POST, never a GET. Mail scanners follow links. This is a measurement-integrity control as much as a security one.
- The waitlist endpoint cannot be used as an address oracle. Identical response body, status, and timing for new, pending, and already-confirmed addresses.
- Signup abuse controls: honeypot field, sub-two-second submission rejection, five-per-visitor-key hourly limit enforced by a database count. A scripted signup flood does not just cost Resend quota, it destroys the metric.
- Resend quota is a data-integrity risk, not just an outage. At 100/day free, a good traffic day silently stops confirmations and craters your confirm rate. Alert on send failures; upgrade before a planned traffic spike.
- No raw IPs, anywhere, including logs. Salts deleted after two days. This is what lets you run without a cookie banner and without a data-deletion process.
/adminreturns 404 when unauthenticated, with a timing-safe secret comparison and an httpOnly,Secure,SameSite=Laxcookie. The readout is business-sensitive.- Refund obligation is code, not intention. The
refund-allscript exists before launch so that a KILL verdict cannot be quietly ignored because refunding 40 charges by hand is annoying. - Say what the charge is. The Payment Link's product name and your statement descriptor must both read as a pre-order. A card line item the buyer does not recognize becomes a chargeback, which costs more than the pre-order was worth.
7. Launch checklist
- Domain and DNS. Real domain, not a
.vercel.appsubdomain — the deployment platform's name in your URL measurably changes how a stranger reads the promise. Apex pluswwwredirecting to apex. - Email domain auth. SPF, DKIM and DMARC records verified in Resend before the first send. Note
that Resend's SPF and MX records go on the
sendsubdomain and DKIM atresend._domainkey— a very common misplacement. Send yourself one confirmation and check it lands in the inbox, not spam, in Gmail and Outlook. - Stripe live mode. Business details and payout bank account complete, or you can take money and not receive it. Live Payment Link created fresh, its "After payment" redirect re-configured (it does not copy from test mode), live webhook endpoint registered against your production URL.
- The payment wiring test, both modes. Test mode with card
4242 4242 4242 4242, then live mode with your own real card, then refund yourself. Confirm all three of: money in the Stripe dashboard, a row incheckouts, and the correctvisitor_key. Do not launch on a test-mode-only pass. - Vercel Pro. Required before the pre-order button goes live, per §3.
- Seed data wiped.
npm run seed -- --scenario=clean, then confirmnpm run statusreads all zeros. Fixture rows in a real run are how you ship an idea nobody wanted. - Start date recorded so the 10-day window and the readout's date range agree.
First 10 customers — concrete channels for this buyer. Tag every link with a distinct
utm_source; an untagged channel is an unmeasured channel and is worth roughly nothing to you.
- The 5–10 people who already complained about this problem to you. Direct message, individually, no broadcast. If you cannot name five, that is the first finding of the experiment.
- The subreddit where the problem lives, not where builders hang out.
r/SideProjectis other builders being supportive; it produces flattering signups and zero pre-orders. Post in the community that has the pain, follow its self-promotion rules, lead with the problem. - Hacker News
Show HN, once, only after the page holds up on mobile. Expect a spike, then nothing — read it as one channel row in the breakdown, never as the whole verdict. - One Discord or Slack community you were already a member of before you had something to sell. Joining to post a link converts nobody and gets you removed.
- A single narrow paid test, $50–100 max, on the platform where your buyer already searches — for developer tools that is usually search intent, not social feeds. Its only job is to buy you a second independent channel so the two-channel rule in the verdict can be satisfied. Assumption: $50–100 is enough to reach a few hundred targeted visitors in most niches; check your own cost-per-click before committing.
- Reply, personally, to every confirmed email. One sentence: "what made you sign up?" The answers are worth more than the count, and they tell you what to build first if the verdict is SHIP.
8. Extension paths
- Multi-idea mode: an
experimentstable keyed by hostname so one deployment runs several validations at once, each with its own promise, Payment Link, and independent verdict. - Interview scheduling: on confirm, offer a Cal.com link to the 20% who care most; five recorded conversations beat five hundred email addresses for deciding what to build.
- Price-ladder testing: three Payment Links at three amounts, assigned by visitor-key hash, once you have enough traffic that per-arm samples clear the 400 floor.
- Verdict digest: a scheduled job that emails you the funnel every morning at 8am, so checking the run does not require the discipline of remembering to.
- Turn the instrument into the product: the readout plus rubric is the thing most indie hackers actually lack. It is a plausible small SaaS — and the correct way to find out is to point this machine at it.
This is one of eight. The pack has seven more like it — subscription SaaS, metered API, paid Chrome extension, digital storefront, private knowledge base, price watcher, newsletter engine — same format, same gates. Get the pack for $39 →