Skip to content

Search docs

Jump between documentation pages.

Browse docs

Accept payments with Stripe in DaloyJS

Stripe is one of the most common starting points for SaaS, subscriptions, marketplaces, and card payments. This guide uses the official stripe Node SDK (v23.x) on the server, Stripe-hosted Checkout Sessions so card details never touch your DaloyJS app, and a plain browser redirect to the session URL.

What you should know up front

  • Start with Checkout Sessions. Stripe Checkout is the fastest secure path: your server creates a session, the browser goes to Stripe, and Stripe handles the hosted payment page, payment method collection, SCA, wallets, localization, and receipts.
  • Prices live on the server. Create Products and Prices in Stripe and pass Price IDs (price_...). The browser sends only an order id. See never trust client amounts.
  • redirectToCheckout is gone. Stripe removed stripe.redirectToCheckout from Stripe.js in the 2025-09-30 (Clover) release. Redirect with window.location.assign(session.url) instead.
  • Webhook verification needs the raw body. constructEventAsync() checks the Stripe-Signature header (t= timestamp, v1= HMAC-SHA256 hex over t + . + raw body) against your endpoint secret, with a default 5 minute tolerance. Parsing before verification breaks the check.
  • Derive idempotency keys from your order. Stripe accepts idempotency keys on every POST. A key built from the order id and cart version (not a fresh UUID per call) makes a retried request a no-op.
  • Stripe is separate from PayPal. Braintree is PayPal's gateway; see the Braintree guide for PayPal wallets.

1. Provision

  1. Create a Stripe account or sandbox from the Stripe Dashboard.
  2. Install and authenticate the Stripe CLI so you can create test products, forward webhooks, and trigger events.
  3. Create a Product and Price for each SKU and store the Price ID next to the SKU in your catalogue.
  4. Copy your test Secret key from Developers - API keys. Keep it server-side only.
  5. Add a webhook endpoint for your app and copy its Signing secret (whsec_...). For local development, stripe listen prints a different secret than the Dashboard endpoint.

2. Install

bash
pnpm add stripe

stripe belongs in your server application. The redirect flow below needs no browser package; add @stripe/stripe-js only if you render embedded Checkout or Elements. Neither package is a runtime dependency of @daloyjs/core.

3. Environment variables

.envbash
STRIPE_SECRET_KEY=sk_test_replace_me
STRIPE_WEBHOOK_SECRET=whsec_replace_me
STRIPE_SUCCESS_URL=https://your-app.example.com/checkout/success?session_id={CHECKOUT_SESSION_ID}
STRIPE_CANCEL_URL=https://your-app.example.com/cart

Treat sk_ keys and whsec_ webhook secrets as production credentials. Only the publishable pk_ key may reach the browser, and only if you use Stripe.js.

4. Plugin

src/plugins/stripe.tsts
import Stripe from "stripe";
import type { App } from "@daloyjs/core";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  // Pin the API version your code was written against; upgrade deliberately.
  apiVersion: "2026-09-30.endive",
  maxNetworkRetries: 2,
  timeout: 20_000,
  // Cloudflare Workers / other fetch-only runtimes:
  // httpClient: Stripe.createFetchHttpClient(),
});

// WebCrypto-based verifier: works on Node 24+, Deno, Bun, and Workers.
const cryptoProvider = Stripe.createSubtleCryptoProvider();

export interface StripeClient {
  raw: Stripe;
  createCheckoutSession(input: {
    lineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
    orderId: string;
    customerEmail?: string;
    idempotencyKey: string;
  }): Promise<{ id: string; url: string }>;
  retrieveCheckoutSession(sessionId: string): Promise<Stripe.Checkout.Session>;
  refund(input: {
    paymentIntent: string;
    amount?: number;
    idempotencyKey: string;
  }): Promise<{ id: string; status: string | null }>;
  constructWebhookEvent(rawBody: Uint8Array, signature: string | null): Promise<Stripe.Event>;
}

// Exported so job handlers (which run outside a request) can use it too.
export const stripeClient: StripeClient = {
  raw: stripe,

  async createCheckoutSession({ lineItems, orderId, customerEmail, idempotencyKey }) {
    const session = await stripe.checkout.sessions.create(
      {
        mode: "payment",
        line_items: lineItems,
        success_url: process.env.STRIPE_SUCCESS_URL!,
        cancel_url: process.env.STRIPE_CANCEL_URL!,
        customer_email: customerEmail,
        client_reference_id: orderId,
        metadata: { orderId },
      },
      { idempotencyKey },
    );
    if (!session.url) throw new Error("Stripe returned a Checkout Session without a URL");
    return { id: session.id, url: session.url };
  },

  retrieveCheckoutSession(sessionId) {
    return stripe.checkout.sessions.retrieve(sessionId);
  },

  async refund({ paymentIntent, amount, idempotencyKey }) {
    const refund = await stripe.refunds.create(
      { payment_intent: paymentIntent, amount },
      { idempotencyKey },
    );
    return { id: refund.id, status: refund.status };
  },

  async constructWebhookEvent(rawBody, signature) {
    if (!signature) throw new Error("Missing Stripe-Signature header");
    return stripe.webhooks.constructEventAsync(
      Buffer.from(rawBody), // on Workers, pass new TextDecoder().decode(rawBody)
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
      undefined, // default 300 s tolerance; never pass 0
      cryptoProvider,
    );
  },
};

