GitHub

Payments (Stripe)

Pulse uses Stripe's hosted Checkout — no client-side Stripe JS required. Checkout sessions are created server-side in an action's run function. Stripe handles the payment UI entirely. Webhooks are verified and handled through a raw response spec.

Pulse has no external client-side JS. Use Stripe's hosted Checkout page (redirect flow) rather than Stripe Elements, which requires loading Stripe's client library.

Setup

npm install stripe
# .env
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
APP_URL=http://localhost:3000

Checkout action

Create a Stripe Checkout session in an action's run function and redirect the browser to it.

// src/pages/pricing.js
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const APP_URL = process.env.APP_URL

export default {
  route: '/pricing',

  state: { status: 'idle' },

  view: (state) => `
    <main id="main-content">
      <h1>Pricing</h1>
      <form data-action="checkout">
        <input type="hidden" name="priceId" value="price_xxxx">
        <button type="submit">
          ${state.status === 'loading' ? 'Redirecting…' : 'Buy now'}
        </button>
      </form>
      ${state.status === 'error'
        ? '<p role="alert">Something went wrong. Please try again.</p>'
        : ''}
    </main>
  `,

  actions: {
    checkout: {
      onStart: () => ({ status: 'loading' }),

      run: async (state, serverState, formData) => {
        const priceId = formData.get('priceId')

        const session = await stripe.checkout.sessions.create({
          mode:                'payment',
          line_items:          [{ price: priceId, quantity: 1 }],
          success_url:         `${APP_URL}/checkout/success?session={CHECKOUT_SESSION_ID}`,
          cancel_url:          `${APP_URL}/checkout/cancel`,
        })

        return { url: session.url }
      },

      onSuccess: (state, result) => {
        // Redirect to Stripe's hosted checkout page
        window.location.href = result.url
        return { status: 'redirecting' }
      },

      onError: () => ({ status: 'error' }),
    },
  },
}

Success and cancel pages

// src/pages/checkout/success.js
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)

export default {
  route: '/checkout/success',
  meta: { title: 'Payment successful', styles: ['/app.css'] },

  server: {
    session: async (ctx) => {
      const { session } = ctx.query
      if (!session) return null
      return stripe.checkout.sessions.retrieve(session)
    },
  },

  state: {},
  view: (state, server) => `
    <main id="main-content">
      <h1>Payment successful</h1>
      ${server.session
        ? `<p>Thank you! Your order reference is <strong>${server.session.id}</strong>.</p>`
        : '<p>Thank you for your purchase.</p>'
      }
      <a href="/">Back to home</a>
    </main>
  `,
}
// src/pages/checkout/cancel.js
export default {
  route: '/checkout/cancel',
  meta: { title: 'Payment cancelled', styles: ['/app.css'] },
  state: {},
  view: () => `
    <main id="main-content">
      <h1>Payment cancelled</h1>
      <p>No charge was made.</p>
      <a href="/pricing">Back to pricing</a>
    </main>
  `,
}

Webhook handler

Stripe sends signed POST requests to your webhook endpoint. Use a raw response spec to verify the signature and handle events. The raw body is required for signature verification — access it via ctx.rawBody if your server is configured to populate it, or read it from the request stream.

// src/pages/webhooks/stripe.js
import Stripe from 'stripe'

const stripe         = new Stripe(process.env.STRIPE_SECRET_KEY)
const webhookSecret  = process.env.STRIPE_WEBHOOK_SECRET

export default {
  route:       '/webhooks/stripe',
  contentType: 'application/json',

  server: {
    event: async (ctx) => {
      const sig  = ctx.headers['stripe-signature']
      const body = ctx.rawBody // raw Buffer — see note below

      try {
        return stripe.webhooks.constructEvent(body, sig, webhookSecret)
      } catch (err) {
        return { error: err.message }
      }
    },
  },

  render: (ctx, server) => {
    if (server.event.error) {
      ctx.setHeader('X-Webhook-Error', server.event.error)
      return JSON.stringify({ error: server.event.error })
    }

    const event = server.event

    if (event.type === 'checkout.session.completed') {
      const session = event.data.object
      // fulfil the order...
    }

    if (event.type === 'customer.subscription.deleted') {
      // handle cancellation...
    }

    return JSON.stringify({ received: true })
  },
}
Stripe signature verification requires the raw request body before JSON parsing. Configure your Pulse server with onRequest to capture ctx.rawBody for the webhook route, or use a dedicated webhook path handled before Pulse's request pipeline.

Pattern summary

ConcernPulse primitive
Initiate checkoutaction.run — calls Stripe API server-side, returns checkout URL
Redirect to Stripeaction.onSuccess — sets window.location.href
Confirm paymentspec.server on success page — retrieves session from Stripe
Handle webhooksRaw response spec with render returning JSON
Verify signaturespec.server fetcher using stripe.webhooks.constructEvent