Quickback Docs

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:

  1. An action creates a Stripe Checkout Session via services.stripe, carrying the record's id in metadata.
  2. A stripe:checkout.session.completed handler 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:

quickback/quickback.config.ts
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_SECRET

The record

Nothing about this table is payment machinery — it is your domain row plus three columns recording what Stripe did to it.

quickback/features/orders/orders.ts
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.

quickback/features/orders/actions/startCheckout.ts
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:

PropertyWhat 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 checkThe 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 handlerBelongs in an action
status → "paid", paidAt, stripeSessionIdPosting double-entry GL rows
Enqueuing a receipt emailDebiting a wallet or credit balance
Invalidating a cacheAnything 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.

SituationCorrect response
No metadata.orderIdReturn quietly. The endpoint receives every Stripe event for the account, including ones this recipe never created.
orderId present, no matching rowLog 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 paidZero 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 a stripe:charge.refunded handler, following the same shape.
  • Recurring billing. Use the subscriptions plugin — it already owns tiers, the portal, renewals and cancellation.
  • Marketplaces. Read ctx.event.account to route a connected account's event; see Stripe Connect.

Next

On this page