> ## Documentation Index
> Fetch the complete documentation index at: https://docs.talosjs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Stripe

> Checkout sessions, webhooks, customers, the billing portal, products, prices, discounts, and revenue analytics through Stripe.

`@talosjs/payment-stripe` wraps the [Stripe](https://stripe.com) API behind eight injectable classes — `StripeCheckoutSession`, `StripeWebhookEvent`, `StripeCustomer`, `StripeCustomerPortal`, `StripeProducts`, `StripeDiscount`, `StripeAnalytics`, and the `StripeProvider` facade — plus typed models for sessions, webhook events, customers, products, prices, and discounts. Each class talks to Stripe through a shared `StripeClient` and normalizes snake\_case responses into camelCase Talos types, converting Unix timestamps to `Date` objects, so call sites never touch the SDK directly.

## Installation

`@talosjs/payment-stripe` depends on Stripe's SDK.

```bash theme={null}
bun add @talosjs/payment-stripe stripe
```

## Configuration

Every class injects `StripeClient`, which reads its credentials from `AppEnv` and constructs the underlying SDK once for the whole container.

```typescript theme={null}
import { container } from "@talosjs/container";
import { StripeCheckoutSession } from "@talosjs/payment-stripe";

const checkout = container.get(StripeCheckoutSession);
```

`StripeClient` validates the secret key eagerly, so a missing one fails fast with `PaymentException` (`TOKEN_REQUIRED`) rather than on the first API call.

If you need an escape hatch, `StripeClient` exposes the configured SDK instance through its `sdk` getter for endpoints the package doesn't wrap.

## Environment variables

| Variable                | Required | Purpose                                                                       |
| ----------------------- | -------- | ----------------------------------------------------------------------------- |
| `STRIPE_SECRET_KEY`     | Yes      | Stripe secret API key. Missing throws `PaymentException` (`TOKEN_REQUIRED`).  |
| `STRIPE_API_VERSION`    | No       | Stripe API version to pin. Defaults to `"2025-06-30.basil"`.                  |
| `STRIPE_WEBHOOK_SECRET` | No       | Signing secret passed to `StripeWebhookEvent.construct()` to verify payloads. |

```bash theme={null}
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
```

<Note>
  Stripe expresses every monetary amount in the currency's minor unit, so `unitAmount: 2900` means \$29.00. The package passes these values through unchanged.
</Note>

## Usage

### Checkout sessions

`StripeCheckoutSession` creates and retrieves hosted checkout sessions. Line items reference existing Stripe price ids, and `quantity` defaults to `1`.

```typescript theme={null}
import { inject } from "@talosjs/container";
import { StripeCheckoutSession } from "@talosjs/payment-stripe";

export class BillingService {
  constructor(@inject(StripeCheckoutSession) private readonly checkout: StripeCheckoutSession) {}

  public async startCheckout(priceId: string, email: string) {
    return this.checkout.create({
      lineItems: [{ price: priceId }],
      mode: "subscription",
      successUrl: "https://app.example.com/billing/success",
      cancelUrl: "https://app.example.com/billing",
      customerEmail: email,
    });
  }
}
```

`mode` accepts `"payment"`, `"subscription"`, or `"setup"`. Passing `customerId` attaches the session to an existing Stripe customer and takes precedence over `customerEmail`. Both `create()` and `get(id)` return a `CheckoutSessionType` with `url`, `status`, `paymentStatus`, `amountTotal`, `currency`, and `metadata`.

### Webhooks

`StripeWebhookEvent.construct()` verifies the signature against the raw request body and returns a discriminated union keyed on `type`, so narrowing on `type` gives you a fully typed `data` payload.

```typescript theme={null}
import { AppEnv } from "@talosjs/app-env";
import { inject } from "@talosjs/container";
import { EStripeEvent, StripeWebhookEvent } from "@talosjs/payment-stripe";

export class WebhookService {
  constructor(
    @inject(StripeWebhookEvent) private readonly webhook: StripeWebhookEvent,
    @inject(AppEnv) private readonly env: AppEnv,
  ) {}

  public async handle(rawBody: string, signature: string) {
    const event = await this.webhook.construct(rawBody, signature, this.env.STRIPE_WEBHOOK_SECRET!);

    switch (event.type) {
      case EStripeEvent.CheckoutSessionCompleted:
        return this.activate(event.data.customerId, event.data.subscriptionId);
      case EStripeEvent.CustomerSubscriptionDeleted:
        return this.revoke(event.data.customerId);
    }
  }
}
```

<Warning>
  `construct()` needs the exact bytes Stripe sent. A controller cannot reach them, because the framework parses the JSON body before the controller runs — see [Reading the raw body](#reading-the-raw-body) for what to do instead.
</Warning>

Five event types are mapped. Anything else throws `PaymentException` (`UNSUPPORTED_EVENT_TYPE`), so configure your Stripe endpoint to send only these:

| `EStripeEvent` member         | Stripe event                    | Mapped `data` fields                                                                                          |
| ----------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `CheckoutSessionCompleted`    | `checkout.session.completed`    | `id`, `customerId`, `customerEmail`, `amountTotal`, `currency`, `paymentStatus`, `subscriptionId`, `metadata` |
| `InvoicePaid`                 | `invoice.paid`                  | `id`, `customerId`, `subscriptionId`, `amountPaid`, `currency`, `status`, `hostedInvoiceUrl`                  |
| `CustomerSubscriptionUpdated` | `customer.subscription.updated` | `id`, `customerId`, `status`, `currentPeriodEnd`, `cancelAtPeriodEnd`, `metadata`                             |
| `CustomerSubscriptionDeleted` | `customer.subscription.deleted` | `id`, `customerId`, `status`, `currentPeriodEnd`, `canceledAt`, `metadata`                                    |
| `PaymentIntentPaymentFailed`  | `payment_intent.payment_failed` | `id`, `customerId`, `amount`, `currency`, `lastPaymentErrorMessage`                                           |

### Provider facade

`StripeProvider` implements `IPaymentProvider`, a three-method interface covering the checkout and webhook flow. Depend on it when a service only needs to start a checkout and read webhooks, and you want the Stripe specifics behind an interface.

```typescript theme={null}
import { StripeProvider } from "@talosjs/payment-stripe";

const session = await stripeProvider.createCheckoutSession({
  lineItems: [{ price: priceId }],
  mode: "payment",
  successUrl: "https://app.example.com/thanks",
});

await stripeProvider.retrieveSession(session.id);
await stripeProvider.constructWebhookEvent(rawBody, signature, secret);
```

### Customers

`StripeCustomer` covers the customer lifecycle, including a structured billing address that maps to Stripe's `address` fields.

```typescript theme={null}
import { StripeCustomer } from "@talosjs/payment-stripe";

const customer = await stripeCustomer.create({
  email: "ada@example.com",
  name: "Ada Lovelace",
  billingAddress: { line1: "12 Rue de Rivoli", city: "Paris", postalCode: "75004", country: "FR" },
});

await stripeCustomer.update(customer.id, { phone: "+33612345678" });
await stripeCustomer.list({ email: "ada@example.com", limit: 20 });
```

`list()` returns `{ items, hasMore }` and defaults to a `limit` of `10`. Paginate by passing the last item's id as `startingAfter`.

<Note>
  `get(id)` throws a plain `Error` when the customer has been deleted in Stripe, not a `PaymentException`.
</Note>

### Products and prices

`StripeProducts` manages both products and their prices. A product carries the descriptive metadata; a price carries the amount, currency, and recurrence.

```typescript theme={null}
import { StripeProducts } from "@talosjs/payment-stripe";

const product = await stripeProducts.create({ name: "Pro Plan" });

const price = await stripeProducts.createPrice({
  productId: product.id,
  currency: "eur",
  unitAmount: 2900,
  type: "recurring",
  interval: "month",
});

await stripeProducts.listPrices(product.id, { active: true });
```

`type` accepts `"one_time"` or `"recurring"`; `interval` (`"day"`, `"week"`, `"month"`, `"year"`) and `intervalCount` apply only to recurring prices, and `intervalCount` defaults to `1`. `remove(id)` deletes the product, which Stripe rejects for products that already have prices attached — deactivate with `update(id, { active: false })` instead.

### Customer portal

`StripeCustomerPortal` creates a billing portal session where customers manage their own subscriptions and payment methods.

```typescript theme={null}
import { StripeCustomerPortal } from "@talosjs/payment-stripe";

const session = await stripeCustomerPortal.create({
  customerId: customer.id,
  returnUrl: "https://app.example.com/settings/billing",
});

session.url; // send the customer here
```

### Discounts

`StripeDiscount` creates percentage or fixed-amount coupons. For `type: "percentage"`, `amount` is the percentage; for `type: "fixed"`, it is a minor-unit amount and `currency` defaults to `"usd"`.

```typescript theme={null}
import { StripeDiscount } from "@talosjs/payment-stripe";

const discount = await stripeDiscount.create({
  name: "Launch week",
  type: "percentage",
  amount: 20,
  duration: "repeating",
  durationInMonths: 3,
  code: "LAUNCH20",
  maxRedemptions: 100,
  appliesTo: [product.id],
});
```

Passing `code` also creates a customer-facing promotion code on top of the coupon, inheriting `maxRedemptions` and `redeemBy`. Without it, the coupon can only be applied programmatically. `durationInMonths` is read only when `duration` is `"repeating"`.

`update()` only accepts `name` and `metadata` — Stripe coupons are otherwise immutable, so changing an amount or duration means creating a new one.

### Analytics

`StripeAnalytics.get()` aggregates succeeded charges into daily periods over a date range and counts subscriptions by status.

```typescript theme={null}
import { StripeAnalytics } from "@talosjs/payment-stripe";

const analytics = await stripeAnalytics.get({
  startDate: new Date("2025-01-01"),
  endDate: new Date("2025-01-31"),
  currency: "eur",
});

analytics.totalRevenue;        // minor units, succeeded charges only
analytics.periods;             // [{ date: "2025-01-04", revenue, currency, transactionCount }, ...]
analytics.activeSubscriptions;
```

<Warning>
  The summary is computed from a single page of Stripe results — `limit` charges (default `100`) and 100 subscriptions. Ranges busier than that are silently truncated, so treat the output as a dashboard figure, not an accounting total.
</Warning>

Passing `currency` filters charges to that currency; otherwise the reported `currency` is taken from the first charge in the range.

## Local development

Stripe delivers webhooks to a public URL, so a local server never receives them on its own. The [Stripe CLI](https://docs.stripe.com/stripe-cli) opens a tunnel from your account to `localhost` and mints the signing secret that tunnel uses.

<Steps>
  <Step title="Install the CLI">
    <CodeGroup>
      ```bash macOS theme={null}
      brew install stripe/stripe-cli/stripe
      ```

      ```bash Linux theme={null}
      curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg > /dev/null
      echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee /etc/apt/sources.list.d/stripe.list
      sudo apt update && sudo apt install stripe
      ```

      ```bash Windows theme={null}
      scoop bucket add stripe https://github.com/stripe/scoop-stripe-cli.git
      scoop install stripe
      ```
    </CodeGroup>
  </Step>

  <Step title="Pair the CLI with your account">
    ```bash theme={null}
    stripe login
    ```

    This opens a browser to authorize the CLI. It stores a restricted key locally and does not touch your `.env`.
  </Step>

  <Step title="Forward events to your app">
    Point the tunnel at your webhook route. The skeleton serves on port `8030` under the `api` prefix.

    ```bash theme={null}
    stripe listen --forward-to localhost:8030/api/webhooks/stripe
    ```

    The command prints the signing secret for this session:

    ```
    > Ready! Your webhook signing secret is whsec_1a2b3c4d5e6f7g8h9i0j (^C to quit)
    ```
  </Step>

  <Step title="Set the secret">
    Copy that value into your local environment. It is specific to the tunnel and differs from the secret of a dashboard-registered endpoint.

    ```yaml .env.yml theme={null}
    payment:
      stripe:
        secret_key: "sk_test_xxxxxxxxxxxxxxxxxxxxxxxx"
        webhook_secret: "whsec_1a2b3c4d5e6f7g8h9i0j"
    ```

    Restart the app so `AppEnv` picks up the change.
  </Step>

  <Step title="Trigger an event">
    In a second terminal, make Stripe emit a real event through the tunnel.

    ```bash theme={null}
    stripe trigger checkout.session.completed
    ```

    `stripe trigger` creates the underlying objects in test mode, so the payload matches production shape. Run `stripe trigger --help` for the full list.
  </Step>
</Steps>

### Forwarding only the mapped events

`StripeWebhookEvent.construct()` throws `UNSUPPORTED_EVENT_TYPE` on anything outside its five mapped types, and `stripe trigger` often emits several events at once. Restrict the tunnel to what the package handles:

```bash theme={null}
stripe listen \
  --events checkout.session.completed,invoice.paid,customer.subscription.updated,customer.subscription.deleted,payment_intent.payment_failed \
  --forward-to localhost:8030/api/webhooks/stripe
```

Apply the same event list to the endpoint you register in the Stripe dashboard for staging and production.

### Receiving webhooks in a controller

Signature verification needs the exact bytes Stripe sent, and `context.request.native` no longer holds them: for an `application/json` request the framework calls `req.json()` while building the context, so the body is consumed before the controller runs. Reading it again throws `Body already used`.

Stripe serializes webhook payloads with two-space indentation, so re-serializing the parsed payload the same way reproduces the original bytes and the signature verifies.

```typescript theme={null}
import { inject } from "@talosjs/container";
import type { ContextType } from "@talosjs/controller";
import { Route } from "@talosjs/routing";
import { Assert } from "@talosjs/validation";
import { HandleStripeWebhookService } from "../services/HandleStripeWebhookService";

type WebhookStripeRouteType = {
  response: Record<string, never>;
};

@Route.post("/webhooks/stripe", {
  name: "billing.webhook.stripe",
  version: 1,
  description: "Handle incoming Stripe webhook events with signature verification",
  response: Assert({}),
  roles: [],
})
export class WebhookStripeController {
  constructor(@inject(HandleStripeWebhookService) private readonly service: HandleStripeWebhookService) {}

  public async index(context: ContextType<WebhookStripeRouteType>) {
    const rawBody = JSON.stringify(context.payload, null, 2);
    const signature = context.request.native.headers.get("stripe-signature") ?? "";

    try {
      await this.service.execute({ rawBody, signature });
    } catch (error) {
      context.logger.error("Stripe webhook rejected", { error });
    }

    return context.response.json({});
  }
}
```

The service calls `StripeWebhookEvent.construct(rawBody, signature, secret)` and dispatches on `event.type`. Returning `200` even on failure stops Stripe from retrying an event you will never accept, and keeps a forged request from learning whether its signature was close.

<Warning>
  Log the failure rather than discarding it. Verification that breaks — a rotated `STRIPE_WEBHOOK_SECRET`, a payload shape that no longer round-trips — looks exactly like a quiet endpoint from Stripe's side, since every delivery still answers `200`. Alert on a sustained run of rejected events.
</Warning>

<Tip>
  Answer Stripe quickly and do the work afterwards. Stripe treats a response slower than 20 seconds as a failure and retries, so acknowledge immediately and hand the event to a [queue](/components/queue) rather than processing it inline.
</Tip>

### Verifying against the raw bytes

The controller above depends on Stripe's payload formatting rather than on the bytes themselves. Round-tripping is exact for the payloads Stripe sends, but it is reconstruction, not the original: a payload whose keys are integer-like — user-controlled `metadata` is the realistic case — comes back reordered and fails verification.

To remove that dependency, serve the webhook from a listener that never parses the body:

```typescript theme={null}
import { AppEnv } from "@talosjs/app-env";
import { container } from "@talosjs/container";
import { StripeWebhookEvent } from "@talosjs/payment-stripe";

const env = container.get(AppEnv);
const webhook = container.get(StripeWebhookEvent);

Bun.serve({
  port: 8031,
  routes: {
    "/webhooks/stripe": async (req) => {
      const raw = await req.text(); // read once, never parsed
      const signature = req.headers.get("stripe-signature");

      if (!signature) {
        return new Response("Missing signature", { status: 400 });
      }

      const event = await webhook.construct(raw, signature, env.STRIPE_WEBHOOK_SECRET!);
      // dispatch on event.type

      return new Response(null, { status: 204 });
    },
  },
});
```

Forward to that port instead — `stripe listen --forward-to localhost:8031/webhooks/stripe`. The trade-off is that this route sits outside the framework, so it gets no middlewares, no route roles, and no request logging.

### Inspecting traffic

`stripe listen` prints each event and the status your app returned. For the API calls behind them, tail the account log in a separate terminal:

```bash theme={null}
stripe logs tail
```

Every object created while `STRIPE_SECRET_KEY` holds an `sk_test_` key lives in test mode and is visible in the dashboard's test view.

## API

| Class                   | Methods                                                                                                                                               |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `StripeClient`          | `sdk` (getter)                                                                                                                                        |
| `StripeProvider`        | `createCheckoutSession(data)`, `retrieveSession(id)`, `constructWebhookEvent(payload, signature, secret)`                                             |
| `StripeCheckoutSession` | `create(data)`, `get(id)`                                                                                                                             |
| `StripeWebhookEvent`    | `construct(payload, signature, secret)`                                                                                                               |
| `StripeCustomer`        | `create(data)`, `update(id, data)`, `remove(id)`, `get(id)`, `list(options?)`                                                                         |
| `StripeCustomerPortal`  | `create(data)`                                                                                                                                        |
| `StripeProducts`        | `create(data)`, `update(id, data)`, `remove(id)`, `get(id)`, `list(options?)`, `createPrice(data)`, `getPrice(id)`, `listPrices(productId, options?)` |
| `StripeDiscount`        | `create(data)`, `update(id, data)`, `remove(id)`, `get(id)`, `list(options?)`                                                                         |
| `StripeAnalytics`       | `get(options)`                                                                                                                                        |

## Exceptions

The package throws `PaymentException` from [`@talosjs/payment`](/integrations/polar) (extending `Exception`, mapped to `InternalServerError`) with a machine-readable `key`.

| Key                         | When                                                                     |
| --------------------------- | ------------------------------------------------------------------------ |
| `TOKEN_REQUIRED`            | `StripeClient` is constructed without `STRIPE_SECRET_KEY` set.           |
| `WEBHOOK_SIGNATURE_INVALID` | The webhook signature fails verification against the payload and secret. |
| `UNSUPPORTED_EVENT_TYPE`    | A verified webhook carries an event type the package doesn't map.        |

```typescript theme={null}
import { PaymentException } from "@talosjs/payment";

try {
  const event = await stripeWebhookEvent.construct(rawBody, signature, secret);
} catch (error) {
  if (error instanceof PaymentException) {
    logger.error(`Payment error [${error.key}]: ${error.message}`, error.data);
  } else {
    throw error;
  }
}
```

Errors raised by Stripe itself propagate as the SDK's own error types.

## Types

Beyond the service classes, `@talosjs/payment-stripe` exports the model backing them: `IPaymentProvider`, `LineItemType`, `CheckoutSessionCreateType`, `CheckoutSessionType`, the per-event `*DataType` and `*EventType` members and the `WebhookEventType` union, the customer types (`StripeCustomerType`, `StripeCustomerCreateType`, `StripeCustomerUpdateType`, `StripeCustomerAddressType`, `StripeCustomerListOptionsType`, `StripeCustomerListResultType`), the portal, product, price, discount, and analytics types, and the `EStripeEvent`, `EStripeDiscountType`, `EStripeDiscountDuration`, `EStripePriceType`, and `EStripePriceInterval` enums. Each enum has a matching string-literal alias (`StripeEventType`, `StripeDiscountTypeType`, `StripePriceIntervalType`, and so on) so payloads accept plain strings.

For Polar as the payment backend instead, see [Polar](/integrations/polar).
