> ## 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.

# Sell digital products

> Sell ebooks, templates, courses or software at a fixed price or pay what you want, and release the download only when Bachs confirms the payment.

You sell something people download: an ebook, a template pack, a course or a software licence. In this guide you'll create your products, send a buyer to checkout without asking them to sign up, and release the download only after Bachs confirms the payment. By the end you'll have a store that charges a fixed price or lets the buyer name their price, and that delivers each order exactly once.

## How it fits together

```mermaid theme={"dark"}
sequenceDiagram
    participant C as Buyer
    participant A as Your store
    participant B as Bachs
    C->>A: Clicks "Buy"
    A->>A: Save the order and an Idempotency-Key
    A->>B: Create a checkout (reference = order ID)
    B-->>A: checkout_url
    A-->>C: Redirect to checkout_url
    C->>B: Enters email, pays
    B->>A: Webhook: checkout.completed
    A->>B: Retrieve the checkout
    B-->>A: status completed, amount, currency
    A->>A: Match to the order, mark it paid
    C->>A: Opens the order page
    A-->>C: Download link
```

Your store owns the orders and the files. Bachs owns the payment. The store releases a file only when Bachs reports the checkout as completed, for the amount the order expects.

## What you'll use

| Object | Its job in this build | Reference |
| - | - | - |
| Product | One thing you sell, at a fixed price or pay what you want. | [The product object](/api-reference/products/object) |
| Checkout session | The hosted page where the buyer enters their email and pays. Its `reference` is your order ID. | [The checkout session object](/api-reference/checkout-sessions/object) |
| Webhook endpoint | The route on your server that Bachs tells when a checkout completes. | [Set up webhooks](/guides/webhooks/overview) |

## Before you start

