Stripe, top-ups and payouts
Buying credits: a top-up intent
GET /api/payments/topups → PaymentIntent[] (yours)POST /api/payments/topups → { intent, checkoutUrl, clientSecret, instructions }GET /api/payments/topups/:id → PaymentIntentPOST /api/payments/topups/:id/cancel → PaymentIntentPOST /api/payments/topups takes a packId (or a custom credits amount) and a provider
(stripe or manual), inserts a PaymentIntent row in status: 'pending', then calls that
provider’s createCheckout(). Nothing is credited here. A created checkout is not money — the
three-method shape of PaymentProvider (createCheckout → verifyWebhook → settle) exists
because that is the only sequence that is safe: credits are granted in exactly one function
(grantForEvent), and only after a webhook has cryptographically proven the provider really sent
it.
For Stripe’s hosted flow (STRIPE_CHECKOUT_FLOW=hosted, the default), this returns a Checkout
Session URL with billing_address_collection: 'required', Stripe Tax enabled
(STRIPE_AUTOMATIC_TAX), and VAT-number collection — the UK-company requirements are not optional
extras, they’re on by default. For the payment_intent flow, it returns a clientSecret for an
on-page Payment Element instead, with the explicit caveat in the code that this flow is
tax-exclusive — Stripe Tax does not apply automatically to a bare PaymentIntent.
Verifying the webhook
Stripe’s webhook lands on an unauthenticated route with its own content-type parser:
POST /api/payments/webhooks/:providerThis scope has no session, no cookie, no CSRF assumption — Stripe is a server on the public
internet that has never heard of the session cookie, and its authentication is an HMAC signature
over the raw request body. The route installs its own addContentTypeParser that keeps the body
as a Buffer; Fastify’s normal JSON parser would hand back a re-serialised object with different
whitespace and key order, and the signature check would fail every time. This is, in the code’s own
words, “the single most common way a Stripe integration breaks.”
verifyStripeSignature() (apps/api/src/modules/payments/stripe.ts) implements the documented
v1 = HMAC-SHA256(timestamp + "." + raw body) scheme with node:crypto directly (not the Stripe
SDK’s constructEvent), specifically so the security-critical half is unit-testable against
Stripe’s published fixture with no client and no network. A bad signature returns 400, never
500 — Stripe retries a 5xx for days, and an unsigned or forged request is a permanent failure,
not a transient one.
Settling: the idempotency claim and the grant, together
// apps/api/src/modules/payments/routes.ts, inside handleVerifiedEvent()const decision = await provider.settle(intent, event); // pure — no writes, so it runs BEFORE the transaction
db.transaction((tx) => { const first = claimEvent(tx, { provider: provider.id, eventId: event.id, ... }); // PRIMARY KEY (provider, event_id) if (!first) return { duplicate: true }; // already handled — grant nothing if (decision.status === 'succeeded') grantForEvent(tx, intent, event.id, decision.creditsToGrant, ...); finishEvent(tx, provider.id, event.id, 'settled', intent.id);});claimEvent() and the credit grant commit together or not at all, inside one transaction. A
replayed webhook (Stripe retries for up to three days) finds the event already claimed and grants
nothing; a crash mid-flight leaves neither claimed nor granted. grantForEvent’s own idempotency
key is topup:<intentId>, not the event id — the thing that must happen at most once is “this
intent was paid for”, regardless of how many events describe it, so a checkout that fires both
checkout.session.completed and payment_intent.succeeded still credits exactly once.
Before granting anything, stripeProvider.settle() checks the verified event’s amount and currency
against what was quoted: a payment for the wrong amount, or in the wrong currency, becomes a
failed settlement a human looks at — never a partial or a generous credit. A Checkout Session
whose payment_status isn’t paid (a delayed method like BACS Direct Debit still fires
checkout.session.completed before the money has actually arrived) is treated as ignored, not
succeeded.
Manual top-ups: the owner grants by hand
The manual provider has no webhooks by design — verifyWebhook() always throws, because there is
nobody to send them. Instead, the owner settles a manual intent directly:
POST /api/payments/topups/:id/grant (owner only, manual intents only)POST /api/payments/grant (owner only — a direct grant, no intent behind it) { beingId, credits, memo, idemKey? }Force-settling a Stripe intent by hand is refused outright — “Only a manual top-up can be granted by hand. A Stripe top-up settles when Stripe confirms the payment” — because that is precisely the “just add an admin override” hole that mints credits for a card payment that never arrived.
Refunds: a ledger reversal, not an edit
POST /api/market/orders/:id/refund (owner only) { reason, revokeDelivery? }A refund does not touch the original purchase/sale entries — it posts two new ones that undo
them, so the history still says a purchase happened and was then reversed, with a reason attached:
postCredit(tx, { beingId: order.sellerId, delta: -order.credits, reason: 'refund_debit', allowNegative: true, ... });postCredit(tx, { beingId: order.buyerId, delta: order.credits, reason: 'refund_credit', idemKey: `refund:${orderId}`, ... });The seller’s debit is deliberately allowed to push their balance below zero: if they already spent
what they earned, the honest record is that they owe it back, not that the refund silently failed.
The buyer’s credit carries idemKey: refund:<orderId>, so a double-clicked refund is refused by the
ledger’s own unique index rather than by hoping an earlier status check got there first.
revokeDelivery: true additionally deletes the buyer’s copy of a generator/agent/theme (never
a service project or a skill grant — see
Listings and buying for why).
Payouts: the hold is posted at request time
POST /api/payments/payouts { credits, note } → PayoutRequestPOST /api/payments/payouts/:id/approve (owner only) { decision }POST /api/payments/payouts/:id/reject (owner only) { decision }POST /api/payments/payouts/:id/cancel (requester or owner)GET /api/payments/payouts?all= → PayoutRequest[]Asking for a payout removes the credits from the spendable balance immediately, as a
payout_hold ledger entry — not when the owner later approves it:
const hold = postCredit(tx, { beingId, delta: -input.credits, reason: 'payout_hold', refType: 'payout_request', refId: row.id });If the hold waited for approval, a seller could request a payout and spend the same credits in the
marketplace while the request sits in the queue — and the platform would owe the value twice.
Rejecting or cancelling a request posts a payout_release entry that puts the held credits back,
with idemKey: payout_release:<payoutId> so it can’t double-release.
Approving a payout is credits-only. POST /api/payments/payouts/:id/approve marks the request
paid and records who decided it and why — it does not call Stripe, does not touch a bank account,
and does not move a single unit of real currency. As the payments overview
says plainly: Stripe Connect is not enabled, so “approved” means the owner has decided, by hand,
that this seller is owed real money outside of Novaterra, and this record is the receipt for that
decision — not the mechanism that pays it.
The owner’s view
GET /api/payments/status (owner only)Reports whether Stripe is configured, which keys are set (never their values), the active checkout flow, the exact webhook URL to paste into the Stripe dashboard, and the last 50 payment intents, webhook events and payouts — the same data the admin console surfaces for a human to read at a glance rather than curl by hand.