Skip to content

Stripe

Accept payments and manage subscriptions with Stripe. Create a Stripe client in your server entry, share it via typed context, and handle checkout sessions and webhooks in standard Juice routes.

Install

bun add stripe

Setup

Create the Stripe client in your server entry and share it via a typed context key. Every route and server action can then access the same Stripe instance.

// server.ts
import Stripe from 'stripe';
import { createContextKey, setContext } from '@cmj/juice/runtime';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export const stripeKey = createContextKey<Stripe>('stripe');

// In your router config:
onBeforeRequest: async (req) => {
  setContext(req, stripeKey, stripe);
}

Checkout Session

Create a server action that builds a Stripe Checkout session and redirects the user to Stripe's hosted checkout page. No client-side JavaScript required.

// app/routes/pricing.tsx
import React from 'react';
import { getContext, redirect } from '@cmj/juice/runtime';
import { stripeKey } from '../../server.js';

async function checkout(formData: FormData) {
  'use server';
  const stripe = getContext(this.request, stripeKey)!;
  const priceId = formData.get('priceId') as string;

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: 'https://myapp.com/dashboard?session_id={CHECKOUT_SESSION_ID}',
    cancel_url: 'https://myapp.com/pricing',
  });

  redirect(session.url!, 303);
}

export default function Pricing() {
  return (
    <form action={checkout} method="POST">
      <input type="hidden" name="priceId" value="price_xxx" />
      <button type="submit">Subscribe</button>
    </form>
  );
}

Webhooks

Stripe sends events (payment succeeded, subscription cancelled, etc.) to a webhook endpoint. Create a route-as-API handler that verifies the signature and processes events.

// app/routes/api/webhooks/stripe.ts
import { getContext } from '@cmj/juice/runtime';
import { stripeKey } from '../../../../server.js';

export async function POST(req: Request) {
  const stripe = getContext(req, stripeKey)!;
  const body = await req.text();
  const sig = req.headers.get('stripe-signature')!;

  let event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch (err) {
    return new Response('Invalid signature', { status: 400 });
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object;
      // Provision access for the customer
      break;
    }
    case 'payment_intent.succeeded': {
      const intent = event.data.object;
      // Record the successful payment
      break;
    }
    case 'customer.subscription.deleted': {
      const subscription = event.data.object;
      // Revoke access
      break;
    }
  }

  return new Response('ok');
}

Disable CSRF for webhooks. Stripe sends POST requests from its servers, not from your app's forms. If you have CSRF protection enabled globally, exclude the webhook route with csrfProtection: false or add Stripe's origin to allowedOrigins.

Environment Variables

Bun loads .env automatically. Access keys with process.env.

# .env
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

Workers pass environment variables via the env binding. Set secrets with wrangler secret put.

wrangler secret put STRIPE_SECRET_KEY
wrangler secret put STRIPE_WEBHOOK_SECRET
// server.ts — access via env binding
export default {
  fetch: (req: Request, env: Env) => {
    const stripe = new Stripe(env.STRIPE_SECRET_KEY);
    // share via context...
  },
};