Skip to content

Plans and payments

Markdown

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.

  1. 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:config

    site.config.ts OK — <your site name> (https://your-domain.com). With payments off and a priced tier still enabled it stops at site.config.ts › tiers[1].priceCents: must be 0 when features.payments is false.

  2. 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_KEY

    It shows the key’s help text, prompts without echoing, refuses a whsec_ value or a publishable key, and pipes what passes to wrangler secret put.

  3. Register the webhook. Stripe → Developers → Webhooks → Add destination: the URL https://<your site>/api/stripe/webhook/, the events checkout.session.completed and checkout.session.async_payment_succeeded, still in test mode, with the endpoint’s API version set to the one the installed stripe package uses. The signing secret (whsec_…) is on the endpoint’s page after saving.

    Terminal
    pnpm cf:secrets STRIPE_WEBHOOK_SECRET
    curl -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.

  4. Deploy the config change, with pnpm deploy or a push; secrets need no deploy. /admin/ stops listing the two keys.

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
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 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

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”.

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.

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.

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.

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 created row
        • 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
      • jobs.ts expireFeatured, purgeUnpaid
      • queries.ts featureListing, unfeatureListing, markPaid
      • secrets.ts the messages /admin/ shows
    • Directorylib/
      • listing.ts isFeatured, the sort order, outboundRel
    • Directorypages/admin/
      • featured.astro /admin/featured/
    • Directorypages/submit/status/
      • [token].astro “Complete payment” and the upgrade buttons