What
- Add a landing page at
/ (explains the product; CTA to create).
- Move event creation from
/ to /create — today the root route (src/routes/+page.svelte + +page.server.ts with the create form action) is the create page; it moves wholesale to src/routes/create/.
- Gate creation behind a ~10 DKK Stripe Checkout payment.
Payment flow
The key thing to get right: treat the Stripe webhook as the source of truth, not the redirect back to the app. A user can close the tab or spoof the success URL, so the event is created unpaid first and only activated when the webhook confirms payment.
1. Stripe setup for Workers
compatibility_flags = ["nodejs_compat"] is already set in wrangler.toml, so only the secrets are needed:
wrangler secret put STRIPE_SECRET_KEY
wrangler secret put STRIPE_WEBHOOK_SECRET
The standard Stripe SDK works in Workers, but use the fetch-based HTTP client and async webhook verification (Node's sync crypto isn't available):
import Stripe from 'stripe';
function getStripe(env) {
return new Stripe(env.STRIPE_SECRET_KEY, {
httpClient: Stripe.createFetchHttpClient(),
// apiVersion: pin to the current version shown in the dashboard
});
}
In SvelteKit routes, env is platform.env (same place the DB binding comes from).
2. D1 migration — the existing events.status column already means open | closed (poll lifecycle), so payment state gets its own column. A nullable paid_at is enough: NULL = awaiting payment. Timestamps in this schema are UTC ISO text, not epoch integers.
-- migrations/0005_add_paid_at.sql
ALTER TABLE events ADD COLUMN paid_at TEXT;
(Decide what backfill existing events get — presumably paid_at = created_at so nothing already shared breaks.)
3. Create action — the existing create form action in (moved) src/routes/create/+page.server.ts keeps all its validation, inserts the event as unpaid via the data provider, then instead of redirecting to /e/{organizer_token} creates a Checkout session and 303-redirects to session.url:
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{
price_data: {
currency: 'dkk',
product_data: { name: 'Create a poll' },
unit_amount: 1000, // 10.00 DKK — amount is in øre
},
quantity: 1,
}],
success_url: `${url.origin}/e/${organizerToken}?paid=1`,
cancel_url: `${url.origin}/create?canceled=1`,
metadata: { event_id: eventId },
});
redirect(303, session.url);
Derive the origin from the request URL — no APP_URL var needed. Note unit_amount: 1000: Stripe uses the smallest unit, and DKK has 2 decimals, so 10 DKK = 1000 øre.
⚠️ One capability-URL caveat: success_url above contains the organizer token, which Stripe stores and shows in its dashboard/logs — specs/PROJECT.md says to keep tokens out of logs. If that matters, use success_url: ${url.origin}/paid?session_id={CHECKOUT_SESSION_ID} and resolve the token server-side from the session's metadata.event_id.
4. Webhook handler — src/routes/webhooks/stripe/+server.ts (POST). It needs the raw body for signature verification, and must use constructEventAsync + createSubtleCryptoProvider() (not the sync constructEvent):
export async function POST({ request, platform }) {
const stripe = getStripe(platform.env);
const body = await request.text();
const sig = request.headers.get('stripe-signature');
let event;
try {
event = await stripe.webhooks.constructEventAsync(
body, sig, platform.env.STRIPE_WEBHOOK_SECRET,
undefined, Stripe.createSubtleCryptoProvider(),
);
} catch (err) {
return new Response(`Webhook error: ${err.message}`, { status: 400 });
}
if (event.type === 'checkout.session.completed') {
const eventId = event.data.object.metadata.event_id;
// WHERE paid_at IS NULL makes this idempotent — Stripe may deliver twice
await platform.env.DB.prepare(
`UPDATE events SET paid_at = ? WHERE id = ? AND paid_at IS NULL`
).bind(new Date().toISOString(), eventId).run();
// Purge the edge cache (src/lib/server/cache.ts) for this event's token
// pages, same as every other write path.
}
return new Response('ok', { status: 200 });
}
Then in the Stripe dashboard: add https://poll.malpou.io/webhooks/stripe, subscribe to checkout.session.completed, copy the signing secret into STRIPE_WEBHOOK_SECRET.
5. Unpaid events — decide and spec what /e/…, /r/…, /s/… do while paid_at IS NULL. Simplest: /e/… shows an "awaiting payment" state (with a retry-payment link), respondent/share pages 404. Also decide cleanup for abandoned unpaid events (a cron/scheduled delete, or just leave them).
Repo conventions this touches
- New behavior areas need
specs/<area>/spec.md + colocated Playwright .spec.ts (landing page; payment gating). Moving create to /create updates existing specs/tests that assume /.
- Landing page copy goes through Paraglide —
messages/{da,en,fr}.json, no hardcoded strings; look and feel per specs/DESIGN.md.
- E2E runs against the real Worker with local D1 — Stripe must be stubbed there (e.g. an env flag that skips Checkout and marks the event paid, or seed paid events directly via
specs/support/db.ts).
Pricing caveat
At 10 DKK, Stripe's fee (roughly 1.5% + 1.80 DKK on EEA cards, more on non-European cards) eats close to 20% of each charge. True per-poll microtransactions bleed a lot to fixed fees. A common fix is selling credits in a bundle (e.g. 10 polls for one payment) so the fixed fee is amortized — same code, just decrement a credit balance instead of charging per event.
What
/(explains the product; CTA to create)./to/create— today the root route (src/routes/+page.svelte++page.server.tswith thecreateform action) is the create page; it moves wholesale tosrc/routes/create/.Payment flow
The key thing to get right: treat the Stripe webhook as the source of truth, not the redirect back to the app. A user can close the tab or spoof the success URL, so the event is created unpaid first and only activated when the webhook confirms payment.
1. Stripe setup for Workers
compatibility_flags = ["nodejs_compat"]is already set inwrangler.toml, so only the secrets are needed:The standard Stripe SDK works in Workers, but use the fetch-based HTTP client and async webhook verification (Node's sync crypto isn't available):
In SvelteKit routes,
envisplatform.env(same place theDBbinding comes from).2. D1 migration — the existing
events.statuscolumn already meansopen | closed(poll lifecycle), so payment state gets its own column. A nullablepaid_atis enough:NULL= awaiting payment. Timestamps in this schema are UTC ISO text, not epoch integers.(Decide what backfill existing events get — presumably
paid_at = created_atso nothing already shared breaks.)3. Create action — the existing
createform action in (moved)src/routes/create/+page.server.tskeeps all its validation, inserts the event as unpaid via the data provider, then instead of redirecting to/e/{organizer_token}creates a Checkout session and 303-redirects tosession.url:Derive the origin from the request URL — no
APP_URLvar needed. Noteunit_amount: 1000: Stripe uses the smallest unit, and DKK has 2 decimals, so 10 DKK = 1000 øre.success_urlabove contains the organizer token, which Stripe stores and shows in its dashboard/logs —specs/PROJECT.mdsays to keep tokens out of logs. If that matters, usesuccess_url: ${url.origin}/paid?session_id={CHECKOUT_SESSION_ID}and resolve the token server-side from the session'smetadata.event_id.4. Webhook handler —
src/routes/webhooks/stripe/+server.ts(POST). It needs the raw body for signature verification, and must useconstructEventAsync+createSubtleCryptoProvider()(not the syncconstructEvent):Then in the Stripe dashboard: add
https://poll.malpou.io/webhooks/stripe, subscribe tocheckout.session.completed, copy the signing secret intoSTRIPE_WEBHOOK_SECRET.5. Unpaid events — decide and spec what
/e/…,/r/…,/s/…do whilepaid_at IS NULL. Simplest:/e/…shows an "awaiting payment" state (with a retry-payment link), respondent/share pages 404. Also decide cleanup for abandoned unpaid events (a cron/scheduled delete, or just leave them).Repo conventions this touches
specs/<area>/spec.md+ colocated Playwright.spec.ts(landing page; payment gating). Moving create to/createupdates existing specs/tests that assume/.messages/{da,en,fr}.json, no hardcoded strings; look and feel perspecs/DESIGN.md.specs/support/db.ts).Pricing caveat
At 10 DKK, Stripe's fee (roughly 1.5% + 1.80 DKK on EEA cards, more on non-European cards) eats close to 20% of each charge. True per-poll microtransactions bleed a lot to fixed fees. A common fix is selling credits in a bundle (e.g. 10 polls for one payment) so the fixed fee is amortized — same code, just decrement a credit balance instead of charging per event.