Recipe: payments, billing and email on Cloudflare

Take money and send mail from a cascivo app on Cloudflare Workers, with no Stripe SDK, no AWS SDK and no nodejs_compat. Everything here is in @cascivo/app: plain fetch against Stripe's and SES's APIs, and WebCrypto for the signatures.

SubpathWhat it does
@cascivo/app/stripeCheckout sessions, subscriptions, the Customer Portal, refunds, typed webhook events, entitlement checks
@cascivo/app/guardverifyWebhook: Stripe's signature and timestamp window over the raw body
@cascivo/app/sesAmazon SES sending, and SES bounces and complaints delivered by SNS
@cascivo/emailThe emails themselves: receipts, reminders, newsletters

The reference for every function is the @cascivo/app README. This page is the workflow, and the mistakes it is built to prevent.

Start from a scaffold

npx cascivo create shop --framework cloudflare --example checkout              # one product
npx cascivo create shop --framework cloudflare --example checkout --auth email # + a monthly plan
npx cascivo create news --framework cloudflare --example newsletter            # Amazon SES

Each runs under vite dev before any account is set up: emails are logged instead of sent, and the order page reads the payment back from Stripe instead of waiting for a webhook. The generated README lists the keys and dashboard settings each one needs. The rest of this page explains what the generated code does, so you can change it safely.

Sell one thing

The Worker creates a Checkout Session and sends the browser to Stripe's hosted page. The card never touches your app.

import { createStripe } from '@cascivo/app/stripe'

const stripe = createStripe(env.STRIPE_SECRET_KEY, { apiVersion: '2025-09-30.clover' })
const session = await stripe.createCheckoutSession(
  {
    mode: 'payment',
    lineItems: [{ name: 'Sticker pack', amount: 900, currency: 'eur', quantity: 1 }],
    successUrl: `${origin}/checkout/${orderId}`,
    cancelUrl: `${origin}/checkout`,
    clientReferenceId: orderId,
  },
  { idempotencyKey: orderId },
)
// store the order as pending under session.id, then redirect to session.url

Hear back from Stripe

Stripe calls your webhook when something happens. Verify first, then parse:

import { verifyWebhook } from '@cascivo/app/guard'
import { parseStripeEvent } from '@cascivo/app/stripe'

const { body, id } = await verifyWebhook(request, {
  scheme: 'stripe',
  secret: env.STRIPE_WEBHOOK_SECRET,
})
const event = parseStripeEvent(body)

verifyWebhook reads the raw body, checks the Stripe-Signature HMAC in constant time and refuses a delivery signed more than five minutes ago (a captured one replayed later). A bad signature is a 401. id is the event's evt_…: a retried delivery keeps it, so you can store deliveries by it. parseStripeEvent then reads each field it types, because a signature proves who sent the body, not its shape.

The handler has to survive three things Stripe does on purpose:

Find the order by Stripe's session id, never by clientReferenceId: a buyer can set that one on a Payment Link. Answer 2xx to events you do not handle (kind: 'other') so Stripe stops retrying them.

After the sale: refunds and disputes

Refund and dispute events name the payment, not the session. Store session.paymentIntentId with the order when it is paid, and look the order up by it.

EventkindWhat the order does
charge.refundedrefundStore charge.amountRefunded, the running total; refunded when charge.refunded
charge.dispute.createddisputedisputed: hold back anything not delivered yet
charge.dispute.closeddisputepaid again when dispute.status is won; stays disputed when lost
if (event.kind === 'refund') {
  // UPDATE orders SET refunded_amount = MAX(refunded_amount, ?) … WHERE payment_intent_id = ?
}

amountRefunded is cumulative, so keeping the largest value seen means a retried or late event cannot count a refund twice. A created dispute event can arrive after the closed one: store the dispute's status, and ignore created once a status is stored. Answer a dispute with evidence in the Stripe dashboard, before the deadline it shows.

To refund from your own admin screen:

await stripe.createRefund({ paymentIntent, amount: 300 }, { idempotencyKey: `${orderId}-refund-1` })

Change the order when charge.refunded arrives, not on this call's answer. Then a refund made in the Stripe dashboard is handled the same way.

Subscriptions

A subscription needs an account to belong to, so this part assumes --auth email.

