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 on the server, @stripe/stripe-js in the browser, and Stripe-hosted Checkout Sessions so card details never touch your DaloyJS app.

What you should know up front

  • Start with Checkout Sessions. Stripe Checkout is the fastest secure path: your server creates a session, the browser redirects to Stripe, and Stripe handles the hosted payment page, payment method collection, SCA, wallets, localization, and receipts.
  • Use Stripe.js on the client, not raw card forms. @stripe/stripe-jsis a small loader for Stripe's hosted https://js.stripe.com script. Stripe says this is required for PCI compliance; do not bundle or self-host Stripe.js.
  • Webhook verification needs the raw body. stripe.webhooks.constructEvent() requires the exact raw request body, the Stripe-Signature header, and the endpoint secret. JSON parsing before verification will break the signature check.
  • Send idempotency keys on mutating retries. Stripe accepts idempotency keys on every POST. Generate a UUID per logical attempt and reuse it when retrying the same create or update call.
  • Stripe is separate from PayPal. Keep this guide next to Braintree, not under it. Braintree is PayPal's gateway; Stripe is a separate provider.

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 run local integration checks.
  3. Copy your test Secret key and Publishable key from Developers - API keys. Keep the secret key server-side only.
  4. Add a webhook endpoint for your app and copy its Signing secret, which starts with whsec_. For local development, stripe listen prints a different secret than the Dashboard endpoint.

2. Install

ts
pnpm add stripe @stripe/stripe-js

stripe belongs in your server application. @stripe/stripe-js belongs in browser code that redirects to Checkout or renders Elements. Neither package is a runtime dependency of@daloyjs/core.

3. Environment variables

ts
# .env
STRIPE_SECRET_KEY=sk_test_replace_me
STRIPE_PUBLISHABLE_KEY=pk_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
STRIPE_CURRENCY=usd

Only STRIPE_PUBLISHABLE_KEY is safe to expose to the browser. Treat sk_ keys and whsec_ webhook secrets as production credentials.

4. Plugin

ts
// src/plugins/stripe.ts
import { randomUUID } from "node:crypto";
import Stripe from "stripe";
import type { App } from "@daloyjs/core";

let client: Stripe | null = null;

function getStripe() {
  client ??= new Stripe(process.env.STRIPE_SECRET_KEY!, {
    maxNetworkRetries: 2,
    timeout: 20_000,
  });
  return client;
}

