One-off Checkout
Charge for a single record with Stripe Checkout — an action creates the session, an inbound webhook handler marks the record paid.
Charging once for one row — an order, a ticket, a report — is a recipe, not a Quickback primitive. It is two halves that already exist:
- An action creates a Stripe Checkout Session via
services.stripe, carrying the record's id inmetadata. - A
stripe:checkout.session.completedhandler on the inbound webhook surface reads that id back and marks the record paid.
metadata is the whole link between them. Stripe returns it verbatim on the
event, so the handler never has to guess which row a payment belongs to.
There is deliberately no definePayment(). If this recipe turns out to have a
hole in real use, that is the trigger to build one.
Configuration
webhooksBinding is what turns on the inbound surface — and with it,
services.stripe:
import { defineAuth, defineConfig, defineDatabase, defineRuntime } from "@quickback/compiler";
export default defineConfig({
name: "storefront",
providers: {
runtime: defineRuntime("cloudflare"),
database: defineDatabase("cloudflare-d1", {
binding: "DB",
// Turns on inbound webhooks — and emits services.stripe.
webhooksBinding: "WEBHOOKS_DB",
}),
auth: defineAuth("better-auth", {
emailAndPassword: { enabled: true },
}),
},
});Set both secrets — see the dev loop for test vs live values:
npx wrangler secret put STRIPE_SECRET_KEY
npx wrangler secret put STRIPE_WEBHOOK_SECRETThe record
Nothing about this table is payment machinery — it is your domain row plus three columns recording what Stripe did to it.
import { q, defineTable } from "@quickback/compiler";
export const orders = q.table("orders", {
id: q.id(),
description: q.text().required(),
amountCents: q.int().required(),
currency: q.text().default("usd").required(),
status: q.text().default("unpaid").required(),
stripeSessionId: q.text().optional(),
paidAt: q.timestamp().optional(),
organizationId: q.scope("organization"),
...q.audit(),
...q.softDelete(),
});
export default defineTable(orders, {
read: { access: { roles: ["AUTHENTICATED"] } },
crud: {
create: { access: { roles: ["AUTHENTICATED"] } },
// Stripe is the only writer of `status: "paid"`. Clients never mark
// their own orders paid, so ordinary update/delete stay admin-only.
update: { access: { roles: ["admin"] } },
delete: { access: { roles: ["admin"] } },
},
});status starts at unpaid and only the webhook handler moves it. That is the
entire trust model: the client is told where to pay, and Stripe — not the
client — reports back that it happened.
The action and the handler
Both halves live in one action file. The handler is registered at module
scope, which is what puts it on the queue consumer's module graph: feature
action files are imported by the feature's routes, the routes are imported by
the worker entry, and that same entry exports the queue() consumer.
import { z } from "zod";
import { and, eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/d1";
import { defineAction, orders } from "../.quickback/define-action";
import { onWebhookEvent } from "../../../lib/webhooks";
export default defineAction({
description: "Create a Stripe Checkout Session for this order.",
input: z.object({
successUrl: z.string().url(),
cancelUrl: z.string().url(),
}),
access: { roles: ["AUTHENTICATED"] },
async execute({ services, record, input }) {
if (record.status === "paid") {
throw new Error("Order is already paid");
}
const session = await services.stripe.checkout.sessions.create(
{
mode: "payment",
success_url: input.successUrl,
cancel_url: input.cancelUrl,
line_items: [
{
quantity: 1,
price_data: {
currency: record.currency,
unit_amount: record.amountCents,
product_data: { name: record.description },
},
},
],
// The only link back to the row. Stripe returns it verbatim.
metadata: { orderId: record.id },
},
// A retried click reuses the session instead of opening a second one.
{ idempotencyKey: `order-checkout-${record.id}` },
);
return { checkoutUrl: session.url };
},
});
// ── Fulfilment ────────────────────────────────────────────────────────────
onWebhookEvent("stripe:checkout.session.completed", async (ctx) => {
// Through `unknown`: ctx.data is a Record<string, unknown>, which does not
// sufficiently overlap a Stripe resource type for a one-step cast.
const session = ctx.data as unknown as import("stripe").Stripe.Checkout.Session;
const orderId = session.metadata?.orderId;
// A Checkout Session this recipe did not create. Not an error.
if (!orderId) return;
// `completed` is not `paid`: delayed-notification methods complete the
// session unpaid and settle later on async_payment_succeeded.
if (session.payment_status !== "paid") return;
const db = drizzle(ctx.env.DB);
const marked = await db
.update(orders)
// `q.timestamp()` is a text column on D1/SQLite — write the ISO string.
.set({
status: "paid",
stripeSessionId: session.id,
paidAt: new Date().toISOString(),
})
.where(and(eq(orders.id, orderId), eq(orders.status, "unpaid")))
.returning({ id: orders.id });
if (marked.length === 0) {
console.warn(
`stripe: no unpaid order ${orderId} for session ${session.id} — ignoring`,
);
}
});The action returns a URL; redirect the browser to it. Everything after that happens on Stripe's domain, and the next thing your Worker hears is the webhook.
A webhook handler has no caller, so it has no scoped db. Actions get a
security-filtered handle that applies the org/owner firewall from the request
principal (Scoped DB). A queued event has no
principal to scope to, so the handler builds a plain Drizzle client off
ctx.env instead — exactly as the bundled subscription handlers do. That
client is unfiltered: the where clause is the only thing keeping the
write on one row, which is why it matches on the id Stripe echoed back and on
status = "unpaid".
Idempotency is not optional
Inbound delivery is at-least-once. Deduplication on (provider, externalId)
stops Stripe redelivering an event you already accepted, but it does not stop a
retry: if any handler registered for a message throws, the queue retries the
whole message and every handler for that event runs again.
So the handler must converge, not accumulate. Two properties do it here:
| Property | What it buys |
|---|---|
where status = "unpaid" | The second run updates zero rows. Marking paid twice is a no-op, not a double state change. |
.returning() + a zero-row check | The no-op is observable, so "already handled" is distinguishable from "never matched". |
Write the same way for any handler you add: match on the id the provider echoed back, guard the transition on the state you expect to leave, and make a re-run land on nothing.
The idempotencyKey on the outbound side is the mirror image — it stops a
double-clicked action from opening two Checkout Sessions for one order.
The money rule
The queue handler does state marking. It does not move money.
Marking an order paid is state propagation: Stripe already took the money, and the handler is recording a fact that is true whether or not the Worker is healthy. Async, retried, eventually-consistent — all correct.
Posting to a ledger is not that. A general-ledger entry must succeed or fail with the operation that caused it, in a transaction the caller can see the result of. A queue handler has no caller, no shared transaction, and a retry policy that will happily run your posting logic a second time.
| Belongs in the handler | Belongs in an action |
|---|---|
status → "paid", paidAt, stripeSessionId | Posting double-entry GL rows |
| Enqueuing a receipt email | Debiting a wallet or credit balance |
| Invalidating a cache | Anything whose failure must fail the caller's request |
If fulfilment must post to a ledger, the handler marks the record paid and the ledger posting happens in an action — invoked by the operation that needs it, synchronously, where a failure is somebody's 500 rather than a silent retry.
Failure modes
Handle these explicitly; none of them should be a guess.
| Situation | Correct response |
|---|---|
No metadata.orderId | Return quietly. The endpoint receives every Stripe event for the account, including ones this recipe never created. |
orderId present, no matching row | Log loudly and stop. Never fall back to matching on amount, email, or customer — that is how one customer's payment marks another customer's order paid. |
Row already paid | Zero rows updated. Log at most; this is the retry path working. |
payment_status !== "paid" | Return. Subscribe to checkout.session.async_payment_succeeded if you accept delayed-notification methods. |
| Genuinely transient failure (D1 unavailable) | Throw. That is what earns a retry. |
The distinction in the last two rows is the one to get right: throw only when a
retry could plausibly succeed. Throwing on an unknown order id buys three
pointless retries and then a failed row, when the honest outcome was "this
event is not mine".
An inbound event is recorded and deduplicated before it is queued, so if
the queue send itself fails, Stripe's redelivery is suppressed for that event
id. Replay it with POST /webhooks/v1/inbound/events/:id/retry. This applies
to every provider, not just Stripe.
What this recipe does not cover
- Refunds.
services.stripe.refunds.create(...)from an action, plus astripe:charge.refundedhandler, following the same shape. - Recurring billing. Use the subscriptions plugin — it already owns tiers, the portal, renewals and cancellation.
- Marketplaces. Read
ctx.event.accountto route a connected account's event; see Stripe Connect.
Next
- The Stripe dev loop — run this locally before you ship it.
Payments
What Quickback does natively with Stripe — the subscriptions plugin, services.stripe, and stripe:* inbound webhooks — and what it deliberately leaves to you.
The Stripe Dev Loop
Test keys vs live keys per environment, forwarding events with stripe listen, and driving subscription lifecycles with test clocks.