Plans and payments
tiers in site.config.ts defines the plans a submitter can pick, and a tier with a price is
sold through Stripe Checkout: one hosted payment page per attempt, one webhook to say the money
arrived. features.payments: true with providers.payment: 'stripe' switches it on; the
shipped config has both.
-
Check the config. An enabled priced tier needs both switches on, or the file does not parse.
site.config.ts tiers: [{ id: 'free', name: 'Free', description: 'Reviewed in order.', kind: 'queue', priceCents: 0, currency: 'usd' },{ id: 'express', name: 'Express', description: 'Reviewed first.', kind: 'express', priceCents: 4900, currency: 'usd', slaHours: 48 },],features: { payments: true },providers: { payment: 'stripe' },Terminal pnpm check:configsite.config.ts OK — <your site name> (https://your-domain.com). With payments off and a priced tier still enabled it stops atsite.config.ts › tiers[1].priceCents: must be 0 when features.payments is false. -
Create the API key. Stripe → Developers → API keys → Create restricted key, with only Checkout Sessions: Write. Stay in test mode: the key begins
rk_test_.Terminal pnpm cf:secrets STRIPE_SECRET_KEYIt shows the key’s help text, prompts without echoing, refuses a
whsec_value or a publishable key, and pipes what passes towrangler secret put. -
Register the webhook. Stripe → Developers → Webhooks → Add destination: the URL
https://<your site>/api/stripe/webhook/, the eventscheckout.session.completedandcheckout.session.async_payment_succeeded, still in test mode, with the endpoint’s API version set to the one the installedstripepackage uses. The signing secret (whsec_…) is on the endpoint’s page after saving.Terminal pnpm cf:secrets STRIPE_WEBHOOK_SECRETcurl -s -o /dev/null -w '%{http_code}\n' https://<your site>/api/stripe/webhook/405: the route exists and takes only POST. Without the slash the same command prints a redirect code. -
Deploy the config change, with
pnpm deployor a push; secrets need no deploy./admin/stops listing the two keys.
Verify
Section titled “Verify”One test-mode checkout on the deployed site:
| Check | Where | You should see |
|---|---|---|
| The plans are offered | /submit/, step 2 |
the priced tiers next to the free one |
| Checkout starts | submit on a priced tier | a redirect to Stripe; the row is pending, payment unpaid |
| The payment lands | pay with card 4242 4242 4242 4242, any future expiry, any CVC and postcode |
/submit/status/<token>/?paid=1 with “Payment received.”; on reload the “Not completed yet” line and the “Complete payment” button are gone |
| The webhook wrote it | pnpm exec wrangler d1 execute DB --remote --command "SELECT id, status, event_id FROM payments ORDER BY created_at DESC LIMIT 1" |
status paid, an event_id beginning evt_ |
| It is in the queue | /admin/ |
the listing under “Waiting for review”, not under “Awaiting payment” |
| Stripe agrees | the endpoint’s recent deliveries in Stripe | 200. A redirect code: the slash is missing. 400: the wrong secret, or the two values swapped. No deliveries: the events were not selected |
How it works
Section titled “How it works”The paid path
Section titled “The paid path”| Free tier | Priced tier | |
|---|---|---|
| Row written | pending, payment n/a |
pending, payment unpaid |
| Submitter email | queued immediately | not until the payment lands |
| Redirect | /submit/status/<token>/?new=1 |
/api/checkout/<id>/?t=<token> → Stripe |
| In the review queue | yes | only once paid |
No receipt goes out for something nobody has paid for; you are notified either way.
/api/checkout/<id>/ has no session and no login: the signed 24-hour token in ?t= is the
whole credential, and it names the listing. A bad token, an unknown listing, a free tier and
payments off all answer the same 404; a listing already paid is sent back to its status page.
Each visit creates one Checkout Session from the tier’s own price and name — no product
catalogue to keep in sync — with the listing id in client_reference_id and metadata. A
payments row with status created is written first, so /admin/ can flag a checkout started
over an hour ago and never completed; “Mark as paid by hand” on the listing’s admin page
records money that arrived without a webhook, under a manual: id. The status page keeps an
unpaid listing payable through “Complete payment”.
The webhook
Section titled “The webhook”The two events fulfil; everything else is acknowledged and ignored, a completed session not yet
paid included. Fulfilment is one statement: the payments row is inserted with the Stripe
session id as its primary key, and the conflict clause only advances a row not already paid.
A redelivery collides with the first row and changes nothing — no read-then-write window — and
the receipt is queued only by the delivery that moved the listing, so it goes out once.
| Situation | Response | Why |
|---|---|---|
| Payments off | 404 | the endpoint is not part of this site |
| Provider unconfigured | 503 | so Stripe redelivers once the keys are set |
| Bad signature, or a stale timestamp | 400, no detail | nothing about why |
| Recording failed after a valid signature | 200 | a non-2xx makes Stripe retry for three days over a problem retrying will not fix; it is logged as PAYMENT_RECORDING_FAILED and audited under the session id |
| Not a POST | 405 | a browser opening the URL |
Upgrading a pending free listing
Section titled “Upgrading a pending free listing”A pending free submission’s status page and receipt offer the priced tiers as a way to skip
the queue; each button is the checkout URL with ?tier=<id>. Nothing is written at checkout
time — the tier travels in the session’s metadata and is applied when the payment lands — so
an abandoned checkout changes nothing. The receipt then reads “Upgrade received”.
Featured placements
Section titled “Featured placements”A tier with featuredDays writes featured_until when its payment lands; a renewal overwrites
the window rather than extending it. Featured is decided at build time — featured_until in
the future on a kind: 'featured' tier — so a placement lapses at the next build whether or
not the hourly job ran. Featured listings sort first under listing.defaultSort: 'featured',
and any priced or featured tier puts rel="sponsored noopener" on the outbound link, whatever
listing.outboundRel says.
/admin/featured/ has two groups: “Currently featured”, with Extend (days, defaulting to
featuredDays) and Remove; and “Expired, not yet demoted” — already unfeatured on the site,
still on the tier in the database — with “Demote now”. Any listing can be pinned from its own
admin page; without a kind: 'featured' tier the page says so.
The hourly job demotes lapsed placements in one UPDATE — featured_until cleared, tier set to
the free tier, approved rows only — then requests one rebuild, and only when a row changed.
Unpaid rows
Section titled “Unpaid rows”The same job deletes unpaid listings older than limits.unpaidTtlDays, up to 100 per tick:
payments and clicks rows first, then the listing, then its R2 objects, so a storage
failure leaves an orphaned object rather than a row pointing at nothing.
When it is off or degraded
Section titled “When it is off or degraded”| Configuration | A visitor sees | /admin/ reports |
Fix |
|---|---|---|---|
features.payments: false, or providers.payment: 'none' |
only free tiers on the form; checkout and the webhook answer 404 | nothing | leave it, or turn both on |
| Payments on, a Stripe secret missing | the priced tiers, then a 503 at checkout: “Payment is temporarily unavailable. Nothing has been charged — please try this link again in a few minutes.” The webhook answers 503 too | Missing secret STRIPE_SECRET_KEY — run: wrangler secret put STRIPE_SECRET_KEY (local: add to .dev.vars). Paid tiers cannot complete a checkout. — one line per key |
pnpm cf:secrets STRIPE_SECRET_KEY STRIPE_WEBHOOK_SECRET |
TOKEN_SECRET missing |
a 503 at checkout | Missing secret TOKEN_SECRET … |
pnpm cf:secrets TOKEN_SECRET |
| Endpoint without the slash, or the wrong signing secret | “Payment received.” on the status page, but the listing stays unpaid and out of the queue |
“Awaiting payment”, and after an hour a checkout “created over an hour ago and never completed” | fix the endpoint or the secret; Stripe redelivers. For money already taken, “Mark as paid by hand” |
One Stripe secret without the other degrades the provider to none: money you cannot verify
having received is worse than none.
Configuration
Section titled “Configuration”Each entry in tiers:
| Field | Default | What it changes |
|---|---|---|
id, name, description |
required | the id is stored on the listing; name and description appear on the plan card, the payment page and in mail |
kind |
required | queue, express or featured; sets queue priority in /admin/ and which tiers can be pinned |
priceCents |
required | 0 is free; above 0 the tier goes through checkout and its listings get rel="sponsored" |
currency |
required | lowercase ISO 4217, passed to Stripe as is |
featuredDays |
— | required for kind: 'featured', not allowed otherwise; the window written at payment |
slaHours |
— | the review promise: on the plan card, in the receipt, and in the daily digest when a paid submission is within a day of it |
features |
[] |
the bullet list on the plan card |
enabled |
true |
false hides the tier from the form but keeps its price, so listings already on it stay sponsored |
Elsewhere: features.payments (false),
providers.payment ('none'),
limits.unpaidTtlDays (7), and
listing.outboundRel ('nofollow'), which priced tiers override.
- site.config.ts
tiers,features.payments,providers.payment - .dev.vars.example the two Stripe keys and their instructions
Directorysrc/
Directoryconfig/
- schema.ts the rule tying priced tiers to payments
Directorypages/api/
Directorycheckout/
- [id].ts the redirect to Stripe
Directorystripe/
- webhook.ts the endpoint; 405 for anything but POST
Directoryserver/
Directorypayments/
- checkout.ts the checks before the redirect; the
createdrow - stripe.ts the Checkout Session and the signature check
- webhook.ts the status-code table
- fulfill.ts the idempotent write, upgrades, the receipt
- index.ts the provider selector; both secrets or
none
- checkout.ts the checks before the redirect; the
- jobs.ts
expireFeatured,purgeUnpaid - queries.ts
featureListing,unfeatureListing,markPaid - secrets.ts the messages
/admin/shows
Directorylib/
- listing.ts
isFeatured, the sort order,outboundRel
- listing.ts
Directorypages/admin/
- featured.astro
/admin/featured/
- featured.astro
Directorypages/submit/status/
- [token].astro “Complete payment” and the upgrade buttons