Skip to main content
Stripe is the production payment processor: it wraps the Stripe Node SDK behind the PaymentProcessorType contract and handles subscriptions, invoices, checkout sessions, payment methods, and usage-based billing.

What the processor covers

The PaymentProcessorType contract is satisfied by both the Stripe adapter and the mock. Every operation is available through tools.paymentProcessor:
CategoryOperations
OrganisationscreateOrganization, updateOrganization, findOrganizationBy
ProductscreateProduct, updateProduct, retireProduct, restaureProduct
SubscriptionscreateSubscription, updateSubscription, cancelSubscription, recordUsage
InvoicespreviewInvoice, retrieveInvoice
PaymentscreateIntent, confirmPayment, cancelPayment, capturePayment
CheckoutsetCheckoutSession, getCheckoutSession
Payment methodsfindPaymentMethodBy, detachPaymentMethod
DiscountsvalidateDiscountCode
DowngradescreateDowngradeRequest, cancelDowngradeRequest, syncPendingProductDowngradeScheduledQuantity

Why

The Stripe adapter gives you access to the full Stripe billing API through a single, testable interface. Every operation is a small pure function that receives the Stripe instance, parameters, and the tools object — making individual operations easy to unit-test in isolation. The quantityDowngradePolicy setting lets you control whether subscription seat reductions are prorated immediately or deferred to the end of the billing cycle.

Setup

  1. In apps/backend/src/configuration/development.ts, set tools.paymentProcessor:
    paymentProcessor: {
      client: PaymentProcessorClientType.STRIPE,
      apiVersion: process.env.STRIPE_API_VERSION ?? '2026-01-28.clover',
      publishableKey: process.env.STRIPE_PUBLISHABLE_KEY,
      secretKey: process.env.STRIPE_SECRET_KEY,
      webhookSigningSecret: process.env.STRIPE_WEBHOOK_SECRET,
      settings: {
        quantityDowngradePolicy: QuantityDowngradePolicy.IMMEDIATELY_PRORATED,
      },
    }
    
  2. In production.ts, use QuantityDowngradePolicy.END_OF_BILLING_CYCLE unless you have a specific reason for immediate proration.
  3. In your environment, set:
    STRIPE_API_VERSION=2026-01-28.clover
    STRIPE_PUBLISHABLE_KEY=pk_live_...
    STRIPE_SECRET_KEY=sk_live_...
    STRIPE_WEBHOOK_SECRET=whsec_...
    
  4. For local webhook testing, use the Stripe CLI to forward events:
    stripe listen --forward-to http://localhost:4000/stripe/webhook
    
  5. Run make test module=backend.

Quantity downgrade policy

The quantityDowngradePolicy setting controls how Stripe handles subscription seat reductions:
PolicyBehaviour
IMMEDIATELY_PRORATEDStripe issues a prorated credit immediately when seats are reduced. Use in dev/staging.
END_OF_BILLING_CYCLEThe reduction takes effect at the end of the current billing period. Use in production.
Set this in apps/backend/src/configuration/production.ts:
settings: {
  quantityDowngradePolicy: QuantityDowngradePolicy.END_OF_BILLING_CYCLE,
}

Testing with stripeMock

In apps/backend/src/configuration/test.ts, the payment processor is configured as stripeMock — a no-op stub that requires no Stripe account, API key, or network traffic:
paymentProcessor: {
  client: PaymentProcessorClientType.STRIPE_MOCK,
}
Every operation on the mock returns { success: false } by default. Two exceptions — retrieveInvoice and cancelSubscription — reject with an error instead. When a test needs a specific response, use jest.spyOn:
jest
  .spyOn(tools.paymentProcessor, "createSubscription")
  .mockResolvedValue({ success: true, result: mockSubscription });
The mock satisfies the full PaymentProcessorType contract. When you add a new operation, TypeScript will fail to compile until you add a matching stub to createTestStripePaymentProcessor in apps/backend/src/tools/paymentProcessor/stripe/index.ts — this is intentional.