await stripe.createCheckoutSession({
  mode: 'subscription',
  lineItems: [{ name: 'Pro', amount: 900, currency: 'eur', interval: 'month', quantity: 1 }],
  successUrl: `${origin}/billing?session={CHECKOUT_SESSION_ID}`,
  cancelUrl: `${origin}/billing`,
  customerEmail: user.email, // or customer: the stored cus_… for a returning subscriber
  subscriptionMetadata: { user: user.id },
})

Gate the paid features

import { isEntitled, requireEntitlement } from '@cascivo/app/stripe'

requireEntitlement(row?.status) // throws HttpError(402) unless the plan is on

active and trialing unlock the plan. past_due does too, while Stripe retries a failed renewal: pass { pastDue: false } to lock it at once instead. How long the retries last is set in the Stripe dashboard (Billing → Subscriptions and emails → Manage failed payments), which then ends the subscription as canceled or unpaid. So the grace period is decided in one place, not in your code.

Check in the Worker, never only in the page. To show or hide things by plan, put the plan into your feature flags' context, e.g. flags.evaluate(env.FLAGS, { plan: isEntitled(status) ? 'pro' : 'free' }), and still refuse the paid request in the Worker.

Failed renewals

invoice.payment_failed (kind: 'invoice') arrives once per failed attempt. Tell the customer, with invoice.hostedInvoiceUrl: Stripe's page for paying the invoice with another card. Each event can be retried, so record ${invoice.id}:${invoice.attemptCount} and send only when it is new. Send only to customers your app bills: one Stripe account often bills other things too. If you send these yourself, turn off Stripe's own failed-payment emails, or customers get two.

Email the customer

sendEmail from @cascivo/email checks a message before it leaves (subject, preheader, text part, size, and line breaks in headers), then hands it to a sender. Cloudflare's Email Service binding is a sender, and so is the SES client:

import { createSes } from '@cascivo/app/ses'
import { Receipt, receiptSubject, renderEmail, sendEmail } from '@cascivo/email'

const message = renderEmail(<Receipt {...props} />, { subject: receiptSubject(props) })

await sendEmail(env.EMAIL, message, { from: '[email protected]', to }) // Email Service
await sendEmail(createSes({ region, accessKeyId, secretAccessKey }), message, {
  from: { name: 'Example Shop', email: '[email protected]' },
  to,
}) // Amazon SES

Pick the transport in one place. Email Service suits sign-in links and receipts on a domain already on Cloudflare. Amazon SES suits bulk mail: about $0.10 per 1,000 emails, your own domain with DKIM, and feedback about bounces and complaints. Give the SES IAM user ses:SendEmail and nothing else.

Bounces and complaints

SES publishes them to an SNS topic, which can call your Worker over HTTPS:

import { handleSns, parseSesNotification } from '@cascivo/app/ses'

return handleSns(request, {
  topicArn: env.SNS_TOPIC_ARN,
  onNotification: async ({ message }) => {
    const event = parseSesNotification(message)
    if (event.kind === 'complaint' || (event.kind === 'bounce' && event.bounceType === 'Permanent')) {
      // never mail event.recipients again
    }
  },
})

handleSns checks each message's RSA signature against the certificate SNS signs with, fetched only from an sns.<region>.amazonaws.com host, and confirms the subscription itself. Act on permanent bounces and complaints: an SES account that keeps mailing them is put under review.

Newsletters

Gmail and Yahoo filter bulk mail that skips the rules. --example newsletter has each one: double opt-in, one-click unsubscribe (List-Unsubscribe and List-Unsubscribe-Post, RFC 8058), suppression from SES feedback, and sending through a Queue at SES's rate, recording each send so a retried batch never mails anyone twice.

The Stripe webhook endpoint

In the Stripe dashboard, add an endpoint at https://<your app>/api/stripe/webhook for these events, and put its signing secret in STRIPE_WEBHOOK_SECRET:

Locally, stripe listen --forward-to localhost:5173/api/stripe/webhook prints a webhook secret for .dev.vars. In test mode, pay with the card 4242 4242 4242 4242.

What this does not cover

Stripe Elements (a card form in your page, with a larger PCI scope than hosted Checkout), Connect, metered billing and Stripe Tax are product decisions rather than scaffolds. For any other Stripe call, install the stripe package: it runs on Workers too, and nothing here gets in its way. For any other AWS call, signAwsRequest from @cascivo/app/ses produces the Signature V4 headers.

As Markdown: /docs/recipe-payments.md

← All guides