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

> ## Agent Instructions
> Money is always a decimal string at the currency's precision (for example "29.00"), paired with an ISO 4217 currency field. Never use minor units.
> Build against the sandbox first: base URL https://sandbox-api.bachs.io with sk_sandbox_ keys. Production is https://api.bachs.io with sk_live_ keys; going live is a key swap.
> Treat webhooks (for example collection.succeeded) as the source of truth for fulfilment, never client-side events or redirects.
> Subscriptions are created by completing a checkout for a recurring product. There is no direct create-subscription endpoint.
> IDs carry resource prefixes (cust_, prod_, sub_, chk_, inv_, ref_) and timestamps are ISO 8601 UTC.

# Build a SaaS with subscriptions

> Sell monthly and yearly plans, give access from webhooks, and let customers manage billing themselves. The full flow, end to end, in the sandbox.

You run a software product and want customers to pay for it every month or every year. In this guide you'll create your plans, send a signed-in user to checkout, give them access when Bachs tells you the subscription is active, and let them change or cancel their plan in the customer portal. By the end you'll have the complete subscription loop running in the sandbox, with three routes on your server and no billing logic of your own.

<Note>
  Subscriptions bill **USD cards** today. Free trials are in beta. See [Subscriptions](/guides/subscriptions/overview) and [Trials](/guides/subscriptions/trials).
</Note>