* A **sandbox API key** (`sk_sandbox_...`) with `products:write` and `payments:write`. See [Authentication](/authentication) and [Permissions](/api-reference/permissions).
* The **Bachs CLI**, to forward webhooks to your machine. See [Install the CLI](/cli/overview#install).
* A server-side app. The examples use Node.js with the [official Bachs SDK](https://github.com/bachsdev/bachs-node); every call is also a plain API request.

Every request goes to `https://sandbox-api.bachs.io`, so nothing moves real money while you build.

## Steps

<Steps>
  <Step title="Create your products">
    Create one product for each thing you sell. A fixed-price product needs an `amount`. A pay-what-you-want product uses `price_type: "custom"`, with a `minimum_amount` and a `preset_amount` that the checkout shows first.

    <CodeGroup>
      ```bash Fixed price theme={"dark"}
      curl -X POST https://sandbox-api.bachs.io/v1/products \
        -H "Authorization: Bearer $BACHS_API_KEY" \
        -H "Idempotency-Key: create-field-guide" \
        -H "Content-Type: application/json" \
        -d '{
          "name": "The Field Guide",
          "price": { "currency": "USD", "amount": "19.00" }
        }'
      ```

      ```bash Pay what you want theme={"dark"}
      curl -X POST https://sandbox-api.bachs.io/v1/products \
        -H "Authorization: Bearer $BACHS_API_KEY" \
        -H "Idempotency-Key: create-template-pack" \
        -H "Content-Type: application/json" \
        -d '{
          "name": "The Template Pack",
          "price": {
            "currency": "USD",
            "price_type": "custom",
            "minimum_amount": "5.00",
            "preset_amount": "10.00"
          }
        }'
      ```
    </CodeGroup>

    Keep each product ID, and keep its price or minimum in your own catalog too. Your store checks the amount paid against it before it delivers anything. See [Products](/guides/products/overview).
  </Step>

  <Step title="Save the order, then create the checkout">
    When a buyer clicks **Buy**, save an order first, with its own ID and an Idempotency-Key. Then create the checkout with the order ID as its `reference`. The browser sends only which product it wants; your server chooses the product ID.

    Leave out `customer`. The checkout page asks for the buyer's email, so nobody needs an account with your store.

    ```ts app/api/checkout/route.ts theme={"dark"}
    const order = await saveOrder({ product: "guide", productId, idempotencyKey: randomUUID() });

    const checkout = await bachs.checkoutSessions.create(
      {
        product_cart: [{ product_id: productId, quantity: 1 }],
        reference: order.id,
        metadata: { order_id: order.id },
        success_url: "https://store.example.com/orders/" + order.id, // must be public
      },
      { idempotencyKey: order.idempotencyKey },
    );

    await saveCheckout(order.id, checkout.checkout_id);
    return Response.json({ checkout_url: checkout.checkout_url });
    ```

    A `reference` is unique for good on your account, even after its checkout expires, so one order has exactly one checkout. If the request times out, keep the order and its key: if the buyer paid, the webhook still names the order. See [Idempotency](/guides/idempotency).

    <Note>
      Bachs refuses `localhost` and private network addresses for `success_url` and `cancel_url`, in the sandbox too. While you build on your own machine, leave them out and go back to your store yourself after paying.
    </Note>
  </Step>

  <Step title="Forward webhooks to your machine">
    ```bash Terminal theme={"dark"}
    bachs listen --forward-to localhost:3000/api/webhooks/bachs \
      --events checkout.completed,collection.succeeded,checkout.expired
    ```

    Put the signing secret it prints (`whsec_...`) in your environment as `BACHS_WEBHOOK_SECRET`. See [Test webhooks locally](/developer-portal/local-testing).
  </Step>

  <Step title="Deliver from the webhook, after checking with Bachs">
    `checkout.completed` is sent once a checkout is paid, and `collection.succeeded` once its payment succeeds. Either can arrive first, and either can arrive more than once. Handle both the same way, and deliver each order once:

    1. Verify the signature on the raw body.
    2. Find the order from `data.reference`, or from `data.checkout_id`.
    3. If the order is already paid, stop.
    4. Retrieve the checkout from Bachs. Continue only if its `status` is `completed`. If it isn't yet, answer `503` so Bachs sends the event again later.
    5. Check that the checkout's `currency` and `amount` match the order: the exact price for a fixed-price product, at least the minimum for pay what you want. If they don't, hold the order for review instead of delivering it.
    6. Mark the order paid and record the event ID, in one database transaction.

    ```ts app/api/webhooks/bachs/route.ts theme={"dark"}
    import { webhooks, BachsWebhookError } from "@bachs/sdk";

    export async function POST(req: Request) {
      let event;
      try {
        event = webhooks.constructEvent(new Uint8Array(await req.arrayBuffer()), req.headers, process.env.BACHS_WEBHOOK_SECRET!);
      } catch (err) {
        if (err instanceof BachsWebhookError) return new Response("Invalid webhook", { status: 400 });
        throw err;
      }
      if (!["checkout.completed", "collection.succeeded"].includes(event.type)) return new Response("OK");

      try {
        const order = await findOrder(event.data.reference, event.data.checkout_id); // your own store
        if (!order || order.paid) return new Response("OK");

        const checkout = await bachs.checkoutSessions.get(String(event.data.checkout_id));
        if (checkout.status !== "completed") return new Response("Not completed yet", { status: 503 });
        if (!amountMatches(order, checkout.amount, checkout.currency)) {
          await holdForReview(order.id, event.id); // do not deliver
          return new Response("OK");
        }
        await markPaid(order.id, event.id, checkout.customer_details?.email); // one transaction
        return new Response("OK");
      } catch {
        return new Response("Could not handle event", { status: 500 }); // Bachs retries 5xx
      }
    }
    ```

    The checkout's `amount` is the total in the product's currency. For pay what you want, it is the price the buyer chose. Compare amounts as whole minor units, not floating-point numbers.

    <Warning>
      Never deliver from the `success_url` page. The buyer can open it without paying. The order page should only read the order your webhook saved, and show the download once it is paid.
    </Warning>
  </Step>

  <Step title="Release the download">
    Give each order a long random secret, and serve the file only to a request that carries the order's secret, for an order that is paid. Keep the files outside your public folder, so the only way to them is through that check.

    Email the buyer a link to their order page after payment. The buyer's email is on the checkout's `customer_details`.
  </Step>

  <Step title="Run it in the sandbox">
    1. Start your store and `bachs listen`.
    2. Buy each product. Pay with any test card number, for example `4242 4242 4242 4242`. For pay what you want, change the price on the checkout page.
    3. Watch `checkout.completed` and `collection.succeeded` arrive. The order turns paid and the download appears.
    4. Pay with `4000 0000 0000 0002` to see a declined card. The checkout stays open and nothing is delivered.

    See [Test payment outcomes](/integrate/sandbox#test-payment-outcomes) for the other test cards.
  </Step>
</Steps>

## Webhooks to handle

| Event | What it means | What your store does |
| - | - | - |
| [`checkout.completed`](/guides/webhooks/events/checkout-completed) | The checkout is paid. Sent once per checkout. | Check with Bachs, then deliver the order. |
| [`collection.succeeded`](/guides/webhooks/events/collection-succeeded) | The checkout's payment succeeded. | The same as `checkout.completed`. Whichever arrives first delivers the order. |
| [`checkout.expired`](/guides/webhooks/events/checkout-expired) | The checkout was not paid in time. | Show the order as expired, but don't close it: a payment can still arrive and complete it. |

You don't need `collection.failed`. It is sent when one payment attempt fails, but the checkout stays open and the buyer can try again.

## Edge cases

<AccordionGroup>
  <Accordion title="The buyer pays and closes the tab">
    The webhook still arrives and the order is marked paid. Email the buyer their order link, so they can get the file without returning to the same browser.
  </Accordion>

  <Accordion title="The same event arrives twice, or both events arrive at once">
    Delivery is at least once and not in order. Record each event ID with the order change in one transaction, and stop when the order is already paid. Whichever event arrives first delivers the order; the other changes nothing.
  </Accordion>

  <Accordion title="A payment arrives after the checkout expired">
    A buyer can still pay a checkout after you receive `checkout.expired`, for example with a bank transfer sent before it expired. The checkout then completes and `checkout.completed` follows. Don't treat expiry as final, and don't reuse the order for a new checkout.
  </Accordion>

  <Accordion title="The buyer pays less than the price">
    A card always charges the full amount. A bank transfer can arrive short. The checkout then does not complete, so no fulfilment event is sent and the order stays unpaid. You can [refund](/guides/refunds) the amount received. For crypto, a short payment sends [`collection.underpaid`](/guides/webhooks/events/collection-underpaid), and the buyer can send the rest.
  </Accordion>

  <Accordion title="Someone tries to pay less than your minimum">
    Bachs refuses a pay-what-you-want price below the product's `minimum_amount`. Your store checks the amount again anyway, and holds any order that does not match for review.
  </Accordion>

  <Accordion title="A buyer wants a refund">
    Refund the payment with [Issue a refund](/guides/refunds), using the `charge_id` from `collection.succeeded` or from the checkout's `charge`. A payment can carry one refund, so decide on a partial refund before you send it. Revoke the download when the refund is paid.
  </Accordion>

  <Accordion title="The request to create the checkout times out">
    The checkout may exist. Keep the order and its Idempotency-Key, and don't create another checkout for it. If the buyer paid, the webhook names the order through its `reference`.
  </Accordion>
</AccordionGroup>

## Go-live checklist

* [ ] Your account is verified. See [Go live](/go-live).
* [ ] You created your products again in production and updated their IDs. Sandbox and production share nothing.
* [ ] Your catalog prices match the products in Bachs.
* [ ] Your server uses an `sk_live_` key and `https://api.bachs.io`.
* [ ] `success_url` points to a public page on your store.
* [ ] You registered your production webhook endpoint for `checkout.completed`, `collection.succeeded` and `checkout.expired`, and put its signing secret in your environment.
* [ ] Orders, event IDs and download secrets are in a real database, not a local file.
* [ ] Buyers receive an email with the link to their order.

## Start from working code

The [Next.js digital products starter](https://github.com/bachsdev/bachs-nextjs-digital-products) is this guide as a working store: a fixed-price product, a pay-what-you-want product, guest checkout, delivery checked against Bachs, and secret download links, with tests for each rule on this page.

## Build it with an AI assistant

The [one-time payment prompt](/build/ai/prompts#accept-a-one-time-payment) builds this flow into your own app.

## Next steps

* [Accept a payment](/guides/checkout/checkout-sessions): every checkout option, including restricting payment methods.
* [Add an overlay checkout](/guides/checkout/overlay-checkout): keep buyers on your page while they pay.
* [Sell in local currencies](/guides/products/local-pricing): show buyers prices in their own currency.
* [Issue a refund](/guides/refunds): refund a purchase in full or in part.


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