# Accept payments with Stripe in DaloyJS

[Stripe](https://stripe.com/) is one of the most common starting points for SaaS, subscriptions, marketplaces, and card payments. This guide uses the official [`stripe`](https://github.com/stripe/stripe-node) Node SDK on the server, [`@stripe/stripe-js`](https://www.npmjs.com/package/@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](https://docs.stripe.com/checkout/quickstart) 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-js` is 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](/docs/payments/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](https://dashboard.stripe.com/register).
2. Install and authenticate the [Stripe CLI](https://docs.stripe.com/get-started/development-environment?lang=node) 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

**Diagram: Stripe-hosted checkout**

Participants: Browser, DaloyJS route, Stripe

1. **Browser -> DaloyJS route** (request) - POST /checkout/stripe - cart id, line items, customer email
2. **DaloyJS route -> Stripe** (request) - checkout.sessions.create - success_url, cancel_url, idempotencyKey
3. **Stripe -> DaloyJS route** (response) - Checkout Session - { id, url }
4. **DaloyJS route -> Browser** (response) - 200 { 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.ts";

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

app.post(
  "/checkout/stripe",
  {
    operationId: "createStripeCheckoutSession",
    request: {
      body: z.object({
        cartId: z.string().min(1).max(80),
        customerEmail: z.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.url() }),
      },
    },
  },
  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 plain `window.location.assign(url)` is enough. If you prefer redirecting by Session ID, load Stripe.js from `@stripe/stripe-js` and 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.com` and Stripe's required frame/connect endpoints in your content security policy when you render Stripe.js or Elements.

## 7. Receive and verify webhooks

**Diagram: Webhook verification**

Participants: Stripe, DaloyJS route, Your queue

1. **Stripe -> DaloyJS route** (request) - POST /webhooks/stripe - Stripe-Signature over raw body
2. **DaloyJS route -> DaloyJS route** (note) - webhooks.constructEvent - raw body + signature + whsec_ secret
3. **DaloyJS route -> Stripe** (response) - 400 when verification fails - { error: 'invalid signature' }
4. **DaloyJS route -> Your queue** (async) - Dedupe on event.id, then enqueue and ack - 200 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 { readBodyLimited } from "@daloyjs/core";

app.post(
  "/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() }) },
    },
  },
  async ({ request, state }) => {
    // Keep webhook reads bounded and preserve the exact bytes Stripe signed.
    const raw = Buffer.from(await readBodyLimited(request, 1_048_576));
    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.StripeError` subclasses 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+json](/docs/errors) shape to clients.

## Runtimes

The official `stripe` package 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](/docs/payments), [Braintree guide](/docs/payments/braintree), [Square guide](/docs/payments/square), and [problem+json errors](/docs/errors).

---

Source: https://daloyjs.dev/docs/payments/stripe