For a working Node.js implementation, use the [Next.js SaaS starter](https://github.com/bachsdev/bachs-nextjs-saas). It uses the official Bachs SDK for checkout, products, customer portal sessions, and webhook verification. The HTTP examples below explain the same flow for any server language.

## How it fits together

```mermaid theme={"dark"}
sequenceDiagram
    participant C as Customer
    participant A as Your app
    participant B as Bachs
    C->>A: Clicks "Subscribe"
    A->>B: Create a checkout session
    B-->>A: checkout_url
    A-->>C: Redirect to checkout_url
    C->>B: Pays on the hosted checkout
    B-->>C: Redirect to your success_url
    B->>A: Webhook: customer.subscription.created
    A->>A: Save the subscription, give access
    Note over A,B: Each renewal: invoice.paid or invoice.payment_failed
    C->>A: Clicks "Manage billing"
    A->>B: Create a portal session
    A-->>C: Redirect to the portal
```

Your app owns two things: who the user is, and whether they have access. Bachs owns everything about money: the card, the renewals, the retries, the receipts and the cancellation flow. The webhook is where the two meet.

## What you'll use

| Object | Its job in this build | Reference |
| - | - | - |
| Product | One plan at one cadence, for example "Pro, monthly". | [The product object](/api-reference/products/object) |
| Checkout session | The hosted page where the customer pays and the card is saved. | [The checkout session object](/api-reference/checkout-sessions/object) |
| Customer | The billing identity Bachs charges on every renewal. | [The customer object](/api-reference/customers/object) |
| Subscription | The ongoing relationship. Its `status` decides access. | [The subscription object](/api-reference/subscriptions/object) |
| Portal session | A signed-in link to the page where customers manage billing. | [Customer portal](/guides/customer-portal/overview) |
| Webhook endpoint | The route on your server that Bachs tells about every change. | [Set up webhooks](/guides/webhooks/overview) |

## Before you start

* A **sandbox API key** (`sk_sandbox_...`). See [Authentication](/authentication). If you restrict the key's [permissions](/api-reference/permissions), this build needs `products:write`, `payments:write` and `customers:write`.
* The **Bachs CLI**, to forward webhooks to your machine. See [Install the CLI](/cli/overview#install).
* An app with signed-in users. The examples use Next.js route handlers, but any server framework works the same way.

Every request below goes to `https://sandbox-api.bachs.io`, so you can run the whole flow without moving real money. Keep these values in your server's environment, never in the browser:

```bash .env theme={"dark"}
BACHS_API_URL=https://sandbox-api.bachs.io
BACHS_API_KEY=sk_sandbox_...
BACHS_WEBHOOK_SECRET=whsec_...
BACHS_PRO_MONTHLY=prod_...
BACHS_PRO_YEARLY=prod_...
APP_URL=http://localhost:3000
# Optional: public checkout return URLs. Leave empty for local development.
CHECKOUT_SUCCESS_URL=
CHECKOUT_CANCEL_URL=
```

## Steps

<Steps>
  <Step title="Create your plans">
    Create one product for each plan and cadence. A product with a `billing_cycle` is recurring, and its cadence cannot change after you create it, so a monthly and a yearly plan are two products.

    <CodeGroup>
      ```bash Request theme={"dark"}
      curl -X POST https://sandbox-api.bachs.io/v1/products \
        -H "Authorization: Bearer $BACHS_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "name": "Pro (monthly)",
          "price": { "currency": "USD", "amount": "29.00" },
          "billing_cycle": { "interval": "month", "frequency": 1 },
          "metadata": { "plan": "pro" }
        }'
      ```

      ```json Response theme={"dark"}
      {
        "id": "prod_8f3a2c9d1e4b7a6c5d0f",
        "name": "Pro (monthly)",
        "price": {
          "currency": "USD",
          "price_type": "fixed",
          "amount": "29.00",
          "preset_amount": null,
          "minimum_amount": null,
          "maximum_amount": null,
          "currency_options": []
        },
        "billing_cycle": { "interval": "month", "frequency": 1 },
        "trial_period": null,
        "status": "active",
        "metadata": { "plan": "pro" },
        "created_at": "2026-10-09T09:00:00.000Z",
        "updated_at": "2026-10-09T09:00:00.000Z",
        "archived_at": null
      }
      ```
    </CodeGroup>

    Repeat with `"name": "Pro (yearly)"`, `"amount": "290.00"` and `"interval": "year"`. Put both IDs in your environment as `BACHS_PRO_MONTHLY` and `BACHS_PRO_YEARLY`. You can also create products in the dashboard; the IDs work the same way.

    <Tip>
      To offer a free trial, add `"trial_period": { "interval": "day", "frequency": 14 }` to the product. The card is saved at checkout and the first charge happens when the trial ends. A trial checkout sends `customer.subscription.created` with status `trialing`, and the first `invoice.paid` arrives when the trial ends. See [Offer a free trial](/guides/subscriptions/trials).
    </Tip>
  </Step>

  <Step title="Create a checkout session from your server">
    When a signed-in user clicks **Subscribe**, your server creates a checkout session and returns its `checkout_url`. The browser sends only the plan name. Your server decides which product that means, so nobody can change the price from the browser.

    A subscription checkout needs a `customer`, because Bachs must know who to charge on every renewal. Send the user's email the first time, and their saved `customer_id` after that. Put your own user ID in `metadata`: Bachs copies a subscription checkout's metadata onto the subscription, so every subscription webhook tells you which of your users it belongs to.

    <CodeGroup>
      ```bash Request theme={"dark"}
      curl -X POST https://sandbox-api.bachs.io/v1/checkout-sessions \
        -H "Authorization: Bearer $BACHS_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "product_cart": [{ "product_id": "prod_8f3a2c9d1e4b7a6c5d0f", "quantity": 1 }],
          "customer": { "email": "jane@example.com", "name": "Jane Doe" },
          "billing_currency": "USD",
          "metadata": { "user_id": "8812" },
          "success_url": "https://app.example.com/billing/success",
          "cancel_url": "https://app.example.com/pricing"
        }'
      ```

      ```json Response theme={"dark"}
      {
        "checkout_id": "chk_2N3o4P5q6R7s8T9u",
        "checkout_url": "https://checkout.bachs.io/c/V8xQ2mZpLj9RfTa",
        "status": "open",
        "expires_at": "2026-10-09T10:00:00Z",
        "created_at": "2026-10-09T09:00:00Z"
      }
      ```
    </CodeGroup>

    The same call as a route in your app:

    ```js app/api/checkout/route.js theme={"dark"}
    const PLANS = {
      monthly: process.env.BACHS_PRO_MONTHLY,
      yearly: process.env.BACHS_PRO_YEARLY,
    };

    export async function POST(req) {
      const user = await getCurrentUser(req); // your own auth
      const { plan } = await req.json();
      const productId = PLANS[plan];
      if (!productId) return Response.json({ error: "Unknown plan" }, { status: 400 });

      const res = await fetch(`${process.env.BACHS_API_URL}/v1/checkout-sessions`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.BACHS_API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          product_cart: [{ product_id: productId, quantity: 1 }],
          customer: user.bachsCustomerId
            ? { customer_id: user.bachsCustomerId }
            : { email: user.email, name: user.name },
          billing_currency: "USD",
          metadata: { user_id: String(user.id) },
          success_url: process.env.CHECKOUT_SUCCESS_URL || undefined,
          cancel_url: process.env.CHECKOUT_CANCEL_URL || undefined,
        }),
      });
      if (!res.ok) return Response.json({ error: "Could not start checkout" }, { status: 502 });

      const { checkout_url } = await res.json();
      return Response.json({ checkout_url });
    }
    ```

    <Note>
      Checkout redirects must be public URLs; Bachs refuses localhost and private IPs, including in sandbox. For a fully local app, leave `CHECKOUT_SUCCESS_URL` and `CHECKOUT_CANCEL_URL` empty and return to your app manually after paying. To redirect back automatically, set them to a public deployment or tunnel. The CLI forwards webhooks independently of these return URLs.
    </Note>

    In the browser, send the user to `checkout_url`. To keep them on your page instead, open the same URL in the [overlay checkout](/guides/checkout/overlay-checkout) with `Bachs.Checkout.open({ checkoutUrl })`.
  </Step>

  <Step title="Forward webhooks to your machine">
    Start the CLI and forward the events this build uses to your local webhook route:

    ```bash Terminal theme={"dark"}
    bachs listen --forward-to localhost:3000/api/webhooks/bachs \
      --events customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,invoice.paid,invoice.payment_failed
    ```

    The CLI prints a signing secret (`whsec_...`) for this session. Put it in `BACHS_WEBHOOK_SECRET`. Your verification code is the same code you run in production; only the secret is different. See [Test webhooks locally](/developer-portal/local-testing).
  </Step>

  <Step title="Give access from the webhook">
    Your webhook route does three things, in this order: verify the signature against the raw body, skip events you have already processed, then save the subscription.

    ```js app/api/webhooks/bachs/route.js theme={"dark"}
    import crypto from "node:crypto";

    // X-Bachs-Signature-V2 looks like "t=1760000000,v1=abc...,v1=def..."
    function verify(header, rawBody, secret, toleranceSeconds = 300) {
      if (!header) return false;
      const parts = header.split(",").map((part) => part.split("="));
      const timestamp = Number(parts.find(([key]) => key === "t")?.[1]);
      if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

      const expected = crypto
        .createHmac("sha256", secret)
        .update(`${timestamp}.${rawBody}`)
        .digest("hex");

      // During a secret rotation there is one v1 value per valid secret.
      return parts
        .filter(([key]) => key === "v1")
        .some(([, sig]) =>
          sig.length === expected.length &&
          crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
        );
    }

    export async function POST(req) {
      const rawBody = await req.text(); // read before parsing JSON
      const signature = req.headers.get("x-bachs-signature-v2");
      if (!verify(signature, rawBody, process.env.BACHS_WEBHOOK_SECRET)) {
        return new Response("Invalid signature", { status: 400 });
      }

      const event = JSON.parse(rawBody);
      if (await alreadyProcessed(event.id)) return new Response("OK"); // your own store

      switch (event.type) {
        case "customer.subscription.created":
        case "customer.subscription.updated":
        case "customer.subscription.deleted": {
          const sub = event.data;
          // Deliveries can arrive out of order: skip an event older than the one you saved.
          const saved = await getSubscription(sub.metadata.user_id); // your own store
          if (saved && Date.parse(saved.updatedAt) > Date.parse(event.created_at)) break;
          await saveSubscription({
            // your own store
            userId: sub.metadata.user_id,
            subscriptionId: sub.subscription_id,
            customerId: sub.customer.customer_id,
            productId: sub.product_id,
            status: sub.status,
            currentPeriodEnd: sub.current_period_end,
            cancelAtPeriodEnd: sub.cancel_at_period_end,
            updatedAt: event.created_at,
          });
          break;
        }
        case "invoice.payment_failed":
          await showUpdateCardBanner(event.data.customer.customer_id); // optional
          break;
      }

      await markProcessed(event.id);
      return new Response("OK");
    }
    ```

    Then decide access from the saved status, everywhere in your app:

    ```js lib/billing.js theme={"dark"}
    export function hasAccess(subscription) {
      if (!subscription) return false;
      // past_due: a renewal failed and Bachs is retrying. Keeping access
      // during recovery is common; return false here if you prefer.
      return ["trialing", "active", "past_due"].includes(subscription.status);
    }
    ```

    Each subscription event carries the whole subscription, so save its full state instead of applying changes one by one. Events are not guaranteed to arrive in order, so keep each event's `created_at` with the record and skip an event that is older than the one you saved. If you ever need to be certain of the current state, read it with [Retrieve a subscription](/api-reference/subscriptions/get-subscription).

    If saving fails, let the route return a `5xx` so Bachs retries the delivery. A `4xx` response other than `408` or `429` is not retried, so use `400` only for a signature that does not match.

    <Warning>
      Never give access from the `success_url` redirect. The customer can close the tab before it loads, and anyone can open the URL. Your success page should read the user's subscription from your own database and show "Activating your plan..." until the webhook has arrived.
    </Warning>
  </Step>

  <Step title="Let customers manage their plan">
    Add a **Manage billing** button that calls a route on your server. The route creates a [portal session](/guides/customer-portal/create-portal-session) for the user's `customer_id` and redirects to its `url`. In the portal, customers see their subscriptions and invoices and can cancel. A customer whose renewal failed can always update their card there. Updating a card at any other time, and switching plans, are off until you turn them on in your portal settings. Create a new session on every click, because sessions are short-lived.

    <CodeGroup>
      ```bash Request theme={"dark"}
      curl -X POST https://sandbox-api.bachs.io/v1/customers/cust_1a2b3c4d5e6f/portal-sessions \
        -H "Authorization: Bearer $BACHS_API_KEY"
      ```

      ```json Response theme={"dark"}
      {
        "id": "psn_9f2c4a7b1d3e",
        "url": "https://portal.bachs.io/s/6Yc0nQpR2vX1sK7fLbA9tE"
      }
      ```
    </CodeGroup>

    ```js app/api/billing/portal/route.js theme={"dark"}
    export async function POST(req) {
      const user = await getCurrentUser(req); // your own auth
      const res = await fetch(
        `${process.env.BACHS_API_URL}/v1/customers/${user.bachsCustomerId}/portal-sessions`,
        { method: "POST", headers: { Authorization: `Bearer ${process.env.BACHS_API_KEY}` } },
      );
      if (!res.ok) return Response.json({ error: "Could not open billing" }, { status: 502 });

      const { url } = await res.json();
      return Response.redirect(url, 303);
    }
    ```

    Whatever the customer changes in the portal reaches you as the same webhooks as before, so the route from the previous step already handles it.
  </Step>

  <Step title="Run the whole loop in the sandbox">
    With your app and `bachs listen` running:

    1. Sign in to your app, click **Subscribe**, and pay with the test card shown on the sandbox checkout page.
    2. Watch the CLI: `customer.subscription.created`, `invoice.paid` and `customer.subscription.updated` arrive, and your route answers `200`.
    3. Return to your app (manually if running locally without checkout redirects). Check that the user now has access and that their `customerId` is saved.
    4. Click **Manage billing** and cancel the plan in the portal. By default the portal cancels at the end of the period: you receive `customer.subscription.updated` with `cancel_at_period_end: true`, and the user keeps access until `current_period_end`.

    <Note>
      `bachs trigger` does not emit subscription events yet. To test this flow, complete a real sandbox checkout as above.
    </Note>
  </Step>
</Steps>

## Webhooks to handle

| Event | What it means | What your app does |
| - | - | - |
| [`customer.subscription.created`](/guides/webhooks/events/customer-subscription-created) | A customer completed a subscription checkout. | Save the subscription and the `customer_id`. Give access. |
| [`customer.subscription.updated`](/guides/webhooks/events/customer-subscription-updated) | Plan change, trial change, scheduled cancellation, card change or status change. | Save the new state. Access follows `status`. |
| [`customer.subscription.deleted`](/guides/webhooks/events/customer-subscription-deleted) | The subscription is `canceled` and will not renew. | Save the state. Remove access. |
| [`invoice.paid`](/guides/webhooks/events/invoice-paid) | A cycle was paid, including a successful retry. | Optional: record the payment or send your own receipt. |
| [`invoice.payment_failed`](/guides/webhooks/events/invoice-payment-failed) | A renewal charge failed. Bachs retries and emails the customer. | Optional: ask the user in your app to update their card. |

A subscription checkout also emits `collection.succeeded` and `checkout.completed`. You don't need them for this build.

## Edge cases

<AccordionGroup>
  <Accordion title="The customer pays and closes the tab">
    The webhook still arrives. That is why access comes from the webhook and your success page reads from your own database.
  </Accordion>

  <Accordion title="The same event arrives twice">
    Delivery is at least once. Store each event's `id` and skip IDs you have already processed. Saving the full subscription state also makes a repeated event harmless.
  </Accordion>

  <Accordion title="Your webhook endpoint was down">
    Bachs retries a failed delivery several times over about 80 minutes, then stops. If your endpoint was down for longer, read the current state of your subscriptions with [List subscriptions](/api-reference/subscriptions/list-subscriptions), or redeliver past events with [`bachs events replay`](/cli/commands#events).
  </Accordion>

  <Accordion title="A renewal payment fails">
    The subscription moves to `past_due` and Bachs retries three times: 1 day after the failure, then 3 days later, then 5 days later. It emails the customer after each failed attempt, and from the second email on, the email includes a link to update their card. If a retry succeeds, the subscription is `active` again. If all retries fail, the subscription is canceled, or marked `unpaid` if you choose that in your subscription settings. See [Payment recovery](/guides/subscriptions/failed-payments).
  </Accordion>

  <Accordion title="The customer cancels">
    A cancellation from the portal, or from [Cancel a subscription](/api-reference/subscriptions/cancel-subscription) with `cancel_at_period_end: true`, keeps the subscription working until `current_period_end`. You receive `customer.subscription.updated` now and `customer.subscription.deleted` when it ends. Send `cancel_at_period_end: true` explicitly when you cancel through the API: without it, the cancellation is immediate. An immediate cancellation sends `customer.subscription.deleted` at once and does not refund automatically. See [Manage subscriptions](/guides/subscriptions/manage#cancel-a-subscription).
  </Accordion>

  <Accordion title="The customer upgrades, downgrades or switches to yearly">
    Customers can switch plans in the portal once you turn on plan switching and list the products they may move to in your portal settings. You can also [change the plan](/guides/subscriptions/manage#change-the-plan) through the API. The price difference is handled for you. See [Proration](/guides/subscriptions/proration).
  </Accordion>

  <Accordion title="You change your prices">
    Existing subscribers keep the amount they signed up with. A new price applies to new subscriptions only. To move an existing subscriber, change their plan.
  </Accordion>

  <Accordion title="A user who already subscribes clicks Subscribe again">
    Check your own records before you create a checkout. If the user already has a subscription with access, send them to the portal instead.
  </Accordion>
</AccordionGroup>

## Go-live checklist

* [ ] Your account is verified. See [Go live](/go-live).
* [ ] You created your products again in production and updated the product IDs. Sandbox and production share nothing.
* [ ] Your server uses an `sk_live_` key and `https://api.bachs.io`.
* [ ] You registered your production webhook endpoint for the five events above and put its signing secret in your environment. See [Set up webhooks](/guides/webhooks/overview).
* [ ] You chose what happens when payment retries run out. See [When recovery is exhausted](/guides/subscriptions/failed-payments#when-recovery-is-exhausted).
* [ ] You configured what customers can change in the portal, including plan switching if you offer it. See [Configuring the portal](/guides/customer-portal/overview#configuring-the-portal).

## Start from working code

The [Next.js SaaS starter](https://github.com/bachsdev/bachs-nextjs-saas) implements this flow with route, webhook, persistence, and access tests. It includes a local development store and demo authentication; replace both before deploying.

The [live demo](/demo) runs this flow end to end on the sandbox: a server route creates checkout sessions for a fixed set of products, the overlay opens them, and webhooks drive fulfilment. See [how it is built](/demo#how-it-is-built).

## Build it with an AI assistant

Use the [subscription workflow in Build with AI](/build/ai#sell-subscriptions). Its prompt asks your assistant to inspect your app, build the checkout and billing flow, and test activation, recovery, and cancellation. It works without installing a skill.

Keep this walkthrough available to your assistant for the requests and examples. If it cannot open the page, use **Copy page** and paste the content alongside the prompt.

## Next steps

* [Subscriptions](/guides/subscriptions/overview): statuses and how renewals work.
* [Offer a free trial](/guides/subscriptions/trials): add a trial before the first charge.
* [Manage subscriptions](/guides/subscriptions/manage): change plans, update metadata and cancel through the API.
* [Customer portal](/guides/customer-portal/overview): what customers can do and how to configure it.
* [Charge in any currency](/guides/checkout/any-currency-checkout): sell one-time products in your customers' currencies.
* [Build a platform for businesses](/build/use-cases/saas-platform): let your own customers take payments through you with Connect.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.