// Call with the root app, not through app.register(): a decoration made
// inside a registered plugin is visible only to that plugin's own routes.
export function stripePlugin(app: App) {
  app.decorate("stripe", stripeClient);
}

declare module "@daloyjs/core" {
  interface AppState {
    stripe: StripeClient;
  }
}

5. Create a Checkout Session

Stripe-hosted checkout
BrowserDaloyJS routeStripe
  1. 01requestBrowserDaloyJS routePOST /checkout/stripe{ orderId } only
  2. 02requestDaloyJS routeStripecheckout.sessions.createPrice IDs from your catalogue + idempotencyKey
  3. 03responseStripeDaloyJS routeCheckout Session{ id, url }
  4. 04responseDaloyJS routeBrowser200 { url }window.location.assign(url)
Your server owns prices, currencies, redirect URLs, metadata, and idempotency. The browser only names the order it wants to pay and then follows the hosted URL.

This uses the shared app wiring (session, csrf() and rateLimit() scoped away from /webhooks/**) and the orders module from the overview.

src/routes/checkout-stripe.tsts
import { z } from "zod";
import { idempotency } from "@daloyjs/core";
import { stripePlugin } from "../plugins/stripe.ts";
import { orders } from "../orders.ts";

stripePlugin(app);

app.post(
  "/checkout/stripe",
  {
    operationId: "createStripeCheckoutSession",
    // Safe client retries of this POST (scoped per signed-in user).
    hooks: idempotency({ scope: (ctx) => ctx.state.session.get<string>("userId") }),
    request: {
      body: z.object({ orderId: z.string().min(1).max(80) }).strict(),
    },
    responses: {
      200: { description: "checkout session", body: z.object({ url: z.url() }) },
    },
  },
  async ({ body, state }) => {
    const order = await orders.forCheckout(body.orderId, state.session.get<string>("userId"));

    const session = await state.stripe.createCheckoutSession({
      orderId: order.id,
      // Price IDs, never client-supplied amounts.
      lineItems: order.lines.map((line) => ({ price: line.stripePriceId!, quantity: line.quantity })),
      // The cart version makes a changed cart a new logical request.
      idempotencyKey: `checkout:${order.id}:v${order.version}`,
    });

    return { status: 200, body: { url: session.url } };
  },
);

Stripe rejects a reused idempotency key whose parameters differ from the first request, which is why the key carries the cart version: a changed cart gets a new key instead of an error.

6. Redirect from the browser

app/cart/checkout-button.tsxts
export async function startCheckout(orderId: string, csrfToken: string) {
  const res = await fetch("/checkout/stripe", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-csrf-token": csrfToken,
      "idempotency-key": `checkout-${orderId}`,
    },
    body: JSON.stringify({ orderId }),
  });
  if (!res.ok) throw new Error("Could not start checkout");
  const { url } = (await res.json()) as { url: string };
  window.location.assign(url);
}

If you would rather keep the shopper on your page, Stripe also offers embedded Checkout (rendered with @stripe/stripe-js) and the Payment Element. Both still take server-created sessions or intents, so the server-side pricing above does not change; follow Stripe's Checkout docs for the options that match your pinned API version.

7. Receive and verify webhooks

Webhook verification
StripeDaloyJS routeJob worker
  1. 01requestStripeDaloyJS routePOST /webhooks/stripeStripe-Signature over raw body
  2. 02noteDaloyJS routeDaloyJS routeconstructEventAsyncraw body + signature + whsec_ secret
  3. 03responseDaloyJS routeStripe400 when verification fails{ error: 'invalid signature' }
  4. 04asyncDaloyJS routeJob workerenqueue keyed by event.id, then 200worker refetches the session and fulfils
Verify the raw bytes, let the job store's atomic idempotency key collapse Stripe's retries of the same event, and fulfil in the worker after refetching the Checkout Session.

The route follows the shared webhook pattern: verify, dedupe atomically on event.id through the job idempotency key, enqueue, and acknowledge.

src/webhooks/stripe.tsts
import { z } from "zod";
import type Stripe from "stripe";
import { readBodyLimited, jobIdempotencyKey } from "@daloyjs/core";

app.post(
  "/webhooks/stripe",
  {
    operationId: "stripeWebhook",
    // No request.body schema: the raw bytes stay readable for verification.
    responses: {
      200: { description: "ack", body: z.object({ ok: z.literal(true) }) },
      400: { description: "bad signature", body: z.object({ error: z.string() }) },
    },
  },
  async ({ request, state }) => {
    const raw = await readBodyLimited(request, 1_048_576);

    let event: Stripe.Event;
    try {
      event = await state.stripe.constructWebhookEvent(raw, request.headers.get("stripe-signature"));
    } catch {
      return { status: 400, body: { error: "invalid signature" } };
    }

    if (event.type === "checkout.session.completed" || event.type === "checkout.session.async_payment_succeeded") {
      await app.jobs!.enqueue({
        name: "stripe.checkout_paid",
        payload: { eventId: event.id, sessionId: event.data.object.id },
        idempotencyKey: jobIdempotencyKey({ name: "stripe.checkout_paid", key: event.id }),
      });
    }
    // Ack every verified event, including types you do not handle.
    return { status: 200, body: { ok: true as const } };
  },
);

The worker refetches the session, checks it is paid, and lets orders.markPaid() compare the amount and currency with the stored order:

src/jobs/stripe.tsts
import type { JobHandler } from "@daloyjs/core";
import { stripeClient } from "../plugins/stripe.ts";
import { orders } from "../orders.ts";

export const handleStripeCheckoutPaid: JobHandler<{ eventId: string; sessionId: string }> = async ({ job }) => {
  const session = await stripeClient.retrieveCheckoutSession(job.payload.sessionId);
  if (session.payment_status !== "paid") return; // async methods: wait for async_payment_succeeded
  await orders.markPaid(session.metadata!.orderId!, {
    amountMinor: session.amount_total!,
    currency: session.currency!,
    providerRef: String(session.payment_intent),
  });
};

// One useJobs() per app; jobStore is your durable JobStore (MemoryJobStore in dev).
app.useJobs({ store: jobStore, startWorker: true, handlers: { "stripe.checkout_paid": handleStripeCheckoutPaid } });

Stripe retries failed deliveries for up to three days in live mode and does not guarantee ordering, which is why the worker reads the current session state instead of trusting the event body.

8. Testing

shell
stripe listen --forward-to localhost:3000/webhooks/stripe
stripe trigger checkout.session.completed

The CLI prints a temporary whsec_ secret. Use that local secret for forwarded events; do not mix it with the Dashboard endpoint secret. For automated tests, sign a payload with the core signWebhookPayload({ payload, secret, timestamp }) and send Stripe-Signature: t=<timestamp>,v1=<hex>. See testing.

9. Refunds

Expose refunds only through a protected refund route (auth, role, ownership, amount cap, audit log). The plugin call itself:

ts
// Partial refund, amount in the smallest currency unit.
await state.stripe.refund({
  paymentIntent: order.providerRef!,
  amount: 250, // 2.50 USD
  idempotencyKey: `refund:${order.id}:${order.refundedMinor}:250`,
});

Errors

The SDK throws Stripe.errors.StripeError subclasses for API, card, authentication, rate-limit, and connection failures. Log requestId, code, decline_code, and payment_intent internally, and return a stable problem+json shape to clients. Card declines are the only detail worth surfacing, and even then as your own message, not Stripe's raw text.

Runtimes

stripe 23.x declares Node >=20 and supports Node LTS releases; DaloyJS's Node ^24 || >=26 requirement already satisfies that. The package also ships Deno (npm:stripe), Bun, and worker export targets. On Cloudflare Workers, pass httpClient: Stripe.createFetchHttpClient() and verify webhooks with constructEventAsync() plus Stripe.createSubtleCryptoProvider(), as in the plugin above. Vercel Node.js functions, AWS Lambda, and Azure Functions run the Node build unchanged. Vercel Edge is not verified here; see the runtime table.

Modernisation notes

  • Do not make Stripe a framework dependency. Keep it in the application, behind a plugin, so apps that use Braintree, Adyen, Square, or no payments at all keep a dependency-free DaloyJS core.
  • Use Checkout first, Payment Intents when you need control. Payment Intents are the lower-level API for custom payment forms and advanced flows.
  • Pin apiVersion deliberately. stripe-node types track the latest API version at release, and a new API version comes with a new SDK major. Upgrade both together and read the changelog.
  • Never use real card details in test mode. Use Stripe test cards or test PaymentMethod IDs.

See also the payments overview, Braintree guide, Square guide, and problem+json errors.

Edit this page on GitHub

Last updated