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

# Free credits and paid top-ups

> Build a monthly free allowance, meter usage, and sell a one-time credit pack.

This example gives a chat customer **10 free messages each month** and offers **100 more messages for \$10**. One message costs one credit. Work in **Sandbox** with a `ledger_test_` key, and keep that key on your server. The cURL path works with the public API today. The Python and TypeScript examples use [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.

Ledger does not assign the free plan when a customer is created. Your application explicitly attaches it. A credit pack adds to the customer's balance without replacing the free plan. Run the steps in order; later SDK snippets reuse the `ledger` client initialized in step 1.

## 1. Create the three catalog items

Create a feature for metering, a free monthly plan, and a one-time `credit_pack`. Choose IDs unique to your workspace. These are independent creation calls; none replaces the rest of the catalog.

```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/features" \
  -H "Authorization: Bearer $LEDGER_API_KEY" -H "Content-Type: application/json" \
  --data '{"id":"messages","name":"Chat messages","creditCost":1}'

curl --fail-with-body -X POST "$LEDGER_API_URL/v1/plans" \
  -H "Authorization: Bearer $LEDGER_API_KEY" -H "Content-Type: application/json" \
  --data '{"id":"free_messages","name":"Free messages","monthlyCredits":10,"monthlyPriceCents":0}'

curl --fail-with-body -X POST "$LEDGER_API_URL/v1/add-ons" \
  -H "Authorization: Bearer $LEDGER_API_KEY" -H "Content-Type: application/json" \
  --data '{"id":"messages_100","name":"100 more messages","type":"credit_pack","creditAmount":100,"priceCents":1000}'
```

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

ledger = Ledger(api_key=os.environ["LEDGER_API_KEY"])
ledger.create_feature(id="messages", name="Chat messages", credit_cost=1)
ledger.create_plan(id="free_messages", name="Free messages", monthly_credits=10, monthly_price_cents=0)
ledger.create_add_on(id="messages_100", name="100 more messages", credit_amount=100, price_cents=1000)
```

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

const ledger = new Ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" });
await ledger.createFeature({ id: "messages", name: "Chat messages", creditCost: 1 });
await ledger.createPlan({ id: "free_messages", name: "Free messages", monthlyCredits: 10, monthlyPriceCents: 0 });
await ledger.createAddOn({ id: "messages_100", name: "100 more messages", type: "credit_pack", creditAmount: 100, priceCents: 1000 });
```

The plan's zero price means it needs no Stripe Checkout. The pack costs `1000` cents, or \$10. A repeated create with the same active ID returns `409 already_exists`; use the [individual update endpoint](https://docs.integrateledger.com/api-reference/plans/update.md) to change an existing item.

## 2. Create a customer and attach the free plan

Use the stable ID from your application's account system. Customer creation is safe to repeat, but does not grant credits. The separate attach call activates the free plan and grants 10 credits immediately.

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

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":"chat_user_123","planId":"free_messages"}'
```

```python Python SDK source theme={null}
ledger.create_or_get_customer(customer_id="chat_user_123")
free_plan = ledger.attach(customer_id="chat_user_123", plan_id="free_messages")
assert free_plan["status"] == "active"
```

```typescript TypeScript SDK source theme={null}
await ledger.createOrGetCustomer({ customerId: "chat_user_123" });
const freePlan = await ledger.attach({ customerId: "chat_user_123", planId: "free_messages" });
if (!("status" in freePlan && freePlan.status === "active")) throw new Error("Free plan was not activated");
```

The free-plan attach response contains `subscriptionId` and `status: "active"`, with no Checkout URL. Attaching a different free plan to this customer returns a conflict; a paid plan can instead start Checkout while the free plan remains active until verified payment. The monthly free allowance renews when Ledger next reads that customer's balance after a period ends.

## 3. Check access and record completed work

Check before an action. A check reports the current balance but **does not reserve credits**. After the action succeeds, track it with one stable request ID for that action. Reuse that ID only to retry the same action after a lost response.

```bash cURL theme={null}
curl --fail-with-body -X POST "$LEDGER_API_URL/v1/check" \
  -H "Authorization: Bearer $LEDGER_API_KEY" -H "Content-Type: application/json" \
  --data '{"customerId":"chat_user_123","featureId":"messages"}'

# After message_001 succeeds in your app:
curl --fail-with-body -X POST "$LEDGER_API_URL/v1/track" \
  -H "Authorization: Bearer $LEDGER_API_KEY" -H "Content-Type: application/json" \
  --data '{"customerId":"chat_user_123","featureId":"messages","request_id":"message_001"}'
```

```python Python SDK source theme={null}
from uuid import uuid4

decision = ledger.check_credits(customer_id="chat_user_123", feature_id="messages")
if decision["allowed"]:
    request_id = str(uuid4())  # Persist this ID with the app action for retries.
    # Run the message action, then record successful use.
    result = ledger.track_usage(customer_id="chat_user_123", feature_id="messages", request_id=request_id)
    print(result["availableCredits"])
```

```typescript TypeScript SDK source theme={null}
const decision = await ledger.checkCredits({ customerId: "chat_user_123", featureId: "messages" });
if (decision.allowed) {
  const requestId = crypto.randomUUID(); // Persist with the app action for retries.
  // Run the message action, then record successful use.
  const result = await ledger.trackUsage({ customerId: "chat_user_123", featureId: "messages", requestId });
  console.log(result.availableCredits);
}
```

With a fresh customer, check returns `allowed: true`, `availableCredits: 10`, and `creditCost: 1`. After one track, the balance is 9. Repeating that track with the same customer, feature, and request ID returns `idempotent: true` and still leaves 9 credits. A different feature with the same request ID returns `409 request_id_conflict`.

For a sandbox exhaustion test, record nine more **simulated completed actions**, each with a different request ID. Use a fresh customer ID when repeating this exercise; a reused request ID remains idempotent.

```bash cURL theme={null}
for n in 002 003 004 005 006 007 008 009 010; do
  curl --fail-with-body -X POST "$LEDGER_API_URL/v1/track" \
    -H "Authorization: Bearer $LEDGER_API_KEY" -H "Content-Type: application/json" \
    --data "{\"customerId\":\"chat_user_123\",\"featureId\":\"messages\",\"request_id\":\"message_$n\"}"
done
```

```python Python SDK source theme={null}
for n in range(2, 11):
    ledger.track_usage(customer_id="chat_user_123", feature_id="messages", request_id=f"message_{n:03d}")
```

```typescript TypeScript SDK source theme={null}
for (let n = 2; n <= 10; n++) {
  await ledger.trackUsage({ customerId: "chat_user_123", featureId: "messages", requestId: `message_${String(n).padStart(3, "0")}` });
}
```

The next check returns HTTP 200 with `allowed: false`, `availableCredits: 0`, and `creditCost: 1`. A new track attempt returns `409 insufficient_credits`; present the top-up option instead of treating that as a successful charge. A check can be allowed and a later track can still return 409 if another request spent the credits in between. Ledger cannot make an external action and its usage record one atomic transaction.

## 4. Sell a credit pack through Stripe test Checkout

In **Developer → Stripe**, connect a Stripe test key. In **Developer → Checkout**, save valid success and cancel URLs. The [billing walkthrough](https://docs.integrateledger.com/sandbox-test.md) covers the test payment and webhook checks. Call attach with `addOnId`, then send the returned URL to this customer.

```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":"chat_user_123","addOnId":"messages_100"}'
```

```python Python SDK source theme={null}
purchase = ledger.attach(customer_id="chat_user_123", add_on_id="messages_100")
print(purchase["checkoutUrl"])  # Send this URL to your customer.
```

```typescript TypeScript SDK source theme={null}
const purchase = await ledger.attach({ customerId: "chat_user_123", addOnId: "messages_100" });
if (!("checkoutUrl" in purchase)) throw new Error("Checkout URL was not returned");
console.log(purchase.checkoutUrl); // Send this URL to your customer.
```

The response includes `addOnPurchaseId` and `checkoutUrl`. Open the hosted Stripe page and complete the **test-mode** \$10 payment. The browser's success redirect is not payment proof: Ledger adds 100 credits only after its signed paid Checkout webhook is processed. A pending purchase grants no credits. Retrying the same attach while its Stripe Checkout session is open returns the existing link. If Stripe has expired that session, Ledger closes the stale attempt and creates a new purchase and link on retry.

## 5. Show the updated balance

Read the customer from your backend after payment confirmation. If you used all ten free credits in the sandbox test, the balance becomes **100**. If you tracked only `message_001`, it becomes **109**. Ledger spends monthly plan credits before purchased pack credits.

```bash cURL theme={null}
curl --fail-with-body --get "$LEDGER_API_URL/v1/customers" \
  -H "Authorization: Bearer $LEDGER_API_KEY" \
  --data-urlencode 'customerId=chat_user_123'
```

```python Python SDK source theme={null}
customer = ledger.get_customer(customer_id="chat_user_123")
print(customer["availableCredits"])
```

```typescript TypeScript SDK source theme={null}
const customer = await ledger.getCustomer({ customerId: "chat_user_123" });
console.log(customer.availableCredits);
```

The dashboard customer page also shows the active free plan, paid pack, recent usage, and credits by source. To show this in your own application, follow [Build a customer billing page](https://docs.integrateledger.com/build-billing-page.md). See [Credits and usage](https://docs.integrateledger.com/credits.md) for concurrency and error handling.


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