Payments
This board takes money through Stripe, and you can add another rail by writing one file.
The rail is a line in outbid.config.ts:
payments: {
provider: 'stripe', // the only value this template ships
},
Why one rail
Stripe is the only provider this template has ever exercised against real money. Its normalisation, its idempotency guard and its refund path all run in the money-path integration tests.
Two more adapters were built and then withdrawn before release. Each was written to a provider's published documentation and unit-tested against recorded payloads, and neither had ever run against that company's servers. A security review then found a real defect in each, and both were of the same kind: one answered every purchase 200 while granting nothing, and the other's refunds never matched the payment they were reversing. Both were fixed, and a fix that no provider's server has ever answered is a hypothesis rather than a working rail.
The failure mode of an unproven money path is the quiet one. The money moves, the rank does not, the provider stops retrying because it received a 200, and nothing on the board looks wrong. You would hear about it from a bidder, or not at all. Shipping one rail that has been exercised beats shipping three where two have not.
What Stripe means for your tax
With Stripe you are the seller, so the tax is yours. Registering for, filing and remitting VAT or sales tax is the board operator's obligation wherever it applies. npm run doctor warns about this on every run, and /refunds says it in public so a bidder is never told the wrong thing about who they bought from.
Selling a digital service to a buyer in another country can create a VAT obligation there from the first sale — there is no threshold in the EU for digital services sold to consumers. The alternative is a merchant of record: a provider that is legally the seller and takes that on for you, for a higher fee. This template does not ship one, and the interface below is how you would connect one.
Not legal or tax advice. It is a description of what a payment provider does. If the amounts matter to you, ask an accountant in your own country.
The webhook URL
/api/webhooks/stripe
npm run doctor prints the one your board needs. Pointing the dashboard at the wrong path means the money is taken and the rank is never given — the single worst failure this system has, and the reason doctor warns about it on every run rather than only when something is missing.
/api/stripe/webhook still works. It forwards to /api/webhooks/stripe, so an older Stripe dashboard entry does not break.
The path follows the provider's id, so a rail you add answers at /api/webhooks/<its id> with no route to write.
Stripe
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
Subscribe the endpoint to exactly these five events:
| Event | Why it matters |
|---|---|
checkout.session.completed | the ordinary payment |
checkout.session.async_payment_succeeded | delayed methods — a DIFFERENT event id for the SAME session |
checkout.session.expired | releases an abandoned checkout from pending |
charge.refunded | removes the amount, which lowers the rank |
charge.dispute.created | a chargeback never arrives as a refund — without this the rank stays bought after the money is taken back |
No Product and no Price is created. Every bid is a different number, so the amount travels with the Checkout Session as an ad-hoc price. There is no catalogue to keep in step with outbid.config.ts.
Adding a provider
Implement PaymentProvider from src/payments/types.ts — four methods — and register it in four places:
REGISTRYinsrc/payments/index.ts.PROVIDER_IDSinsrc/payments/types.ts.- The
payments.providerenum insrc/config/schema.ts. CHECKOUT_HOSTSinsrc/lib/security-headers.ts, with your provider's hosted checkout hosts. Skip this one and the board blocks its own checkout with its ownform-actionheader, visible exactly once — at the moment somebody tries to pay.
Declare its keys in src/config/env.ts and give it a commented block in .env.example. npm run doctor reads requiredEnv off whichever adapter is selected, so your rail is checked with no work.
Nothing else in the application needs to know it exists. The money path talks to the interface and the webhook route resolves by name.
The one rule: an adapter never decides anything about money. It translates. The amount is priced from the stored listing total before any provider is called, and the pending → paid transition in src/lib/webhook.ts is what makes a delivery idempotent. That is why verifyWebhook returns a normalised event rather than the provider's own payload — the money path must not grow a branch per provider.
Two things your adapter must get right:
verifyWebhookmust THROW on a bad signature, never returnignored. Returningignoredturns a forged request into a200.- Compare signatures with a length check first.
timingSafeEqualthrows on a length mismatch instead of returningfalse, so an unguarded comparison turns a one-character forged signature into a500— and a500tells the provider to retry it forever.
src/payments/stripe.ts is the worked example. It is the shape every adapter takes: verify, normalise, and hand the money path an event it already understands.
Before your own adapter takes real money: the sandbox check
This is the step that was skipped for the two rails that never shipped, and it is the whole reason they never shipped. Ten minutes here, rather than finding out from a bidder whose rank never applied.
In the provider's sandbox or test mode:
- One checkout. Place a bid on your own board. You should land on the provider's hosted page with the right amount and currency.
- One webhook delivery. Pay it. Watch the provider's webhook log for a
200. Then reload the board — the rank must have moved.
- A
400is a signature problem. Check the secret, and check nothing is rewriting the request body in front of the app. - A
500is our side. The log line names the failure.
- One refund. Refund it in the provider's dashboard. The amount must come off the listing's total and the rank must drop.
npm run reconcile. It must report clean.
If a field name has changed since your adapter was written, step 2 or 3 is where you find out, and the fix is one line in src/payments/<provider>.ts.
Run the same four steps against Stripe test mode before your first live payment. docs/START-HERE.md walks it.
Switching provider on a live board
Payments already taken belong to the old provider and must stay refundable there, so payment.provider is stored per row and a refund always goes back through the rail it came from.
- Register the new provider's webhook before changing the config.
- Change
payments.providerand deploy. - Leave the old webhook registered for as long as a refund or dispute could still arrive — months, for a dispute.
/api/webhooks/<old>keeps working because the route resolves the provider from the URL, not from the config.