Cell - Stripe Component
Add @celljs/stripe and stripe (the official SDK, a peer dependency) to the dependencies to use Stripe: @Autowired(StripeClient) gives the Stripe client of the configuration, and a webhook endpoint verifies the Stripe-Signature of events and routes them to your handlers by type. The API itself is the official SDK's, with its types.
bun add @celljs/stripe stripe# cell.yml: configuration is server-only unless a schema marks it public; take secrets from the environment
stripe:
apiKey: ${env.STRIPE_SECRET_KEY}
webhook:
secret: ${env.STRIPE_WEBHOOK_SECRET} # whsec_...import { Autowired } from '@celljs/core';
import { Body, Controller, Post } from '@celljs/mvc/lib/node';
import { StripeClient, StripeWebhookHandler } from '@celljs/stripe';
import type Stripe from 'stripe';
@Controller('checkout')
export class CheckoutController {
@Autowired(StripeClient)
protected readonly stripe: StripeClient;
@Post()
async create(@Body() order: { id: string }) {
const session = await this.stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price: 'price_123', quantity: 1 }],
client_reference_id: order.id,
success_url: 'https://shop.example.com/paid',
});
return { url: session.url };
}
}
@StripeWebhookHandler('checkout.session.completed')
export class CheckoutCompletedHandler implements StripeWebhookHandler<Stripe.CheckoutSessionCompletedEvent> {
async handle(event: Stripe.CheckoutSessionCompletedEvent) {
// event.data.object is the session; mark the order paid once: Stripe can deliver an event more than once.
}
}Webhooks
The endpoint is POST /stripe/webhook (stripe.webhook.url, under server.path): register its URL in the Stripe Dashboard, or forward events to it during development with stripe listen --forward-to localhost:3000/stripe/webhook (set secret to the one it prints). It runs before the body parser and authentication, verifies the signature over the body as sent with each secret (a list serves several endpoints or a rotation) and a tolerance of 300 seconds, then runs the @StripeWebhookHandler components of the event type, then those declared without types, one after the other. It answers:
- 200
{"received":true}when they all succeed (or when no handler takes the type: logged as a warning); - 400 when the signature does not match;
- 500 when a handler throws (or the secret is not configured): Stripe retries the event for up to three days.
StripeWebhookVerifier verifies a request received on a route of your own: verify(rawBody, signatureHeader).
Configuration
stripe:
apiKey: ${env.STRIPE_SECRET_KEY}
config: # options of the Stripe client
maxNetworkRetries: 2
# apiVersion: 2025-... # pin one; the SDK's own by default
webhook:
enabled: true
url: /stripe/webhook
secret: ${env.STRIPE_WEBHOOK_SECRET} # or a list
tolerance: 300 # secondsThe client is created on first use; rebind StripeFactory (@Component({ id: StripeFactory, rebind: true })) to create it another way, for example with a key per tenant. The SDK picks its fetch and SubtleCrypto build on Bun, Deno and Cloudflare Workers. Current versions of stripe need Node.js 20 or later (the rest of Cell runs on 18).
Testing without a Stripe account
stripe-mock answers every API of Stripe's OpenAPI spec and validates the parameters (it keeps no state). Point the client at it with config: { host: '127.0.0.1', port: 12111, protocol: 'http' } and any sk_test_<letters and digits> key; this package's own tests against it run with:
docker run --rm -p 12111:12111 stripe/stripe-mock
STRIPE_MOCK_URL=http://127.0.0.1:12111 bunx turbo run test --filter=@celljs/stripeErrors
Stripe's API errors are the SDK's (Stripe.errors.StripeError). Configuration and webhook failures are CellStripeErrors with a stable code and a hint:
| Code | When |
|---|---|
CELL_STRIPE_NOT_CONFIGURED | stripe.apiKey is not set when the client is injected |
CELL_STRIPE_WEBHOOK_NOT_CONFIGURED | An event arrived and stripe.webhook.secret is not set |
CELL_STRIPE_SIGNATURE_INVALID | No secret verifies the Stripe-Signature, or its timestamp is outside tolerance |