export interface StripeClient {
  raw: Stripe;
  createCheckoutSession(input: {
    lineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
    successUrl?: string;
    cancelUrl?: string;
    customerEmail?: string;
    clientReferenceId?: string;
    metadata?: Record<string, 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: Buffer | string, signature: string | null): Stripe.Event;
}

export const stripePlugin = {
  name: "stripe",
  register(app: App) {
    const stripe = getStripe();
    const wrapped: StripeClient = {
      raw: stripe,

      async createCheckoutSession({
        lineItems,
        successUrl = process.env.STRIPE_SUCCESS_URL!,
        cancelUrl = process.env.STRIPE_CANCEL_URL!,
        customerEmail,
        clientReferenceId,
        metadata,
        idempotencyKey = randomUUID(),
      }) {
        const session = await stripe.checkout.sessions.create(
          {
            mode: "payment",
            line_items: lineItems,
            success_url: successUrl,
            cancel_url: cancelUrl,
            customer_email: customerEmail,
            client_reference_id: clientReferenceId,
            metadata,
          },
          { 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, {
          expand: ["payment_intent", "line_items"],
        });
      },

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

      constructWebhookEvent(rawBody, signature) {
        if (!signature) throw new Error("Missing Stripe-Signature header");
        return stripe.webhooks.constructEvent(
          rawBody,
          signature,
          process.env.STRIPE_WEBHOOK_SECRET!,
        );
      },
    };

    app.decorate("stripe", wrapped);
  },
};

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

5. Create a Checkout Session

Stripe-hosted checkout
BrowserDaloyJS routeStripe
  1. 01requestBrowserDaloyJS routePOST /checkout/stripecart id, line items, customer email
  2. 02requestDaloyJS routeStripecheckout.sessions.createsuccess_url, cancel_url, idempotencyKey
  3. 03responseStripeDaloyJS routeCheckout Session{ id, url }
  4. 04responseDaloyJS routeBrowser200 { sessionId, url }browser redirects to Stripe-hosted page
Your server owns prices, currencies, redirect URLs, metadata, and idempotency. The browser only asks to start checkout and then redirects to Stripe.
ts
import { z } from "zod";
import { App, secureHeaders, rateLimit } from "@daloyjs/core";
import { stripePlugin } from "./plugins/stripe";

const app = new App();
app.use(secureHeaders());
app.use(rateLimit({ windowMs: 60_000, max: 30 }));
app.register(stripePlugin);

app.route({
  method: "POST",
  path: "/checkout/stripe",
  operationId: "createStripeCheckoutSession",
  request: {
    body: z.object({
      cartId: z.string().min(1).max(80),
      customerEmail: z.string().email().optional(),
      items: z.array(
        z.object({
          name: z.string().min(1).max(120),
          quantity: z.number().int().positive().max(99),
          unitAmount: z.number().int().positive(), // cents for USD
        }),
      ).min(1).max(50),
    }),
  },
  responses: {
    200: {
      description: "checkout session",
      body: z.object({ sessionId: z.string(), url: z.string().url() }),
    },
  },
  handler: async ({ body, state }) => {
    const session = await state.stripe.createCheckoutSession({
      clientReferenceId: body.cartId,
      customerEmail: body.customerEmail,
      metadata: { cartId: body.cartId },
      idempotencyKey: `checkout:${body.cartId}`,
      lineItems: body.items.map((item) => ({
        quantity: item.quantity,
        price_data: {
          currency: process.env.STRIPE_CURRENCY ?? "usd",
          unit_amount: item.unitAmount,
          product_data: { name: item.name },
        },
      })),
    });

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

6. Redirect from the browser

If your route returns a session.url, a plainwindow.location.assign(url) is enough. If you prefer redirecting by Session ID, load Stripe.js from @stripe/stripe-jsand call redirectToCheckout:

ts
import { loadStripe } from "@stripe/stripe-js";

const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY);

export async function startCheckout(cartId: string) {
  const res = await fetch("/checkout/stripe", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ cartId, items: collectCartItems() }),
  });
  const { sessionId, url } = await res.json();

  const stripe = await stripePromise;
  if (stripe) {
    await stripe.redirectToCheckout({ sessionId });
    return;
  }

  window.location.assign(url);
}

CSP note: include https://js.stripe.comand Stripe's required frame/connect endpoints in your content security policy when you render Stripe.js or Elements.

7. Receive and verify webhooks

Webhook verification
StripeDaloyJS routeYour queue
  1. 01requestStripeDaloyJS routePOST /webhooks/stripeStripe-Signature over raw body
  2. 02noteDaloyJS routeDaloyJS routewebhooks.constructEventraw body + signature + whsec_ secret
  3. 03responseDaloyJS routeStripe400 when verification fails{ error: 'invalid signature' }
  4. 04asyncDaloyJS routeYour queueDedupe on event.id, then enqueue and ack200 fast, fulfill asynchronously
Stripe webhook verification fails if the body was parsed or re-serialized first. Verify the raw bytes, dedupe on event.id, and handle fulfillment from the signed event or by refetching the Checkout Session.
ts
import { z } from "zod";
import type Stripe from "stripe";
import { readRawBody } from "@daloyjs/core/raw";

app.route({
  method: "POST",
  path: "/webhooks/stripe",
  operationId: "stripeWebhook",
  responses: {
    200: { description: "ack", body: z.object({ ok: z.literal(true) }) },
    400: { description: "bad signature", body: z.object({ error: z.string() }) },
  },
  handler: async ({ request, state }) => {
    const raw = await readRawBody(request);
    const signature = request.headers.get("stripe-signature");

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

    if (await seenStripeEvent(event.id)) {
      return { status: 200, body: { ok: true as const } };
    }

    switch (event.type) {
      case "checkout.session.completed": {
        const session = event.data.object as Stripe.Checkout.Session;
        // Fulfill idempotently on session.id or session.payment_intent.
        await enqueuePaidOrder(session.id, session.client_reference_id);
        break;
      }
      case "checkout.session.expired":
        // Release cart reservation, if you hold one.
        break;
      case "charge.refunded":
      case "refund.updated":
        // Reconcile refund state.
        break;
    }

    return { status: 200, body: { ok: true as const } };
  },
});

During local development, forward events with the Stripe CLI:

ts
stripe listen --forward-to localhost:3000/webhooks/stripe

The CLI prints a temporary whsec_ secret. Use that local secret for forwarded events; do not mix it with the Dashboard endpoint secret.

8. Refunds

ts
// Full refund by PaymentIntent id.
await state.stripe.refund({
  paymentIntent: "pi_xxx",
  idempotencyKey: "refund:order_123",
});

// Partial refund, amount is in the smallest currency unit.
await state.stripe.refund({
  paymentIntent: "pi_xxx",
  amount: 250, // $2.50 for USD
  idempotencyKey: "refund:order_123:partial_1",
});

Errors

The SDK throws structured Stripe.errors.StripeErrorsubclasses for API, card, authentication, rate-limit, and connection failures. Preserve requestId, code, decline_code, and payment_intent in internal logs, but return a stable problem+jsonshape to clients.

Runtimes

The official stripepackage is a server-side SDK and currently supports Node.js LTS versions 18+. It fits DaloyJS Node, serverless, and AWS Lambda deployments. The same SDK also documents a Deno npm import path. For strict edge workers, keep Checkout creation on a Node route or call Stripe's REST API with fetch from an isolated plugin instead of assuming every Node SDK feature works in the worker runtime.

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. Start there only when hosted Checkout cannot model the experience.
  • Pin your API behavior deliberately. Stripe SDK types track the latest API shape. If your account uses an older pinned API version, test upgrades carefully and keep type suppressions rare and local.
  • Never use real card details in test mode. Use Stripe test cards or test PaymentMethod IDs. Real payment method details belong only in live mode through Stripe-hosted collection.

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