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.
| Subpath | What it does |
|---|---|
@cascivo/app/stripe | Checkout sessions, subscriptions, the Customer Portal, refunds, typed webhook events, entitlement checks |
@cascivo/app/guard | verifyWebhook: Stripe's signature and timestamp window over the raw body |
@cascivo/app/ses | Amazon SES sending, and SES bounces and complaints delivered by SNS |
@cascivo/email | The 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
- The price comes from the Worker, never from the browser.
- The order id is the idempotency key, so a retried request returns the first session instead of opening a second one.
- Pin
apiVersion, so upgrading the Stripe account cannot change responses under a deployed app.
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:
- Retries. An event can arrive twice, for up to three days. Change an order with an update
conditioned on its current status (
… WHERE session_id = ? AND status = 'pending'), so a second delivery changes nothing and the receipt goes out once. - Late and out-of-order events. Never apply events in arrival order. Store what Stripe says
now (
retrieveSubscription), or keep a value that only grows (a refunded total). - Delayed payment methods.
checkout.session.completedwithpaymentStatus: 'unpaid'is a bank debit that has not settled. Fulfil oncheckout.session.async_payment_succeeded.
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.
| Event | kind | What the order does |
|---|---|---|
charge.refunded | refund | Store charge.amountRefunded, the running total; refunded when charge.refunded |
charge.dispute.created | dispute | disputed: hold back anything not delivered yet |
charge.dispute.closed | dispute | paid 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 },
})
- Name the user in
subscriptionMetadata. Only your server can set it, so every subscription event can be trusted to say whose plan it is. - Read the subscription back. On
kind: 'subscription', callretrieveSubscription(event.subscription.id)and store that, not the event's copy. - Let Stripe host the rest.
createPortalSession({ customer, returnUrl })opens the Customer Portal for plan changes, cards, invoices and cancellation. Save its settings once in the dashboard (test mode too) first.
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:
checkout.session.completed,checkout.session.async_payment_succeeded,checkout.session.async_payment_failed,checkout.session.expiredcharge.refunded,charge.dispute.created,charge.dispute.closed- with subscriptions:
customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,invoice.payment_failed
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