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

# Build a customer billing page

> Show a customer's plan, credits, purchases, and renewal controls with Ledger's API.

Build this page in your application with a server-side Ledger API key. Use the stable `customerId` from your account system; verify that it belongs to the signed-in user before calling Ledger. These examples use **Sandbox** and a `ledger_test_` key. cURL works with the public API today. Python and TypeScript examples use the [unpublished Python SDK source](https://docs.integrateledger.com/python-sdk.md) and [unpublished TypeScript SDK source](https://docs.integrateledger.com/sdk.md) from a repository checkout.

Show the current plan, available credits, plan history, credit-pack purchases, and eligible renewal controls. Ledger returns billing state; your application renders it and sends the customer to Stripe when a paid Checkout is needed.

## 1. Load the customer's billing state

Create the customer if needed, then read their balance and the first page of each history. Creation does not assign a plan or grant credits. Run these calls on your backend and return only the fields your signed-in customer needs.

```bash cURL theme={null}
export LEDGER_API_KEY="your-sandbox-key"
export LEDGER_API_URL="https://api.integrateledger.com"

curl --fail-with-body -X POST "$LEDGER_API_URL/v1/customers" \
  -H "Authorization: Bearer $LEDGER_API_KEY" -H "Content-Type: application/json" \
  --data '{"customerId":"user_123"}'

curl --fail-with-body --get "$LEDGER_API_URL/v1/customers" \
  -H "Authorization: Bearer $LEDGER_API_KEY" --data-urlencode 'customerId=user_123'

curl --fail-with-body --get "$LEDGER_API_URL/v1/customers/subscriptions" \
  -H "Authorization: Bearer $LEDGER_API_KEY" \
  --data-urlencode 'customerId=user_123' --data-urlencode 'limit=25'

curl --fail-with-body --get "$LEDGER_API_URL/v1/customers/purchases" \
  -H "Authorization: Bearer $LEDGER_API_KEY" \
  --data-urlencode 'customerId=user_123' --data-urlencode 'limit=25'
```

```python Python SDK source theme={null}
import os
from ledger_sdk import Ledger

ledger = Ledger(api_key=os.environ["LEDGER_API_KEY"])
ledger.create_or_get_customer(customer_id="user_123")
customer = ledger.get_customer(customer_id="user_123")
subscriptions = ledger.list_customer_subscriptions(customer_id="user_123", limit=25)
purchases = ledger.list_customer_purchases(customer_id="user_123", limit=25)
```

```typescript TypeScript SDK source theme={null}
import { Ledger } from "@ledger/sdk";

const ledger = new Ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" });
await ledger.createOrGetCustomer({ customerId: "user_123" });
const customer = await ledger.getCustomer({ customerId: "user_123" });
const subscriptions = await ledger.listCustomerSubscriptions({ customerId: "user_123", limit: 25 });
const purchases = await ledger.listCustomerPurchases({ customerId: "user_123", limit: 25 });
```

`customer.availableCredits` is the current balance across active plan and purchased credit grants. `customer.currentPlan` contains the plan ID, name, status, and period dates, or `null`. It does not contain a numeric subscription ID or renewal settings. An active free plan can remain `currentPlan` while a paid plan is pending Checkout; show the pending offer from `subscriptions.items`. A customer may have pack credits while `currentPlan` is `null`.

Render each subscription's `planName`, `monthlyPriceCents`, `status`, and period dates. Render each purchase's `name`, `status`, and `creditAmount`. A pending pack has `creditAmount: null` and grants no credits; a legacy paid row without a linked grant can also have a null amount. A paid row's amount is what was actually granted, even if the catalog item changes later.

Each history response has `items`, `hasMore`, and `nextCursor`, ordered newest first. To load older rows, repeat that request with its returned numeric `nextCursor`; stop when `hasMore` is false. `limit` defaults to 25 and is capped at 100. Keep subscriptions and purchases on separate cursors.

## 2. Offer a plan or credit pack

Create [plans](https://docs.integrateledger.com/api-reference/plans/create.md) and [credit-pack add-ons](https://docs.integrateledger.com/api-reference/add-ons/create.md) before offering them. Send exactly one of `planId` or `addOnId` with the authenticated customer's ID. A free plan activates immediately; a paid plan or pack returns a hosted Stripe `checkoutUrl`.

### Start plan Checkout

```bash cURL theme={null}
curl --fail-with-body -X POST "$LEDGER_API_URL/v1/billing/attach" \
  -H "Authorization: Bearer $LEDGER_API_KEY" -H "Content-Type: application/json" \
  --data '{"customerId":"user_123","planId":"starter"}'
```

```python Python SDK source theme={null}
plan_checkout = ledger.attach(customer_id="user_123", plan_id="starter")
print(plan_checkout["checkoutUrl"])
```

```typescript TypeScript SDK source theme={null}
const planCheckout = await ledger.attach({ customerId: "user_123", planId: "starter" });
if ("checkoutUrl" in planCheckout) console.log(planCheckout.checkoutUrl);
```

### Sell a credit pack

Use this action when the customer chooses a pack. It creates a separate one-time Checkout.

```bash cURL theme={null}
curl --fail-with-body -X POST "$LEDGER_API_URL/v1/billing/attach" \
  -H "Authorization: Bearer $LEDGER_API_KEY" -H "Content-Type: application/json" \
  --data '{"customerId":"user_123","addOnId":"extra_credits"}'
```

```python Python SDK source theme={null}
pack_checkout = ledger.attach(customer_id="user_123", add_on_id="extra_credits")
print(pack_checkout["checkoutUrl"])
```

```typescript TypeScript SDK source theme={null}
const packCheckout = await ledger.attach({ customerId: "user_123", addOnId: "extra_credits" });
if ("checkoutUrl" in packCheckout) console.log(packCheckout.checkoutUrl);
```

A free-plan attach responds with `subscriptionId` and `status: "active"`, without Checkout. A paid-plan attach responds with `subscriptionId` and `checkoutUrl`; a pack attach responds with `addOnPurchaseId` and `checkoutUrl`. Retrying the same attach while its Stripe session is open returns the existing link. If Stripe expired that session, Ledger closes the attempt and creates a new one on retry. The browser's success redirect is **not payment proof**. Refresh the customer and history after Ledger processes the signed Stripe payment webhook. See [Billing and Checkout](https://docs.integrateledger.com/billing.md).

You can attach a paid plan while a free plan is active. For an active **paid** plan, the supported change is an upgrade to a higher-priced paid plan through [Upgrade a paid plan](https://docs.integrateledger.com/api-reference/billing/upgrade.md), with an `Idempotency-Key`. The response may contain a Stripe `paymentUrl`; the new plan applies only after verified payment. Ledger does not expose a general paid-plan switch or downgrade endpoint.

## 3. Let eligible customers stop or resume renewal

Use a numeric subscription `id` from `subscriptions.items`, not the catalog `planId`. Show **Stop renewal** or **Resume renewal** only when the active paid row has `cancellation?.manageable === true`. If `cancellation` is null, Stripe could not verify a manageable renewal state; do not present null as proof that auto-renewal is on. `scheduledEnd` can reflect a Stripe cancellation date even when `cancelAtPeriodEnd` is false.

```bash cURL theme={null}
# Use the numeric ID returned in the subscriptions list.
curl --fail-with-body -X PUT \
  "$LEDGER_API_URL/v1/customers/subscriptions/42/cancellation?customerId=user_123" \
  -H "Authorization: Bearer $LEDGER_API_KEY" -H "Content-Type: application/json" \
  --data '{"cancelAtPeriodEnd":true}'

# To resume before the paid period ends, send {"cancelAtPeriodEnd":false}.
```

```python Python SDK source theme={null}
active_paid = next(
    (item for item in subscriptions["items"]
     if item["status"] == "active" and item["monthlyPriceCents"] > 0
     and item["cancellation"] and item["cancellation"]["manageable"]),
    None,
)
if active_paid:
    ledger.set_subscription_cancellation(
        customer_id="user_123", subscription_id=active_paid["id"], cancel_at_period_end=True
    )
```

```typescript TypeScript SDK source theme={null}
const activePaid = subscriptions.items.find(
  (item) => item.status === "active" && item.monthlyPriceCents > 0 && item.cancellation?.manageable,
);
if (activePaid) {
  await ledger.setSubscriptionCancellation({
    customerId: "user_123", subscriptionId: activePaid.id,
    body: { cancelAtPeriodEnd: true },
  });
}
```

Send `cancelAtPeriodEnd: false` to resume before the paid period ends. Stopping renewal keeps the current paid period and its remaining plan credits available; purchased packs remain. Stripe state can change between loading the page and pressing the button, so handle a conflict or Stripe error and reload subscriptions. A pending upgrade, pause, schedule, or custom Stripe cancellation date can make renewal unavailable. This endpoint does not cancel an unpaid Checkout link.

## Keep the page current

Refresh the customer and both histories after a webhook-confirmed payment or renewal change. A pending Checkout row is useful for progress, but does not include a Checkout URL; keep the URL returned by `attach` for navigation or retry that same attach to recover an open link. Do not show a completed purchase or new allowance from the browser redirect alone. The [sandbox billing test](https://docs.integrateledger.com/sandbox-test.md) walks through a Stripe test payment and signed webhook verification.

Ledger's dashboard also shows Stripe invoice and credit-pack payment panels for operators. Those panels are not public customer API endpoints, so build your application page from the customer, subscription, and purchase endpoints above.


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