Skip to content

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 → PaymentIntent
POST /api/payments/topups/:id/cancel → PaymentIntent

POST /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 (createCheckoutverifyWebhooksettle) 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/:provider

This 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 } → PayoutRequest
POST /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:

apps/api/src/modules/payments/routes.ts
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.