Adding a new Stripe operation

  1. Create apps/backend/src/tools/paymentProcessor/stripe/<operationName>.ts. Export a function that takes (stripe: Stripe, params, tools: ToolsType) and returns { success: true, result } or { success: false, reason }.
  2. Add the function to the StripePaymentProcessorType interface in apps/backend/src/tools/paymentProcessor/stripe/types.ts.
  3. Register it in buildStripePaymentProcessor inside apps/backend/src/tools/paymentProcessor/stripe/index.ts.
  4. Add a no-op stub to createTestStripePaymentProcessor in the same file.
  5. Write a test in apps/backend/src/tools/paymentProcessor/__tests__/stripe/<operationName>.spec.ts.
  6. Run make test module=backend.

Webhooks

When Stripe sends a webhook event, it hits the POST /stripe/webhook endpoint in apps/backend/src/routers/api/stripe.ts. Here’s the full flow: Step by step:
  1. Stripe POSTs to /stripe/webhook with a Stripe-Signature header.
  2. The router calls Stripe.webhooks.constructEvent to verify the signature using webhookSigningSecret. If verification fails, it returns 400 immediately.
  3. The router checks whether the event type is in HandledStripeWebhookEvent (defined in apps/backend/src/utilities/stripe.ts). Unhandled event types get a 200 response and are silently ignored — this is intentional, since Stripe sends many event types you may not care about.
  4. The router checks hasWebhookEventBeenProcessed to deduplicate — Stripe can deliver the same event more than once.
  5. The router validates the payload shape using Zod schemas in webhookValidators.ts. This is a safety net: if Stripe changes their payload format, this fails loudly rather than letting malformed data reach your business logic.
  6. The event is serialised and enqueued to QueueName.STRIPE_WEBHOOK.
  7. The stripeWebhookConsumer picks it up from the queue and dispatches to the right handler based on event.type.
Currently handled events:
EventHandler file
customer.subscription.deletedcustomerSubscriptionDeleted.ts
customer.subscription.updatedcustomerSubscriptionUpdated.ts
customer.updatedcustomerUpdated.ts
payment_intent.amount_capturable_updatedpaymentIntentAmountCapturableUpdated.ts
payment_intent.succeededpaymentIntentSucceeded.ts
payment_method.attachedpaymentMethodAttached.ts
payment_method.detachedpaymentMethodDetached.ts
payment_method.updatedpaymentMethodUpdated.ts

Adding a new event handler

  1. Add the new event type to HandledStripeWebhookEvent in apps/backend/src/utilities/stripe.ts:
    export enum HandledStripeWebhookEvent {
      // ...existing events...
      InvoicePaymentSucceeded = 'invoice.payment_succeeded',
    }
    
  2. Add a Zod validator for the payload shape in apps/backend/src/tools/queue/consumers/stripeWebhookConsumer/webhookValidators.ts.
  3. Create the handler file apps/backend/src/tools/queue/consumers/stripeWebhookConsumer/invoicePaymentSucceeded.ts:
    import { ToolsType } from '../../../../tools';
    
    export const handleInvoicePaymentSucceeded = async (
      payload: YourValidatedPayloadType,
      tools: ToolsType,
    ) => {
      // business logic here
    };
    
  4. Register the handler in apps/backend/src/tools/queue/consumers/stripeWebhookConsumer/index.ts.
  5. Write a test in apps/backend/src/tools/queue/consumers/stripeWebhookConsumer/__tests__/.
  6. For local testing, forward events using the Stripe CLI:
    stripe listen --forward-to http://localhost:4000/stripe/webhook
    # then trigger a test event:
    stripe trigger invoice.payment_succeeded
    
  7. Verify:
    make test module=backend
    

Gotchas

  • The Stripe client is configured with maxNetworkRetries: 3 and timeout: 80000ms. Operations that exceed this timeout will throw — wrap long-running calls with expRetries using isConnectionTimeoutError as the shouldRetry guard.
  • Webhook signatures expire quickly. Never replay a webhook event after the tolerance window — Stripe will reject the signature.
  • Never log secretKey or webhookSigningSecret — they are credentials.
  • apiVersion must exactly match a supported Stripe API version string. A mismatch causes a startup error.

What’s next?