# Plans and payments

> Priced tiers sold through Stripe Checkout — the two secrets, the webhook and its trailing slash, the paid submission path, featured placements and their expiry.

You are reading one page of the ZeroDirs documentation. ZeroDirs is a paid Astro + Cloudflare Workers starter for directory sites: every page is rendered to static HTML at build time, the site ships no client JavaScript outside `/search/`, and one file — `site.config.ts` — carries roughly 80% of the customisation.

Two things to hold on to before you act on anything below:

- `site.config.ts` is validated by a zod schema with ten cross-field rules. After any edit to it, run `pnpm check:config`; every problem is reported as `site.config.ts › <path>: <message>` and the whole file is checked at once.
- The repository ships its own `AGENTS.md` with twenty hard rules, and a machine-checked test suite behind them. If you are working inside a ZeroDirs project, read that file first — it overrides anything general you infer from this page.

Source: https://zerodirs.com/docs/submissions/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.

> **Free edition**
>
> The free edition has no Worker, so it has no checkout, no webhook and no paid plans.

## Setup

1. Check the config. An enabled priced tier needs both switches on, or the file does not parse.

   ```ts title="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' },
   ```

   ```sh title="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_`.

   ```sh title="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.

   ```sh title="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.

> **Keep the trailing slash**
>
> Without the slash the URL answers with a redirect, and Stripe does not follow redirects on a
> delivery: it records a failure, retries for up to three days, and no payment is fulfilled.

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

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

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

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

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

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

| 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

Each entry in [`tiers`](https://zerodirs.com/docs/configure/reference/#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`](https://zerodirs.com/docs/configure/reference/#features) (`false`),
[`providers.payment`](https://zerodirs.com/docs/configure/reference/#providers) (`'none'`),
[`limits.unpaidTtlDays`](https://zerodirs.com/docs/configure/reference/#limits) (`7`), and
[`listing.outboundRel`](https://zerodirs.com/docs/configure/reference/#listing) (`'nofollow'`), which priced tiers override.

## Files

- site.config.ts `tiers`, `features.payments`, `providers.payment`
- .dev.vars.example the two Stripe keys and their instructions
- src/
  - config/
    - schema.ts the rule tying priced tiers to payments
  - pages/api/
    - checkout/
      - [id].ts the redirect to Stripe
    - stripe/
      - webhook.ts the endpoint; 405 for anything but POST
  - server/
    - payments/
      - 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
  - lib/
    - listing.ts `isFeatured`, the sort order, `outboundRel`
  - pages/admin/
    - featured.astro `/admin/featured/`
  - pages/submit/status/
    - [token].astro "Complete payment" and the upgrade buttons

## Related

- [Email](https://zerodirs.com/docs/submissions/email/) — The receipt a paid submission gets once the money lands, and where it waits until a provider can send it.
- [The review queue](https://zerodirs.com/docs/submissions/queue/) — Paid submissions are reviewed first; unpaid ones are visible but not approvable.
- [Environment and secrets](https://zerodirs.com/docs/deploy/environment/#runtime-secrets) — What the two Stripe values look like, where they come from, and how cf:secrets checks them.
- [Troubleshooting](https://zerodirs.com/docs/run/troubleshooting/) — Stripe reports failed deliveries, and the other deploy-day surprises.
