# Build with AI agents Source: https://docs.integrateledger.com/ai Machine-readable docs, the OpenAPI spec, a docs MCP server and copy-paste prompts for integrating Ledger with a coding agent. Everything in these docs is available to agents as well as people. Point your coding agent at the sources below, or paste one of the prompts into it, and it can integrate Ledger the same way this site teaches you to. ## Sources for agents | Source | URL | Use it for | | - | - | - | | Docs index | [`/llms.txt`](https://docs.integrateledger.com/llms.txt) | Every page with a one-line description. Start here. | | Full docs | [`/llms-full.txt`](https://docs.integrateledger.com/llms-full.txt) | Every page in one Markdown file. | | Any page as Markdown | Add `.md` to its URL, for example [`/getting-started/setup.md`](https://docs.integrateledger.com/getting-started/setup.md) | One page, without the site around it. | | OpenAPI spec | [`/openapi.json`](https://docs.integrateledger.com/openapi.json) | Every endpoint, field and response, for codegen and tool definitions. | | Docs MCP server | `https://docs.integrateledger.com/mcp` | Search and read these docs from Claude, Cursor or any MCP client. | Every page also has a menu at the top, next to the title, to copy it as Markdown, open it in Claude or ChatGPT, or connect the MCP server to Cursor or VS Code. ## Connect the docs MCP server ```bash Claude Code theme={"system"} claude mcp add --transport http ledger-docs https://docs.integrateledger.com/mcp ``` ```json Cursor (.cursor/mcp.json) theme={"system"} { "mcpServers": { "ledger-docs": { "url": "https://docs.integrateledger.com/mcp" } } } ``` The server answers questions from these docs. It can't see your workspace or call the Ledger API. ## Integrate Ledger with a prompt Paste this into your coding agent in your app's repository. It plans first, then builds the integration in Sandbox. ```text Prompt wrap theme={"system"} Integrate Ledger, credit billing with docs at https://docs.integrateledger.com, into this app. Read https://docs.integrateledger.com/llms.txt first, then the pages you need as Markdown (append .md to a page URL). The OpenAPI spec is at https://docs.integrateledger.com/openapi.json. 1. Propose a catalog for this app: features (actions and their credit cost), plans (monthly credits and price, with one free plan) and credit packs. Wait for my approval. 2. Use the official SDK for this codebase's language (`integrateledger` on npm or PyPI). Read the key from the LEDGER_API_KEY environment variable on the server. Never send it to the browser. 3. Write the approved catalog to ledger.catalog.json (or ledger.config.ts in a JavaScript project) and publish it with `npx integrateledger-cli push`, using the Sandbox key. 4. Create or get a Ledger customer for each user, with our own user ID as customerId. Attach the free plan on signup. 5. Gate each metered action with POST /v1/check, then call POST /v1/track after it succeeds, with a request_id unique to that action. Handle 409 insufficient_credits from track. 6. Add a billing page: plan, balance, purchase history, plan Checkout and upgrades, a credit pack top-up, and stop or resume renewal. Re-read state from the backend after Checkout; never trust the redirect. 7. Write tests for the metering path, and tell me how to try it with Stripe test cards. ``` ## Ledger skills Three [agent skills](https://agentskills.io) take your agent through onboarding. Start one in your app's repository with: ```text Prompt wrap theme={"system"} Add Ledger billing to my app: docs.integrateledger.com/skill.md ``` | Skill | What it does | Where it comes from | | - | - | - | | `ledger-setup` | Runs onboarding from start to finish: reads your pricing, connects to your Sandbox, then hands over to the other two | [`/skill.md`](https://docs.integrateledger.com/skill.md) | | `ledger-catalog` | Interviews you about your pricing, writes `ledger.catalog.json`, shows the table and diff, and publishes to Sandbox after your yes | `npx integrateledger-cli skills` | | `ledger-integrate` | Adds check and track to one flow in your backend, verifies it in Sandbox, and measures the latency | `npx integrateledger-cli skills` | The CLI writes `ledger-catalog` and `ledger-integrate` to `.agents/skills/` in your repository (`ledger init` does too), so they match the CLI version that runs their commands. Codex, Cursor and other agents read that folder. For Claude Code, link them with `npx skills add ./.agents/skills --agent claude-code --skill '*' -y`. Run `ledger skills` again after upgrading the CLI. The skills work only in Sandbox: they refuse Live keys, never print your key, and publish nothing until you approve the diff. ## Next steps > [card] [Setup](/getting-started/setup) > > The same integration, step by step. > [card] [API reference](/api-reference/introduction) > > Every endpoint, with requests and responses. # API keys and permissions Source: https://docs.integrateledger.com/api-keys Authenticate requests, choose the environment, and limit what each key can do. Every request to the Ledger API carries a secret API key. The key identifies your workspace, chooses the environment, and limits what the request may do. Ledger has no publishable keys: every key is secret and belongs on your server. ## Key types | Prefix | Environment | Example | | - | - | - | | `ledger_test_` | Sandbox | `ledger_test_org_3df0…ff7.Xk2…` | | `ledger_live_` | Live | `ledger_live_org_3df0…ff7.Qp9…` | A key works only in its own environment. A Sandbox key can't read or change Live data, and the reverse. ## Create a key **1. Open API keys** In the dashboard, choose the environment with the switch at the top of the sidebar, then open **Settings › API keys**. **2. Name it after what will use it** Choose **Create key** and name the service or deployment that will hold it, such as "Production backend". Names are unique per environment. **3. Choose its permissions** Leave **Restricted mode** off for full access, or turn it on and choose None, Read or Write for each group below. **4. Copy it once** Ledger shows the key once and stores only a hash of it. Put it in your secret manager or environment, such as `LEDGER_API_KEY`. Each environment holds up to 8 keys. Create a new key before revoking an old one so you can rotate without downtime: existing keys keep working until revoked, and a revoked key stops working immediately. ## Authenticate requests Send the key as a bearer token: ```bash theme={"system"} curl https://api.integrateledger.com/v1/plans \ -H "Authorization: Bearer $LEDGER_API_KEY" ``` A missing, unknown or revoked key returns `401`: ```json Response (401) theme={"system"} { "error": "unauthorized" } ``` ## Permissions A restricted key has a level for each of four groups. **Read** grants the read scope; **Write** grants both. | Group | Read allows | Write also allows | | - | - | - | | **Catalog** | `GET /v1/plans`, `/v1/features`, `/v1/add-ons` and their items | Creating, updating and archiving them; `POST /v1/catalog` | | **Customers** | `GET /v1/customers`, its subscriptions and purchases | `POST` and `DELETE /v1/customers`; overage cap and rate | | **Credits** | `POST /v1/check` | `POST /v1/track` | | **Billing** | (no read level) | `POST /v1/billing/attach`, `/v1/billing/upgrade`; stopping or resuming renewal | A request that the key isn't allowed to make returns the same `401 unauthorized` as an invalid key. ### Suggested keys | Service | Catalog | Customers | Credits | Billing | | - | - | - | - | - | | API server that meters usage | None | None | Write | None | | Billing page backend | Read | Write | None | Write | | Catalog deploy job (CI) | Write | None | None | None | ## Keep keys safe * **Server only.** Never put a key in a browser, mobile app or public repository. Anyone with it can spend your customers' credits. * **One key per service.** If one leaks, you revoke one key, not your whole integration. * **Restrict by default.** Give each service only the groups it calls. * **Rotate on suspicion.** Create a replacement, deploy it, then revoke the old key in **Settings › API keys**. Ledger never logs API keys. Its request logs record the key's ID instead. ## Next steps > [card] [Errors](/errors) > > The error shape and every code. > [card] [Sandbox and Live](/concepts/environments) > > What each environment holds. # Archive a credit pack Source: https://docs.integrateledger.com/api-reference/add-ons/archive openapi.json DELETE /v1/add-ons/{id} Stop selling a credit pack while keeping its purchase history. Hides the pack from new purchases. A Checkout already open can still complete, and past purchases and their credits stay. An unknown or already archived ID returns `404 not_found`. Creating a with the same ID later brings it back. Needs the **Catalog** permission at Write. ```bash cURL theme={"system"} curl -X DELETE "https://api.integrateledger.com/v1/add-ons/credits_500" \ -H "Authorization: Bearer $LEDGER_API_KEY" ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const result = await client.archiveAddOn({ id: "credits_500" }); console.log(result); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) result = client.archive_add_on("credits_500") print(result) ``` ```json 200 theme={"system"} { "archived": true } ``` ```json 404 theme={"system"} { "error": "not_found" } ``` # Create a credit pack Source: https://docs.integrateledger.com/api-reference/add-ons/create openapi.json POST /v1/add-ons Create a one-time purchase of a fixed number of credits. Creates a credit pack, an add-on with `type: "credit_pack"`. Customers buy it once through Stripe Checkout and get `creditAmount` credits, which never expire, for `priceCents` (USD cents, above 0). See [Sell credit packs](/configure-pricing/credit-packs). * `id`: 1–100 letters, numbers, `_` or `-`. It can't be changed. * `name`: 1–120 characters, shown on Stripe Checkout and the invoice. Packs aren't synced to Stripe ahead of time; Ledger prices each Checkout when it opens. An active item with the same `id` returns `409 already_exists`. Needs the **Catalog** permission at Write. ```bash cURL theme={"system"} curl https://api.integrateledger.com/v1/add-ons \ -H "Authorization: Bearer $LEDGER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "id": "credits_500", "name": "Top-up", "type": "credit_pack", "creditAmount": 500, "priceCents": 1000 }' ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const pack = await client.createAddOn({ id: "credits_500", name: "Top-up", type: "credit_pack", creditAmount: 500, priceCents: 1000, }); console.log(pack); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) pack = client.create_add_on( id="credits_500", name="Top-up", credit_amount=500, price_cents=1000 ) print(pack) ``` ```json 201 theme={"system"} { "item": { "id": "credits_500", "name": "Top-up", "type": "credit_pack", "creditAmount": 500, "priceCents": 1000 }, "changed": true } ``` ```json 409 theme={"system"} { "error": "already_exists" } ``` # Get a credit pack Source: https://docs.integrateledger.com/api-reference/add-ons/get openapi.json GET /v1/add-ons/{id} Read one active credit pack by its ID. Returns the active credit pack with this `id`. An unknown or archived ID returns `404 not_found`. Needs the **Catalog** permission at Read. ```bash cURL theme={"system"} curl https://api.integrateledger.com/v1/add-ons/credits_500 \ -H "Authorization: Bearer $LEDGER_API_KEY" ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const creditpack = await client.getAddOn({ id: "credits_500" }); console.log(creditpack); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) add_on = client.get_add_on("credits_500") print(add_on) ``` ```json 200 theme={"system"} { "id": "credits_500", "name": "Top-up", "type": "credit_pack", "creditAmount": 500, "priceCents": 1000 } ``` ```json 404 theme={"system"} { "error": "not_found" } ``` # List credit packs Source: https://docs.integrateledger.com/api-reference/add-ons/list openapi.json GET /v1/add-ons List the active credit packs in the key's environment. Returns every active credit pack in the key's environment. Archived credit packs aren't included. Needs the **Catalog** permission at Read. ```bash cURL theme={"system"} curl https://api.integrateledger.com/v1/add-ons \ -H "Authorization: Bearer $LEDGER_API_KEY" ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const { items } = await client.listAddOns(); console.log(items); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) items = client.list_add_ons()["items"] print(items) ``` ```json 200 theme={"system"} { "items": [ { "id": "credits_500", "name": "Top-up", "type": "credit_pack", "creditAmount": 500, "priceCents": 1000 } ] } ``` # Update a credit pack Source: https://docs.integrateledger.com/api-reference/add-ons/update openapi.json POST /v1/add-ons/{id} Change a credit pack's name, credits or price for future purchases. Changes the pack for purchases from now on. While a customer has this pack's Checkout open, changing `creditAmount` or `priceCents` returns `409 purchase_pending`; renaming is always allowed. Send the complete editable body; sending the current values again returns `"changed": false`. An unknown or archived ID returns `404 not_found`. Needs the **Catalog** permission at Write. ```bash cURL theme={"system"} curl https://api.integrateledger.com/v1/add-ons/credits_500 \ -H "Authorization: Bearer $LEDGER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Top-up", "type": "credit_pack", "creditAmount": 600, "priceCents": 1000 }' ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const pack = await client.updateAddOn({ id: "credits_500", body: { name: "Top-up", type: "credit_pack", creditAmount: 600, priceCents: 1000, }, }); console.log(pack); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) pack = client.update_add_on( "credits_500", name="Top-up", credit_amount=600, price_cents=1000 ) print(pack) ``` ```json 200 theme={"system"} { "item": { "id": "credits_500", "name": "Top-up", "type": "credit_pack", "creditAmount": 600, "priceCents": 1000 }, "changed": true } ``` # Attach a plan or credit pack Source: https://docs.integrateledger.com/api-reference/billing/attach openapi.json POST /v1/billing/attach Start a free plan, charge a saved card, or open Stripe Checkout for a paid plan or credit pack. Gives a customer a plan or sells them a credit pack. Send `customerId` with exactly one of `planId` or `addOnId`. | You send | Response | | - | - | | A free plan | `{ subscriptionId, status: "active" }`. Credits are available at once. | | A paid plan, customer has a saved card | `{ subscriptionId, status: "active" }` after Stripe confirms the charge, or `402 payment_failed` if the card is declined. A card that needs 3-D Secure isn't charged; you get `{ checkoutUrl, subscriptionId }` and the customer authenticates on Checkout. | | A paid plan, no saved card | `{ checkoutUrl, subscriptionId }`. Credits arrive with Stripe's signed payment webhook. | | A credit pack | `{ checkoutUrl, addOnPurchaseId }`. Credits arrive with Stripe's signed payment webhook. | Repeating the same attach while its Checkout is open returns the same `checkoutUrl`. A customer with an active or pending plan gets `409 subscription_exists`; use [upgrade](/api-reference/billing/upgrade) to move a paying customer up. Paid attaches need a healthy Stripe webhook (`502 stripe_webhook_unhealthy` otherwise), and in Live, live charges enabled for the workspace (`503 live_charges_disabled` otherwise). See [Sell a plan with Checkout](/getting-started/display-billing). Needs the **Billing** permission. ```bash cURL theme={"system"} curl https://api.integrateledger.com/v1/billing/attach \ -H "Authorization: Bearer $LEDGER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"customerId": "user_123", "planId": "pro"}' ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const result = await client.attach({ customerId: "user_123", planId: "pro" }); if ("checkoutUrl" in result) { console.log(result.checkoutUrl); // Redirect your customer here. } ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) result = client.attach(customer_id="user_123", plan_id="pro") if "checkoutUrl" in result: print(result["checkoutUrl"]) # Redirect your customer here. ``` ```json 200 theme={"system"} { "checkoutUrl": "https://checkout.stripe.com/c/pay/cs_test_a1Y8KZRAdipi5tQA...", "subscriptionId": 2 } ``` ```json 200 (free plan) theme={"system"} { "subscriptionId": 1, "status": "active" } ``` ```json 409 theme={"system"} { "error": "subscription_exists" } ``` # Undo a scheduled downgrade Source: https://docs.integrateledger.com/api-reference/billing/cancel-downgrade openapi.json POST /v1/billing/downgrade/{downgradeId}/cancel Keep the customer on its current plan. Undoes a scheduled downgrade: Ledger releases its Stripe schedule, so the subscription renews on its current plan, and the downgrade is `closed`. Undoing one that's already closed returns it again. A downgrade that has already applied returns `409 downgrade_applied`; upgrade instead. A schedule released or canceled in Stripe's Dashboard closes the downgrade the same way. # Downgrade a paid plan Source: https://docs.integrateledger.com/api-reference/billing/downgrade openapi.json POST /v1/billing/downgrade Move a customer to a cheaper paid plan at the end of the period, or now. Moves a customer with an active Stripe-backed monthly subscription to a **cheaper paid plan**. Send `customerId`, the target `planId` and a client-generated `Idempotency-Key` header. Moving to a free plan isn't a downgrade: cancel at period end instead. `timing` says when the cheaper plan starts: * **`period_end`** (the default): the customer keeps the current plan and its credits until the period ends. Ledger puts a Stripe subscription schedule on the subscription; the renewal bills the cheaper price, Ledger grants its credits and then releases the schedule. Overage from the ending period bills on that renewal at the old plan's rate. The response is `scheduled`, with `effectiveAt` set to the renewal. * **`immediately`**: the cheaper plan starts now and the renewal date stays. The old plan's remaining credits end (`oldPlanCredits: "keep"` leaves them), and the new plan's credits are prorated to the rest of the period, rounded down. With `refund: "prorated"`, Ledger refunds (old price − new price) × the unused share of the period to the card, plus the tax charged on that amount, as a Stripe credit note on the invoice that paid for it; `refundAmountCents` (before tax) and `creditNoteId` report it. The old plan's unbilled overage bills at its own rate on the next renewal (`overageOnPlanChange: "carry_to_renewal"`) or on an invoice now (`"bill_now"`). The response is `applied`. `refund` and `overageOnPlanChange: "bill_now"` only apply now: with `period_end` they return `400 refund_not_available` or `400 overage_option_not_available`. Options you leave out come from the workspace's defaults, then Ledger's. One change at a time: a pending upgrade returns `409 upgrade_pending`, a scheduled cancellation `409 cancellation_scheduled`, and another downgrade `409 downgrade_scheduled`. While a downgrade is scheduled, upgrades and cancellation return `409 downgrade_scheduled`; [undo it](/api-reference/billing/cancel-downgrade) first. A target that isn't a cheaper, synced paid plan returns `400 ineligible_downgrade`. Reuse the same idempotency key to retry: it returns the same downgrade and never changes Stripe or refunds twice. The same key with a different plan or option returns `409 idempotency_key_mismatch`. If a Stripe request fails (`502 stripe_downgrade_failed`), retry with the **same** key. # Import a Stripe subscription Source: https://docs.integrateledger.com/api-reference/billing/import openapi.json POST /v1/billing/import Move a customer's existing Stripe subscription onto a Ledger plan, with no charge. Imports a subscription you already bill in Stripe onto a paid Ledger plan. Link the customer's Stripe customer first with [Link a Stripe customer](/api-reference/customers/stripe-customer). The plan's monthly price must equal the subscription's. The full walkthrough is [Migrate from Stripe Billing](/migrate-from-stripe). Ledger moves the subscription's item to the plan's own Stripe Price, with no proration: nothing is charged now, and the renewal date and amount stay the same. It then grants the plan's credits for Stripe's current period and treats the subscription like one it sold: each paid renewal grants the next period's credits, and upgrades and cancellations work through Ledger. Send `startingBalance` to carry over the credits the customer has left this period; it defaults to the plan's monthly credits. An active subscription, one set to cancel at the period end, and a trialing one can be imported; a trial becomes the plan's first period. Ledger refuses, without changing anything: * `400 subscription_past_due`, `subscription_incomplete`, `subscription_ended`, `subscription_not_billable` or `latest_invoice_unpaid`: collect or void the open invoice in Stripe first, or don't import an ended subscription. * `400 unsupported_subscription` with a `reason`: something Ledger can't bill yet: `multiple_items`, `quantity`, `price_mismatch`, `not_monthly`, `metered_price`, `send_invoice`, `discount`, `schedule`, `pending_update`, `pause_collection`, `billing_thresholds`, `pending_invoice_items` or `customer_balance`. * `409 stripe_customer_not_linked` or `stripe_customer_mismatch`: link the subscription's own Stripe customer first. * `409 multiple_subscriptions`: the Stripe customer has another subscription that isn't canceled. * `409 subscription_exists`: the customer already has a paid plan or a pending Checkout. An active free plan is replaced by the import. Ledger checks all of this before it changes Stripe. If something changes in Ledger while it switches the Price (a Checkout starts for the same customer, say), the refusal has a `message`: the subscription is already on the plan's Price, with nothing charged, but not imported. Fix what it names and import again. Importing the same subscription onto the same plan again returns it with `imported: false` and changes nothing; onto another plan, it returns `409 subscription_already_imported`. Like attach, the import needs a healthy signed webhook endpoint (`502 stripe_not_configured` or `stripe_webhook_unhealthy`), because the credits for every later renewal arrive by webhook. It works in Live and doesn't need Ledger's live-charge approval, since it starts no charge. The key needs the `billing:write` permission. # Get a scheduled downgrade Source: https://docs.integrateledger.com/api-reference/billing/scheduled-downgrade openapi.json GET /v1/billing/downgrade Read the customer's downgrade that's waiting for the period end. Returns `{ "scheduled": … }`: the customer's downgrade waiting for its period end, or `null`. Once the renewal applies it, or it's undone, there's none. # Upgrade a paid plan Source: https://docs.integrateledger.com/api-reference/billing/upgrade openapi.json POST /v1/billing/upgrade Move an active paid subscription to a more expensive plan, charging the new price. Moves the customer's active paid subscription to a plan with a higher monthly price. Stripe charges the full new price and starts a new monthly period; the new plan's credits arrive when the payment is confirmed, and the old plan's unspent credits stay until its period would have ended. Send an `Idempotency-Key` header (printable ASCII, up to 255 characters) that identifies this one intended upgrade, and reuse it on every retry. | `status` | Meaning | | - | - | | `applied` | Charged; the new plan is active. | | `pending_payment` | Send the customer to `paymentUrl`, Stripe's hosted invoice page. | | `processing` | Still reconciling. Retry with the same key. | | `closed` | Payment failed or expired. The old plan stays. | The target must be a pricier paid plan synced to Stripe; for a cheaper one, [downgrade](/api-reference/billing/downgrade) instead. A Stripe subscription with discounts can't be upgraded (`400 ineligible_stripe_subscription`); one with tax can. See [Upgrades and downgrades](/configure-pricing/upgrades-and-downgrades). Needs the **Billing** permission. ```bash cURL theme={"system"} curl https://api.integrateledger.com/v1/billing/upgrade \ -H "Authorization: Bearer $LEDGER_API_KEY" \ -H "Idempotency-Key: upgrade_user_123_scale" \ -H "Content-Type: application/json" \ -d '{"customerId": "user_123", "planId": "scale"}' ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const upgrade = await client.upgradePaidPlan({ idempotencyKey: "upgrade_user_123_scale", body: { customerId: "user_123", planId: "scale" }, }); console.log(upgrade); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) upgrade = client.upgrade_paid_plan( customer_id="user_123", plan_id="scale", idempotency_key="upgrade_user_123_scale", ) print(upgrade) ``` ```json 200 theme={"system"} { "upgradeId": 2, "status": "applied", "paymentUrl": null } ``` ```json 400 theme={"system"} { "error": "ineligible_subscription" } ``` # Publish a catalog Source: https://docs.integrateledger.com/api-reference/catalog/publish openapi.json POST /v1/catalog Replace an environment's whole catalog in one request, from a config file. Makes the environment's catalog match the request: items are created, updated or archived so that exactly these plans, features and credit packs are active. Publishing the same catalog again returns `"changed": false`. * `environment` must match the key's, or the request returns `400 environment_mismatch`. Use `"live"` with a Live key. * At least one plan and one feature are required. `currency` is `usd`, `schemaVersion` is `1`, and `policy.onInsufficientCredits` is `block`. * Invalid input returns `400 invalid_catalog` with `issues`, each a `path` and a `message`. * `stripeSync` reports the paid plans' sync to Stripe: `synced`, `pending`, `failed`, or `awaiting_connection`. A sync failure doesn't undo the publish. > [!WARNING] > > Active items missing from the request are archived. To change one item, use its own endpoint. Needs the **Catalog** permission at Write. ```bash cURL theme={"system"} curl https://api.integrateledger.com/v1/catalog \ -H "Authorization: Bearer $LEDGER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "schemaVersion": 1, "environment": "sandbox", "currency": "usd", "plans": [ { "id": "free", "name": "Free", "monthlyCredits": 50, "monthlyPriceCents": 0 }, { "id": "pro", "name": "Pro", "monthlyCredits": 1000, "monthlyPriceCents": 2000 } ], "features": [ { "id": "chat_message", "name": "Chat message", "creditCost": 1 } ], "addOns": [ { "id": "credits_500", "name": "Top-up", "type": "credit_pack", "creditAmount": 500, "priceCents": 1000 } ], "policy": { "onInsufficientCredits": "block" } }' ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const result = await client.publishCatalog({ "schemaVersion": 1, "environment": "sandbox", "currency": "usd", "plans": [ { "id": "free", "name": "Free", "monthlyCredits": 50, "monthlyPriceCents": 0 }, { "id": "pro", "name": "Pro", "monthlyCredits": 1000, "monthlyPriceCents": 2000 } ], "features": [ { "id": "chat_message", "name": "Chat message", "creditCost": 1 } ], "addOns": [ { "id": "credits_500", "name": "Top-up", "type": "credit_pack", "creditAmount": 500, "priceCents": 1000 } ], "policy": { "onInsufficientCredits": "block" } }); console.log(result); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) result = client.publish_catalog({ "schemaVersion": 1, "environment": "sandbox", "currency": "usd", "plans": [ { "id": "free", "name": "Free", "monthlyCredits": 50, "monthlyPriceCents": 0 }, { "id": "pro", "name": "Pro", "monthlyCredits": 1000, "monthlyPriceCents": 2000 } ], "features": [ { "id": "chat_message", "name": "Chat message", "creditCost": 1 } ], "addOns": [ { "id": "credits_500", "name": "Top-up", "type": "credit_pack", "creditAmount": 500, "priceCents": 1000 } ], "policy": { "onInsufficientCredits": "block" } }) print(result) ``` ```json 200 theme={"system"} { "changed": true, "stripeSync": "synced" } ``` ```json 400 theme={"system"} { "error": "invalid_catalog", "issues": [ { "path": "plans", "message": "Too small: expected array to have >=1 items" } ] } ``` # Adjust a customer's balance Source: https://docs.integrateledger.com/api-reference/customers/balance-adjustments openapi.json POST /v1/customers/balance-adjustments Add or remove a customer's credits by hand, with a reason. Pass the exact `customerId` as a URL-encoded query value. Use an adjustment for credits outside a plan or credit pack: a goodwill credit, a support fix, or taking back credits after a refund. The key needs the `billing:write` permission. * Positive `credits` add a new grant. They never expire unless you set `expiresAt`, which must be after the customer's current time. * Negative `credits` remove credits from the customer's grants in the order requests spend them (plan credits first, then the rest by expiry). An adjustment never takes the balance below zero: asking for more than the balance returns `409 insufficient_credits` with the `availableCredits` you can remove. * `reason` is required and appears on the customer's page and in billing history, with who made the change (`createdBy`). * `idempotencyKey` is required, per customer. Retrying with the same key and the same body returns the first adjustment, with the `Idempotent-Replayed: true` header, and changes nothing. The same key with a different body returns `409 idempotency_key_reused`. ## Grant a payment Ledger didn't If a customer paid and Ledger granted no credits (for example, an invoice with tax that Ledger refused), add the credits with `stripeInvoiceId`, or `stripeCheckoutSessionId` for a credit pack: ```json theme={"system"} { "credits": 1000, "reason": "Invoice paid with tax; Ledger refused it", "idempotencyKey": "grant-in_1QxYzExample", "stripeInvoiceId": "in_1QxYzExample", "expiresAt": "2026-12-07T00:00:00Z" } ``` Ledger asks Stripe that the payment exists in this environment (`404 stripe_object_not_found` otherwise), is paid (`409 stripe_object_not_paid`) and belongs to this customer (`409 stripe_object_not_this_customers`). The grant then counts as that payment's, so the payment can't be granted again (`409 stripe_object_already_granted`). Linking needs Stripe connected in this environment (`422 stripe_not_ready`); if Stripe doesn't answer, the request returns `502 stripe_unavailable` and you can retry with the same key. Unknown customers return `404 customer_not_found`. # Stop or resume renewal Source: https://docs.integrateledger.com/api-reference/customers/cancellation openapi.json POST /v1/customers/subscriptions/{subscriptionId}/cancellation Cancel a paid subscription at the end of its period, or undo that before it ends. Sets whether an active paid subscription renews. `cancelAtPeriodEnd: true` stops renewal: the plan and its credits last until `cancelsAt`, and Stripe doesn't charge again. `false` resumes it. Sending the state it's already in is safe. * `subscriptionId` is the numeric `id` from [List customer subscriptions](/api-reference/customers/subscriptions); `customerId` goes in the query string. * Free plans and pending Checkouts return `409 subscription_not_manageable`, as does a subscription with a pause, schedule or custom end date in Stripe. * Ledger never refunds. To end a subscription at once, cancel it in Stripe. See [Stop or resume renewal](/configure-pricing/upgrades-and-downgrades#cancel-at-period-end). Needs the **Billing** permission. ```bash cURL theme={"system"} curl "https://api.integrateledger.com/v1/customers/subscriptions/2/cancellation?customerId=user_123" \ -H "Authorization: Bearer $LEDGER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"cancelAtPeriodEnd": true}' ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const renewal = await client.setSubscriptionCancellation({ customerId: "user_123", subscriptionId: 2, body: { cancelAtPeriodEnd: true }, }); console.log(renewal); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) renewal = client.set_subscription_cancellation( customer_id="user_123", subscription_id=2, cancel_at_period_end=True ) print(renewal) ``` ```json 200 theme={"system"} { "cancelAtPeriodEnd": true, "cancelsAt": "2026-12-07T07:40:38.000Z" } ``` ```json 409 theme={"system"} { "error": "subscription_not_manageable" } ``` # Create or get a customer Source: https://docs.integrateledger.com/api-reference/customers/create openapi.json POST /v1/customers Create a customer from your own user ID, or return the one that already exists. Creates a customer in the key's environment, using your application's stable user ID as `customerId` (1–255 characters). If the customer already exists it's returned with `"created": false`, so the call is safe to make on every sign-in. Creating a customer grants no credits and creates nothing in Stripe. [Attach](/api-reference/billing/attach) a plan to start billing. The same ID in Sandbox and Live is two separate customers. Needs the **Customers** permission at Write. ```bash cURL theme={"system"} curl https://api.integrateledger.com/v1/customers \ -H "Authorization: Bearer $LEDGER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"customerId": "user_123"}' ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const customer = await client.createOrGetCustomer({ customerId: "user_123" }); console.log(customer); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) customer = client.create_or_get_customer(customer_id="user_123") print(customer) ``` ```json 200 theme={"system"} { "customerId": "user_123", "created": true } ``` # Delete a customer Source: https://docs.integrateledger.com/api-reference/customers/delete openapi.json DELETE /v1/customers Remove a customer and everything Ledger holds for it, optionally with its Stripe customer. Deletes the customer with its subscriptions, credit grants, usage and purchases. Creating the same `customerId` afterwards starts a new, empty customer. * A customer with a paid subscription that isn't cancelled returns `409 active_paid_subscription`, because deleting only Ledger's record would leave Stripe charging them. Pass `deleteInStripe=true` to delete the Stripe customer too, which cancels its Stripe subscriptions. * Usage events are deleted in Sandbox. In Live they're kept, as the record of what the customer used. The SDKs don't have this method yet, so the examples call the API directly. Needs the **Customers** permission at Write. ```bash cURL theme={"system"} curl -X DELETE "https://api.integrateledger.com/v1/customers?customerId=user_123&deleteInStripe=true" \ -H "Authorization: Bearer $LEDGER_API_KEY" ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const url = new URL("https://api.integrateledger.com/v1/customers"); url.searchParams.set("customerId", "user_123"); url.searchParams.set("deleteInStripe", "true"); const response = await fetch(url, { method: "DELETE", headers: { Authorization: `Bearer ${process.env.LEDGER_API_KEY}` }, }); console.log(response.status, await response.json()); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) import json from urllib.parse import urlencode from urllib.request import Request, urlopen query = urlencode({"customerId": "user_123", "deleteInStripe": "true"}) request = Request( f"https://api.integrateledger.com/v1/customers?{query}", method="DELETE", headers={"Authorization": f"Bearer {os.environ['LEDGER_API_KEY']}"}, ) with urlopen(request) as response: print(json.load(response)) ``` ```json 200 theme={"system"} { "customerId": "user_123", "deleted": true } ``` ```json 409 theme={"system"} { "error": "active_paid_subscription" } ``` # Get a customer Source: https://docs.integrateledger.com/api-reference/customers/get openapi.json GET /v1/customers Read a customer's balance, current plan and this period's overage. Returns the customer's prepaid balance and current plan. Pass the `customerId` as a URL-encoded query parameter. * `availableCredits` is plan and pack credits together. It can be above zero with no plan. * `currentPlan` is the active plan, or a plan waiting for Checkout (`status: "pending_checkout"`) when there's no active one. It's `null` with neither. * `overage` describes the current period on a Sandbox plan with [overage](/configure-pricing/overage), and is `null` otherwise. `overageCapCents` is the customer's own cap, if set. Reading a customer never creates one. Needs the **Customers** permission at Read. ```bash cURL theme={"system"} curl -G https://api.integrateledger.com/v1/customers \ -H "Authorization: Bearer $LEDGER_API_KEY" \ --data-urlencode "customerId=user_123" ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const customer = await client.getCustomer({ customerId: "user_123" }); console.log(customer); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) customer = client.get_customer(customer_id="user_123") print(customer) ``` ```json 200 theme={"system"} { "customerId": "user_123", "createdAt": "2026-11-07T07:39:38.899Z", "availableCredits": 50, "currentPlan": { "id": "free", "name": "Free", "status": "active", "currentPeriodStart": "2026-11-07T07:39:38.910Z", "currentPeriodEnd": "2026-12-07T07:39:38.910Z" }, "overageCapCents": null, "overage": null } ``` ```json 404 theme={"system"} { "error": "customer_not_found" } ``` # Set a customer's overage cap Source: https://docs.integrateledger.com/api-reference/customers/overage-cap openapi.json POST /v1/customers/overage-cap Set or clear the most one customer can spend on overage each month. Sets the customer's own monthly overage cap in cents. It replaces the plan's default `overageCapCents`, higher or lower, from the next [track](/api-reference/usage/track). `0` turns overage off for the customer; `null` clears the cap and restores the plan default. The response returns the customer's `overage` for the current period, or `null` when their plan has no overage. See [Overage](/configure-pricing/overage). Needs the **Customers** permission at Write. ```bash cURL theme={"system"} curl "https://api.integrateledger.com/v1/customers/overage-cap?customerId=user_123" \ -H "Authorization: Bearer $LEDGER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"capCents": 5000}' ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const cap = await client.setCustomerOverageCap({ customerId: "user_123", body: { capCents: 5000 }, }); console.log(cap); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) cap = client.set_customer_overage_cap(customer_id="user_123", cap_cents=5000) print(cap) ``` ```json 200 theme={"system"} { "customerId": "user_123", "capCents": 5000, "overage": { "credits": 1500, "costCents": 1500, "unitAmountCents": 1, "billingUnits": 1, "capCents": 5000, "capSource": "customer", "remainingCents": 3500, "source": "plan", "override": null, "periodStart": "2026-11-07T07:40:58.000Z", "periodEnd": "2026-12-07T07:40:58.000Z" } } ``` # Set a customer's overage rate Source: https://docs.integrateledger.com/api-reference/customers/overage-override openapi.json POST /v1/customers/overage-override Give one customer a negotiated overage price, or clear it. Replaces the plan's overage price for one customer: `unitAmountCents` per `billingUnits` credits (default 1). It never turns overage on; the plan needs overage terms, or the request returns `409 plan_has_no_overage`. * `planId` scopes the override to one plan, across its versions. Leave it out to apply the override to any plan with overage; a plan-specific override wins. * `capCents` sets a cap for this override. A cap the customer set with [Set overage cap](/api-reference/customers/overage-cap) still wins. * Send the same `planId` with `"unitAmountCents": null` to clear it. Usage already recorded keeps its rate. Needs the **Customers** permission at Write. ```bash cURL theme={"system"} curl "https://api.integrateledger.com/v1/customers/overage-override?customerId=user_123" \ -H "Authorization: Bearer $LEDGER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"planId": "scale", "unitAmountCents": 1, "billingUnits": 2}' ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const result = await client.setCustomerOverageOverride({ customerId: "user_123", body: { planId: "scale", unitAmountCents: 1, billingUnits: 2 }, }); console.log(result.override); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) result = client.set_customer_overage_override( customer_id="user_123", plan_id="scale", unit_amount_cents=1, billing_units=2 ) print(result["override"]) ``` ```json 200 theme={"system"} { "customerId": "user_123", "planId": "scale", "override": { "planId": "scale", "unitAmountCents": 1, "billingUnits": 2, "capCents": null, "effectiveFrom": "2026-11-07T07:41:16.403Z" }, "overage": { "credits": 1500, "costCents": 1500, "unitAmountCents": 1, "billingUnits": 2, "capCents": 5000, "capSource": "customer", "remainingCents": 3500, "source": "override", "override": { "planId": "scale", "unitAmountCents": 1, "billingUnits": 2, "capCents": null, "effectiveFrom": "2026-11-07T07:41:16.403Z" }, "periodStart": "2026-11-07T07:40:58.000Z", "periodEnd": "2026-12-07T07:40:58.000Z" } } ``` ```json 409 theme={"system"} { "error": "plan_has_no_overage" } ``` # List customer purchases Source: https://docs.integrateledger.com/api-reference/customers/purchases openapi.json GET /v1/customers/purchases Read a customer's credit pack purchases, newest first. Returns the customer's credit pack purchases, newest first. Each purchase of the same pack is its own item. * `status` is `pending_checkout` until Stripe confirms the payment, then `paid`. * `creditAmount` is the amount actually granted, and `null` while the purchase is pending. * Pages hold `limit` items (1–100, default 25). When `hasMore` is `true`, pass `nextCursor` as `cursor` for the next page. Needs the **Customers** permission at Read. ```bash cURL theme={"system"} curl -G https://api.integrateledger.com/v1/customers/purchases \ -H "Authorization: Bearer $LEDGER_API_KEY" \ --data-urlencode "customerId=user_123" ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const purchases = await client.listCustomerPurchases({ customerId: "user_123" }); console.log(purchases.items); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) purchases = client.list_customer_purchases(customer_id="user_123") print(purchases["items"]) ``` ```json 200 theme={"system"} { "items": [ { "id": 1, "addOnId": "credits_500", "name": "Top-up", "status": "paid", "createdAt": "2026-11-07T07:23:53.836Z", "creditAmount": 500 } ], "hasMore": false, "nextCursor": null } ``` # Link a Stripe customer Source: https://docs.integrateledger.com/api-reference/customers/stripe-customer openapi.json POST /v1/customers/stripe-customer Link a customer to the Stripe customer you already bill them with. Use this when you move existing Stripe billing to Ledger: it's the first step of [Migrate from Stripe Billing](/migrate-from-stripe). Pass the exact `customerId` as a URL-encoded query value and the customer's `stripeCustomerId` from your Stripe account. Create the customer first with [Create or get a customer](/api-reference/customers/create). Ledger checks that the Stripe customer exists in the Stripe account connected for the key's environment (Sandbox or Live), adds `ledger_organization_id`, `ledger_customer_id` and `ledger_env` to its metadata, and saves the link. It changes nothing else on the Stripe customer and charges nothing. After linking, Checkout and upgrades use this Stripe customer and its saved cards. A Sandbox customer on a test clock brings the clock's time with it. Linking the same Stripe customer again changes nothing. The call returns `409 stripe_customer_already_linked` when the customer is linked to a different Stripe customer, `409 stripe_customer_linked_elsewhere` when another of your customers has this one, and `409 stripe_customer_owned_elsewhere` when its metadata names another Ledger workspace or environment. Unknown customers return `404 customer_not_found`; a Stripe customer that doesn't exist or was deleted returns `404 stripe_customer_not_found`. The key needs the `customers:write` permission. # List customer subscriptions Source: https://docs.integrateledger.com/api-reference/customers/subscriptions openapi.json GET /v1/customers/subscriptions Read a customer's plan history and renewal state, newest first. Returns the customer's subscriptions, newest first, including ended ones. Each has a numeric `id`, which [Stop or resume renewal](/api-reference/customers/cancellation) takes. * `status` is `pending_checkout`, `active`, `canceled`, `incomplete_expired`, or a status mirrored from Stripe. See [Billing lifecycle](/concepts/billing-lifecycle#subscription-statuses). * `cancellation` is set on an active paid subscription when Ledger could read it from Stripe, and `null` otherwise. Offer renewal controls only when `cancellation.manageable` is `true`. * Pages hold `limit` items (1–100, default 25). When `hasMore` is `true`, pass `nextCursor` as `cursor` for the next page. Needs the **Customers** permission at Read. ```bash cURL theme={"system"} curl -G https://api.integrateledger.com/v1/customers/subscriptions \ -H "Authorization: Bearer $LEDGER_API_KEY" \ --data-urlencode "customerId=user_123" \ --data-urlencode "limit=25" ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const subscriptions = await client.listCustomerSubscriptions({ customerId: "user_123", limit: 25, }); console.log(subscriptions.items); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) subscriptions = client.list_customer_subscriptions(customer_id="user_123", limit=25) print(subscriptions["items"]) ``` ```json 200 theme={"system"} { "items": [ { "id": 2, "planId": "pro", "planName": "Pro", "monthlyPriceCents": 2000, "status": "active", "createdAt": "2026-11-07T07:40:10.278Z", "currentPeriodStart": "2026-11-07T07:40:38.000Z", "currentPeriodEnd": "2026-12-07T07:40:38.000Z", "cancellation": { "cancelAtPeriodEnd": false, "scheduledEnd": false, "manageable": true, "cancelsAt": null } }, { "id": 1, "planId": "free", "planName": "Free", "monthlyPriceCents": 0, "status": "canceled", "createdAt": "2026-11-07T07:39:41.505Z", "currentPeriodStart": "2026-11-07T07:39:41.505Z", "currentPeriodEnd": "2026-12-07T07:39:41.505Z", "cancellation": null } ], "hasMore": false, "nextCursor": null } ``` # Archive a feature Source: https://docs.integrateledger.com/api-reference/features/archive openapi.json DELETE /v1/features/{id} Stop metering a feature while keeping its usage history. Archives the feature. `check` and `track` for its ID then return `404 feature_not_found`; usage already recorded stays. An unknown or already archived ID returns `404 not_found`. Creating a with the same ID later brings it back. Needs the **Catalog** permission at Write. ```bash cURL theme={"system"} curl -X DELETE "https://api.integrateledger.com/v1/features/chat_message" \ -H "Authorization: Bearer $LEDGER_API_KEY" ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const result = await client.archiveFeature({ id: "chat_message" }); console.log(result); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) result = client.archive_feature("chat_message") print(result) ``` ```json 200 theme={"system"} { "archived": true } ``` ```json 404 theme={"system"} { "error": "not_found" } ``` # Create a feature Source: https://docs.integrateledger.com/api-reference/features/create openapi.json POST /v1/features Create an action in your app and set what one use costs in credits. Creates a feature: an action your app meters, and the `creditCost` (a whole number above 0) that one successful use spends. Pass its `id` to [check](/api-reference/usage/check) and [track](/api-reference/usage/track). * `id`: 1–100 letters, numbers, `_` or `-`. It can't be changed. * `name`: 1–120 characters, shown in the dashboard. Features don't sync to Stripe. An active item with the same `id` returns `409 already_exists`. Needs the **Catalog** permission at Write. ```bash cURL theme={"system"} curl https://api.integrateledger.com/v1/features \ -H "Authorization: Bearer $LEDGER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "id": "image_generation", "name": "Image generation", "creditCost": 5 }' ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const feature = await client.createFeature({ id: "image_generation", name: "Image generation", creditCost: 5, }); console.log(feature); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) feature = client.create_feature( id="image_generation", name="Image generation", credit_cost=5 ) print(feature) ``` ```json 201 theme={"system"} { "item": { "id": "image_generation", "name": "Image generation", "creditCost": 5 }, "changed": true } ``` ```json 409 theme={"system"} { "error": "already_exists" } ``` # Get a feature Source: https://docs.integrateledger.com/api-reference/features/get openapi.json GET /v1/features/{id} Read one active feature by its ID. Returns the active feature with this `id`. An unknown or archived ID returns `404 not_found`. Needs the **Catalog** permission at Read. ```bash cURL theme={"system"} curl https://api.integrateledger.com/v1/features/chat_message \ -H "Authorization: Bearer $LEDGER_API_KEY" ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const feature = await client.getFeature({ id: "chat_message" }); console.log(feature); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) feature = client.get_feature("chat_message") print(feature) ``` ```json 200 theme={"system"} { "id": "chat_message", "name": "Chat message", "creditCost": 1 } ``` ```json 404 theme={"system"} { "error": "not_found" } ``` # List features Source: https://docs.integrateledger.com/api-reference/features/list openapi.json GET /v1/features List the active features in the key's environment. Returns every active feature in the key's environment. Archived features aren't included. Needs the **Catalog** permission at Read. ```bash cURL theme={"system"} curl https://api.integrateledger.com/v1/features \ -H "Authorization: Bearer $LEDGER_API_KEY" ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const { items } = await client.listFeatures(); console.log(items); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) items = client.list_features()["items"] print(items) ``` ```json 200 theme={"system"} { "items": [ { "id": "chat_message", "name": "Chat message", "creditCost": 1 }, { "id": "image_generation", "name": "Image generation", "creditCost": 5 } ] } ``` # Update a feature Source: https://docs.integrateledger.com/api-reference/features/update openapi.json POST /v1/features/{id} Change a feature's name or credit cost. Changes the feature in place. A new `creditCost` applies to uses tracked from now on; usage already recorded keeps the cost it was charged. Send the complete editable body; sending the current values again returns `"changed": false`. An unknown or archived ID returns `404 not_found`. Needs the **Catalog** permission at Write. ```bash cURL theme={"system"} curl https://api.integrateledger.com/v1/features/chat_message \ -H "Authorization: Bearer $LEDGER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Chat message", "creditCost": 2}' ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const feature = await client.updateFeature({ id: "chat_message", body: { name: "Chat message", creditCost: 2 }, }); console.log(feature); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) feature = client.update_feature("chat_message", name="Chat message", credit_cost=2) print(feature) ``` ```json 200 theme={"system"} { "item": { "id": "chat_message", "name": "Chat message", "creditCost": 2 }, "changed": true } ``` # API reference Source: https://docs.integrateledger.com/api-reference/introduction The Ledger REST API, its conventions, authentication, errors, idempotency and pagination. The Ledger API is a JSON API over HTTPS. It's organised around a few resources: usage (check and track), customers, billing, and the catalog of plans, features and credit packs. The [OpenAPI spec](https://docs.integrateledger.com/openapi.json) describes every endpoint, for code generation and agent tools. ```text Base URL theme={"system"} https://api.integrateledger.com ``` There's one base URL for both environments. The API key decides whether a request reads and writes Sandbox or Live. ## Authentication Send a secret key as a bearer token on every request. Keys start with `ledger_test_` (Sandbox) or `ledger_live_` (Live), and each can be limited to groups of endpoints. See [API keys and permissions](/api-keys). ```bash theme={"system"} curl https://api.integrateledger.com/v1/plans \ -H "Authorization: Bearer $LEDGER_API_KEY" ``` Keep keys on your server. The API has no endpoints meant for browsers or mobile apps. ## Conventions * **Methods.** `GET` reads, `POST` creates and updates, `DELETE` archives catalog items or deletes a customer. Updates take the item's complete body. * **Bodies.** Send JSON with `Content-Type: application/json`, up to 1 MiB. Requests without the header get `415`; larger bodies get `413`. * **Customers.** Identify a customer by your own ID, `customerId`, in the body or, on `GET` and some `POST` and `DELETE` requests, as a URL-encoded query parameter: `?customerId=user_123`. * **Money** is in US cents, in fields ending in `Cents`. * **Timestamps** are ISO 8601 strings in UTC. * **Request IDs.** Every response has an `x-request-id` header. Quote it when you ask about a request. ## Errors Errors return a non-2xx status and a body with an `error` code, sometimes with fields that help you act: ```json theme={"system"} { "error": "insufficient_credits", "availableCredits": 0, "creditCost": 1 } ``` [Errors](/errors) lists every code with what to do about it. ## Idempotency Retries are safe where they matter: | Endpoint | How | | - | - | | `POST /v1/track` | The `request_id` you send. A repeat returns the first result with `"idempotent": true`. | | `POST /v1/billing/upgrade` | The `Idempotency-Key` header. A repeat returns the upgrade's current state. | | `POST /v1/billing/attach` | While a Checkout is open, attaching the same plan or pack returns it again. | | `POST /v1/customers` | Returns the existing customer with `"created": false`. | ## Rate limits Each key can send `check` and `track` at 200 requests a second, billing and catalog writes at 5 a second, and everything else at 50 a second, with short bursts above that. Every response carries `RateLimit-*` headers; over the limit, Ledger returns `429` with a `Retry-After` header. See [Rate limits](/rate-limits). ## Pagination [List customer subscriptions](/api-reference/customers/subscriptions) and [List customer purchases](/api-reference/customers/purchases) return pages, newest first: ```json theme={"system"} { "items": [], "hasMore": true, "nextCursor": 42 } ``` Pass `limit` (1–100, default 25), and when `hasMore` is `true`, pass `nextCursor` as `cursor` to get the next page. Catalog lists return every active item at once. ## SDKs The [TypeScript and Python SDKs](/sdks), both `integrateledger`, wrap every endpoint except deleting a customer. Each endpoint page shows cURL, TypeScript and Python. # Preview a Stripe migration Source: https://docs.integrateledger.com/api-reference/migrate/stripe-preview openapi.json GET /v1/migrate/stripe/preview See what moving your Stripe Billing subscribers onto Ledger would import and refuse, without changing anything. Reads the Stripe account connected to the key's environment and answers what a migration would do. It changes nothing in Stripe or Ledger. [`ledger migrate stripe`](/migrate-from-stripe) runs on it. * `prices`: each Stripe Price that a subscription Stripe still bills is on, with `openSubscriptions` and `importableSubscriptions`. `ledgerPlanId` is set when the Price is one of your plans' own synced Prices (a subscription Ledger already moved). `refusal` is set when no subscription on it can be imported: `not_monthly`, `metered_price`, a non-USD `price_mismatch`, or a free Price (`plan_not_paid`). * `subscriptions`: each open subscription, with the Stripe customer's `customerMetadata` (to choose your own customer ID from), the next renewal (`currentPeriodEnd`), `importedAs` when Ledger already holds it, and `refusal`: the code [Import a Stripe subscription](/api-reference/billing/import) would answer, or `null` when it would be imported. A customer whose Ledger metadata names another workspace or environment is `stripe_customer_owned_elsewhere`. The preview runs the import's own checks against a plan priced like the subscription's Price, except two it leaves to the import: pending invoice items, and a Ledger customer that already has a plan. In Sandbox it includes subscriptions on test clocks. It reads up to 1,000 open subscriptions; `truncated: true` means there are more, so migrate these and preview again. It needs a saved Stripe connection (`502 stripe_not_configured`), but not a healthy webhook, so you can preview before fixing one. The key needs the `customers:read` permission. # Archive a plan Source: https://docs.integrateledger.com/api-reference/plans/archive openapi.json DELETE /v1/plans/{id} Stop offering a plan without touching the customers already on it. Hides the plan from new attaches. Customers already on it keep their subscription and credits, and nothing is cancelled in Stripe. An unknown or already archived ID returns `404 not_found`. Creating a with the same ID later brings it back. Needs the **Catalog** permission at Write. ```bash cURL theme={"system"} curl -X DELETE "https://api.integrateledger.com/v1/plans/pro" \ -H "Authorization: Bearer $LEDGER_API_KEY" ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const result = await client.archivePlan({ id: "pro" }); console.log(result); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) result = client.archive_plan("pro") print(result) ``` ```json 200 theme={"system"} { "archived": true } ``` ```json 404 theme={"system"} { "error": "not_found" } ``` # Create a plan Source: https://docs.integrateledger.com/api-reference/plans/create openapi.json POST /v1/plans Create a monthly plan, free or paid. Paid plans sync to Stripe. Creates a plan that grants `monthlyCredits` each month for `monthlyPriceCents` (USD cents). A price of `0` makes it free: it starts as soon as you attach it, without Stripe. A paid plan syncs to the environment's Stripe account as a product with a monthly price; `stripeSync` reports how that went. * `id`: 1–100 letters, numbers, `_` or `-`. It can't be changed. * `name`: 1–120 characters, shown on Stripe Checkout. * In Sandbox, a paid plan can add `overageUnitAmountCents`, `overageBillingUnits` and `overageCapCents`. See [Bill overage](/configure-pricing/overage). An active item with the same `id` returns `409 already_exists`. Needs the **Catalog** permission at Write. ```bash cURL theme={"system"} curl https://api.integrateledger.com/v1/plans \ -H "Authorization: Bearer $LEDGER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "id": "pro", "name": "Pro", "monthlyCredits": 1000, "monthlyPriceCents": 2000 }' ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const plan = await client.createPlan({ id: "pro", name: "Pro", monthlyCredits: 1000, monthlyPriceCents: 2000, }); console.log(plan); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) plan = client.create_plan( id="pro", name="Pro", monthly_credits=1000, monthly_price_cents=2000 ) print(plan) ``` ```json 201 theme={"system"} { "item": { "id": "pro", "name": "Pro", "monthlyCredits": 1000, "monthlyPriceCents": 2000, "overageUnitAmountCents": null, "overageBillingUnits": 1, "overageCapCents": null, "stripeSyncStatus": "synced" }, "changed": true, "stripeSync": "synced" } ``` ```json 409 theme={"system"} { "error": "already_exists" } ``` # Get a plan Source: https://docs.integrateledger.com/api-reference/plans/get openapi.json GET /v1/plans/{id} Read one active plan by its ID. Returns the active plan with this `id`. An unknown or archived ID returns `404 not_found`. `stripeSyncStatus` is `null` for a free plan, which has nothing to sync. Needs the **Catalog** permission at Read. ```bash cURL theme={"system"} curl https://api.integrateledger.com/v1/plans/pro \ -H "Authorization: Bearer $LEDGER_API_KEY" ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const plan = await client.getPlan({ id: "pro" }); console.log(plan); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) plan = client.get_plan("pro") print(plan) ``` ```json 200 theme={"system"} { "id": "pro", "name": "Pro", "monthlyCredits": 1000, "monthlyPriceCents": 2000, "overageUnitAmountCents": null, "overageBillingUnits": 1, "overageCapCents": null, "stripeSyncStatus": "synced" } ``` ```json 404 theme={"system"} { "error": "not_found" } ``` # List plans Source: https://docs.integrateledger.com/api-reference/plans/list openapi.json GET /v1/plans List the active plans in the key's environment. Returns every active plan in the key's environment. Archived plans aren't included, nor are the earlier versions that existing subscriptions still use. Needs the **Catalog** permission at Read. ```bash cURL theme={"system"} curl https://api.integrateledger.com/v1/plans \ -H "Authorization: Bearer $LEDGER_API_KEY" ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const { items } = await client.listPlans(); console.log(items); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) items = client.list_plans()["items"] print(items) ``` ```json 200 theme={"system"} { "items": [ { "id": "free", "name": "Free", "monthlyCredits": 50, "monthlyPriceCents": 0, "overageUnitAmountCents": null, "overageBillingUnits": 1, "overageCapCents": null, "stripeSyncStatus": null }, { "id": "pro", "name": "Pro", "monthlyCredits": 1000, "monthlyPriceCents": 2000, "overageUnitAmountCents": null, "overageBillingUnits": 1, "overageCapCents": null, "stripeSyncStatus": "synced" } ] } ``` # Update a plan Source: https://docs.integrateledger.com/api-reference/plans/update openapi.json POST /v1/plans/{id} Change a plan's terms for customers who start it from now on. Creates a new version of the plan. Customers who attach it afterwards get the new terms; existing subscriptions keep theirs. To move an existing customer, [upgrade](/api-reference/billing/upgrade) them. Updates replace the plan's terms, so overage fields you leave out are removed. Send the complete editable body; sending the current values again returns `"changed": false`. An unknown or archived ID returns `404 not_found`. Needs the **Catalog** permission at Write. ```bash cURL theme={"system"} curl https://api.integrateledger.com/v1/plans/pro \ -H "Authorization: Bearer $LEDGER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Pro", "monthlyCredits": 1200, "monthlyPriceCents": 2000 }' ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const plan = await client.updatePlan({ id: "pro", body: { name: "Pro", monthlyCredits: 1200, monthlyPriceCents: 2000 }, }); console.log(plan); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) plan = client.update_plan( "pro", name="Pro", monthly_credits=1200, monthly_price_cents=2000 ) print(plan) ``` ```json 200 theme={"system"} { "item": { "id": "pro", "name": "Pro", "monthlyCredits": 1200, "monthlyPriceCents": 2000, "overageUnitAmountCents": null, "overageBillingUnits": 1, "overageCapCents": null, "stripeSyncStatus": "synced" }, "changed": true, "stripeSync": "synced" } ``` # Check credits Source: https://docs.integrateledger.com/api-reference/usage/check openapi.json POST /v1/check Find out whether a customer can use a feature now, without spending anything. Returns whether [track](/api-reference/usage/track) would accept one use of the feature right now, with the customer's balance and the feature's cost. A check never spends or reserves credits, so call it before every metered action. * A balance too low for the cost returns `200` with `"allowed": false`. * On a Sandbox plan with [overage](/configure-pricing/overage), `allowed` is also `true` when the shortfall fits under the overage cap; `availableCredits` can then be below `creditCost`. * Because nothing is reserved, `track` decides again and can still return `409 insufficient_credits`. See [Check and track usage](/getting-started/gating). * If Ledger can't answer (a network error, a timeout, a `5xx` or a `429`), the TypeScript and Python SDKs [fail open](/fail-open): the check returns `allowed: true` with `failedOpen: true` and a `reason`. Every other answer, including `allowed: false` and the errors here, is returned or thrown as usual. Needs the **Credits** permission at Read. ```bash cURL theme={"system"} curl https://api.integrateledger.com/v1/check \ -H "Authorization: Bearer $LEDGER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"customerId": "user_123", "featureId": "chat_message"}' ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const decision = await client.checkCredits({ customerId: "user_123", featureId: "chat_message", }); console.log(decision); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) decision = client.check_credits(customer_id="user_123", feature_id="chat_message") print(decision) ``` ```json 200 theme={"system"} { "allowed": true, "availableCredits": 50, "creditCost": 1 } ``` ```json 404 theme={"system"} { "error": "customer_not_found" } ``` # Track usage Source: https://docs.integrateledger.com/api-reference/usage/track openapi.json POST /v1/track Record one successful use of a feature and spend its credits, exactly once per request ID. Records one use of a feature after it succeeds and spends its `creditCost`, plan credits first, then credit packs, then overage where the plan allows it. Each `request_id` is charged once per customer: retrying with the same one returns the original result with `"idempotent": true`. * `availableCredits` is the prepaid balance left; it never goes below zero. * `chargedCredits` is the feature's full cost, including any part recorded as overage. `overageCredits` is this period's overage so far. * A refused use charges nothing: `409 insufficient_credits`, `409 overage_cap_reached` or `409 request_id_conflict` (the ID was used for another feature). * `request_id` is 1–255 characters. The TypeScript SDK calls it `requestId`. * If Ledger can't answer (a network error, a timeout, a `5xx` or a `429`), the TypeScript and Python SDKs [fail open](/fail-open): track returns `failedOpen: true` and replays the usage in the background with the same `request_id`, so it is charged once. The `4xx` and `409` answers are thrown and never replayed. Needs the **Credits** permission at Write. ```bash cURL theme={"system"} curl https://api.integrateledger.com/v1/track \ -H "Authorization: Bearer $LEDGER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "customerId": "user_123", "featureId": "chat_message", "request_id": "msg_0001" }' ``` ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const usage = await client.trackUsage({ customerId: "user_123", featureId: "chat_message", requestId: "msg_0001", }); console.log(usage); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) usage = client.track_usage( customer_id="user_123", feature_id="chat_message", request_id="msg_0001" ) print(usage) ``` ```json 200 theme={"system"} { "allowed": true, "availableCredits": 49, "chargedCredits": 1, "overageCredits": 0, "overageRemainingCents": null, "request_id": "msg_0001", "idempotent": false } ``` ```json 409 theme={"system"} { "error": "insufficient_credits", "availableCredits": 0, "creditCost": 1 } ``` # Create a webhook endpoint Source: https://docs.integrateledger.com/api-reference/webhooks/create openapi.json POST /v1/webhook-endpoints Register a URL for Ledger to send signed events to, in the environment your API key selects. Ledger sends the events in `enabledEvents` to `url`, signed with the endpoint's secret. Use `["*"]` for every event, including ones added later. The [event reference](/webhooks#events) lists each type. The response carries the signing secret (`whsec_…`) once. Store it, then [verify each request](/webhooks#verify-the-signature) with it. To replace it, rotate the secret in **Settings › Webhooks**. * **URL:** Live endpoints must be public `https://` URLs. Private, loopback and internal hosts are refused. * **Limit:** 16 endpoints per environment; another returns `409 endpoint_limit_reached`. * **Errors:** an unknown event type is `400 invalid_request`; a URL Ledger can't send to is `400 https_required`, `private_address`, `credentials_in_url` or `invalid_url`. # Delete a webhook endpoint Source: https://docs.integrateledger.com/api-reference/webhooks/delete openapi.json DELETE /v1/webhook-endpoints/{id} Delete an endpoint and stop every delivery still waiting for it. Deletes the endpoint and its delivery log. Deliveries still retrying stop. To pause instead, [update it](/api-reference/webhooks/update) with `disabled: true`. # Get a webhook endpoint Source: https://docs.integrateledger.com/api-reference/webhooks/get openapi.json GET /v1/webhook-endpoints/{id} Read one webhook endpoint by its ID. Returns the endpoint without its secret. An ID from another workspace or environment returns `404 endpoint_not_found`. # List webhook endpoints Source: https://docs.integrateledger.com/api-reference/webhooks/list openapi.json GET /v1/webhook-endpoints List the environment's webhook endpoints, oldest first. Lists every endpoint in the environment your API key selects, enabled or not. Secrets are never listed. An endpoint Ledger turned off has `status: "disabled"` and `disabledReason: "failing"`; `failingSince` shows when its deliveries started failing. # Update a webhook endpoint Source: https://docs.integrateledger.com/api-reference/webhooks/update openapi.json POST /v1/webhook-endpoints/{id} Change an endpoint's URL, events or description, or turn it off and on. Send only the fields to change. `disabled: true` stops deliveries; events that happen while it is off are not sent to it later. After three days of failed deliveries Ledger turns an endpoint off (`disabledReason: "failing"`). Fix the receiver, then send `disabled: false`: deliveries that were still retrying resume, and new events are sent again. # CLI Source: https://docs.integrateledger.com/cli Keep your catalog in your repo as code, and every ledger command, flag, exit code and JSON output. The `ledger` CLI keeps your catalog in your repository, as `ledger.config.ts` or `ledger.catalog.json`. You change pricing by editing the file, review the change in a pull request, and publish it with `ledger push`. [Setup](/getting-started/setup) walks through the first run. Install it with `npm install --save-dev integrateledger-cli` and run `npx ledger`, or run `npx integrateledger-cli` without installing. It needs Node 22.18 or later, or Bun. ## The config file ```ts ledger.config.ts theme={"system"} import { creditPack, defineCatalog, feature, plan } from "integrateledger-cli"; export default defineCatalog({ plans: [ plan({ id: "free", name: "Free", monthlyCredits: 20, monthlyPriceCents: 0 }), plan({ id: "pro", name: "Pro", monthlyCredits: 1000, monthlyPriceCents: 2900 }), ], features: [ feature({ id: "generate_image", name: "Generate an image", creditCost: 1 }), ], creditPacks: [ creditPack({ id: "credits_300", name: "300 credits", creditAmount: 300, priceCents: 1000 }), ], }); ``` * The fields are the API's, as on [Catalog](/concepts/catalog). `defineCatalog`, `plan`, `feature` and `creditPack` only add types. * The file holds the **whole** catalog. Items you remove from it are archived when you push. * There's no `environment`: the API key chooses it, so one file serves Sandbox and Live. * `ledger.catalog.json` holds the same catalog in the [`POST /v1/catalog`](/api-reference/catalog/publish) body's shape, where packs are `addOns`. It's what the onboarding skill writes, and the default for projects without a `package.json`. If it names an `environment`, the key must match it. ## What a push changes | Change in the file | What happens | | - | - | | A plan's name, credits, price or overage | A new [plan version](/configure-pricing/plans#change-a-plan). New subscribers get it; current subscribers keep theirs. A paid plan gets a new Stripe Price, and a new Product when renamed | | A plan removed | Archived: it can't be attached; current subscribers keep it | | A feature or credit pack changed | Changed in place. A pack's credits or price can't change while a Checkout for it is open (`purchase_pending`) | | A feature or credit pack removed | Archived | Pushing an unchanged file changes nothing and retries any Stripe sync that hasn't finished. In CI, run `ledger diff --exit-code` on pull requests and `ledger push --live --yes` after merge, as in [Deploy](/getting-started/deploy). ## Commands | Command | Does | | - | - | | `ledger init [--format ts\|json] [--template] [--force]` | Writes a config from the workspace's catalog, or a starter template when the workspace is empty, there's no key yet, or `--template` is passed. Won't replace a config without `--force`. Also writes the agent skills, like `ledger skills` | | `ledger pull [--format ts\|json] [--force]` | Writes the workspace's catalog into the config. Leaves a file that already matches untouched, keeps the file's order, and refuses to overwrite changes you haven't pushed unless you pass `--force` | | `ledger diff [--exit-code]` | Validates the config and shows what `push` would change. `ledger plan` is the same command | | `ledger push [--yes]` | Validates, diffs, and publishes the whole catalog with `POST /v1/catalog`. Live asks for confirmation; `--yes` skips it | | `ledger migrate stripe [--dry-run]` | Moves the connected Stripe account's subscribers onto Ledger: proposes a plan per monthly Price, reports what it imports and refuses, then publishes the plans and imports each customer. Resumable. See [Migrate from Stripe Billing](/migrate-from-stripe) | | `ledger skills` | Writes the `ledger-catalog` and `ledger-integrate` [agent skills](/ai#ledger-skills) for this CLI version into `.agents/skills/`, replacing older copies. Needs no key | | `ledger validate` | Checks the config with the API's rules. Needs no key or network | | `ledger login [--live]` | Reads a key (hidden prompt, or stdin when piped), checks it, and saves it to `.ledger/credentials.json` with mode 600, adding `.ledger/` to `.gitignore` | | `ledger logout [--live]` | Removes that environment's saved key | | `ledger whoami` | Shows the environment, workspace, API URL, where the key came from, and the catalog's size | ## Flags | Flag | Meaning | | - | - | | `--live` | Act on Live. The default is Sandbox | | `-c`, `--config ` | The config file. By default the CLI looks here for `ledger.config.ts`, `.mts`, `.js`, `.mjs`, then `ledger.catalog.json`, and stops if it finds more than one | | `--json` | Print one JSON object on stdout and never prompt | | `--api-url ` | The Ledger API. Default: `LEDGER_API_URL`, else `https://api.integrateledger.com` | | `-y`, `--yes` | Push to Live without the prompt | | `--format ts\|json` | For `init` and `pull`: the file to write. Default: the existing file's, else TypeScript in a JavaScript project and JSON elsewhere | | `--template` | For `init`: the starter catalog, even if the workspace has one | | `--force` | For `init` and `pull`: overwrite the config | | `--exit-code` | For `diff`: exit 2 when there are changes | | `--dry-run` | For `migrate`: report only | | `--credits =` | For `migrate`, repeatable: a new plan's monthly credits, instead of the prompt | | `--feature =` | For `migrate`, repeatable: a feature and its credit cost, when the config has none | | `--customer-id ` | For `migrate`: take your customer ID from this Stripe customer metadata key. Default: the Stripe customer ID | | `--no-color` | Plain output. `NO_COLOR` does the same; `FORCE_COLOR` turns colour on outside a terminal | ## API keys The key's prefix is its environment: `ledger_test_` for Sandbox, `ledger_live_` for Live. The CLI uses the first key for the selected environment from: 1. `LEDGER_API_KEY` 2. `.ledger/credentials.json` (from `ledger login`) 3. `LEDGER_API_KEY` in `.env.local`, then `.env` A key for the other environment is skipped, so a Live key in your shell never publishes without `--live`. The key needs the `catalog:read` and `catalog:write` actions, and for `migrate` also `customers:read`, `customers:write` and `billing:write`. The CLI never prints it. ## Exit codes | Code | Meaning | | - | - | | `0` | Success | | `1` | An error: an invalid config, no key, an API error, or a Live push that wasn't confirmed | | `2` | `ledger diff --exit-code` found changes | ## JSON output With `--json`, `diff` prints: ```json theme={"system"} { "environment": "sandbox", "organizationId": "org_…", "path": "ledger.config.ts", "hasChanges": true, "changes": [ { "kind": "plan", "id": "pro", "action": "update", "name": "Pro", "summary": "1,000 credits/month, $39.00/month", "fields": [{ "field": "monthlyPriceCents", "from": 2900, "to": 3900 }], "effects": [ { "code": "new_plan_version", "message": "New plan version: …" }, { "code": "stripe_price", "message": "Stripe: a new $39.00/month Price on the \"Pro\" Product" } ] } ], "unsyncedPlans": [] } ``` * `kind` is `plan`, `feature` or `credit_pack`; `action` is `add`, `update` or `archive`. * Effect codes: `new_plan_version`, `stripe_product_and_price`, `stripe_price`, `stripe_overage_product`, `stripe_none`, `subscribers_keep_plan`, `in_place`, `purchase_pending_risk`, `archived`. * `push` adds the API's `changed` and `stripeSync` (`synced`, `pending`, `failed`, `awaiting_connection`). * `pull` prints `{ environment, organizationId, path, counts, wrote }`; `init` prints `{ path, format, source, environment, counts, skills }` with `source` `workspace` or `template`; `skills` prints `{ skills, linkCommand }`, each skill with `{ name, path, status }` and `status` `written`, `updated` or `unchanged`. * Errors print `{ "error": { "code", "message", "issues"? } }`, where `issues` lists `{ path, message }` with paths like `plans.1.monthlyPriceCents`. Codes include `invalid_config`, `no_api_key`, `confirmation_required`, `local_changes`, `config_not_found`, `unauthorized` and `purchase_pending`. # Billing lifecycle Source: https://docs.integrateledger.com/concepts/billing-lifecycle How a customer moves from attach to paid credits, and what happens at each renewal, upgrade and cancellation. Ledger runs billing through your Stripe account and treats Stripe's signed webhooks as the only proof of payment. Your backend asks for a plan or pack with [`attach`](/api-reference/billing/attach); Ledger grants credits when Stripe says the money arrived. ## From attach to credits ```mermaid theme={"system"} %%{init: {"theme": "base", "themeVariables": {"fontFamily": "IBM Plex Sans, sans-serif", "fontSize": "14px", "actorBkg": "#f7f6f3", "actorBorder": "#c4bfb5", "actorTextColor": "#1a1816", "signalColor": "#3d3934", "signalTextColor": "#1a1816", "noteBkgColor": "#eff2fd", "noteBorderColor": "#bfcbf7", "noteTextColor": "#1d36a0", "activationBkgColor": "#dfe5fb", "activationBorderColor": "#5f7be6"}}}%% sequenceDiagram participant App as Your backend participant L as Ledger participant S as Stripe actor C as Customer App->>L: POST /v1/billing/attach L->>S: Create Checkout Session L-->>App: checkoutUrl (status pending_checkout) App->>C: Redirect to checkoutUrl C->>S: Pays on Checkout S-->>C: Redirect to your success URL S->>L: invoice.paid (signed webhook) Note over L: Plan active, credits granted App->>L: GET /v1/customers L-->>App: availableCredits ``` The redirect back to your success URL happens at about the same time as the webhook, but it proves nothing: anyone can open that URL. Read the customer from your backend, or refresh your billing page, once the webhook has landed. ## What attach does | You attach | Customer state | Result | | - | - | - | | A free plan | Any | Active at once. `{ subscriptionId, status: "active" }` | | A paid plan | No saved card | Pending. `{ checkoutUrl, subscriptionId }` | | A paid plan | Has a saved card in Stripe | Charged straight away. `{ subscriptionId, status: "active" }`, or `402 payment_failed` if declined | | A credit pack | Any | Pending. `{ checkoutUrl, addOnPurchaseId }` | A *saved card* is the default payment method on the customer's Stripe customer. When Ledger charges it, the response arrives after Stripe has confirmed the invoice, so the credits are already there. A declined card creates no subscription, and the customer can attach again. A card that needs 3-D Secure isn't charged off-session: attach returns a `checkoutUrl` for the same subscription, and the customer authenticates on Checkout. Attaching the same plan or pack again while its Checkout is open returns the same `checkoutUrl`, so retries are safe. Once Stripe expires an unpaid Checkout (after 24 hours), the next attach opens a new one. ## Subscription statuses [List customer subscriptions](/api-reference/customers/subscriptions) returns each subscription's `status`: | Status | Meaning | | - | - | | `pending_checkout` | Checkout is open; nothing is granted yet. | | `active` | The plan is running and its credits are available. | | `canceled` | The subscription ended, or a paid plan replaced this free plan. | | `incomplete_expired` | Checkout expired or failed before payment. | | `past_due`, `unpaid`, `paused` | Stripe is waiting on a payment. The plan and its credits stay, but it gets no overage and can't be upgraded or downgraded; the next renewal's credits come once that invoice is paid. `GET /v1/customers` shows it as `currentPlan.status`. | | `incomplete`, `trialing` | Mirrored from Stripe for a subscription whose first payment needs attention. | ## Renewals Stripe renews paid plans every month. Ledger grants the new period's credits when Stripe creates the renewal invoice, about an hour before it charges the card, so a customer isn't stuck at zero while the payment goes through. If Stripe voids that invoice, the credits it granted end. Free plans renew in Ledger, at the same time of day each month from when they started. The previous period's allowance expires at the period's end, whether or not it was spent. ## Plan changes An [upgrade](/configure-pricing/upgrades-and-downgrades#upgrade) charges the full new price now and starts a new period; the old plan's unspent credits stay until the old period would have ended, and are spent first. A [downgrade](/configure-pricing/upgrades-and-downgrades#downgrade) starts the cheaper plan at the next renewal by default, or now with prorated credits. [Cancelling at period end](/configure-pricing/upgrades-and-downgrades#cancel-at-period-end) keeps the plan and its credits until the paid period ends. Ledger doesn't refund a cancellation; cancelling immediately is done in Stripe, and Ledger follows Stripe's `customer.subscription.deleted` event. ## Refunds and disputes Refund and dispute payments in Stripe. Ledger follows Stripe's signed events, and changes credits only for a credit pack refunded in full or lost to a dispute. | What happens in Stripe | What Ledger does | | - | - | | A credit pack is refunded in full | The pack's unused credits expire. Credits already spent stay spent, so the balance never goes negative. | | A credit pack is refunded in part | Recorded on the customer, no change to credits. Once later refunds make it a full refund, the unused credits expire. | | A subscription invoice is refunded, with or without tax | Recorded on the customer. The plan's credits stay; remove credits with a [balance adjustment](/customers/balance-adjustments), or cancel the subscription, if you need to. | | A payment is disputed | Recorded and the customer is marked. `GET /v1/customers` returns `dispute`, the most serious one: `open` while Stripe is waiting on it, then `won` or `lost`. Credits stay while it's open, and when it's won. | | A dispute over a credit pack is lost | The money went back to the cardholder, as for a full refund: the pack's unused credits expire. A lost dispute over a subscription invoice keeps the plan's credits. | | A renewal payment fails | Recorded on the subscription until its invoice is paid or voided. Credits already granted stay, and Stripe's `past_due` or `unpaid` status shows on the plan. | The dashboard's customer page lists the customer's refunds and disputes, with what each did to credits. Canceling a subscription in Stripe ends the plan as [cancellation](/configure-pricing/upgrades-and-downgrades#cancel-at-period-end) does. ## Credit packs A pack's credits are granted when Stripe's signed `checkout.session.completed` event confirms the payment. Stripe also sends the customer an invoice for the pack, with a line such as "Top-up – 500 credits". The invoice never grants the credits a second time. ## When something goes wrong | Symptom | Cause | What to do | | - | - | - | | Status stays `pending_checkout` after paying | The webhook hasn't reached Ledger | Check **Settings › Stripe** shows the webhook as **Receiving**. Ledger waits for the signed event. | | `502 stripe_webhook_unhealthy` from attach | The endpoint is missing, disabled or misconfigured in Stripe | Choose **Check again** in **Settings › Stripe**, or reconnect Stripe. | | `409 subscription_exists` | The customer already has an active or pending plan | [Upgrade or downgrade](/configure-pricing/upgrades-and-downgrades) a paid plan, or wait for the pending Checkout to finish or expire. | | `409 purchase_pending` | A different pack's Checkout is open | Let it finish or expire, then attach again. | | The customer paid but got no credits | Ledger refused the invoice (for example, it had a coupon) | Grant the credits with a [balance adjustment](/customers/balance-adjustments) that names the invoice, or refund it in Stripe. | [Errors](/errors) lists every code. ## Next steps > [card] [Display billing](/getting-started/display-billing) > > Put the lifecycle to work in your app. > [card] [Stripe](/stripe) > > The Stripe account, endpoint and events Ledger uses. # Catalog Source: https://docs.integrateledger.com/concepts/catalog Features, plans and credit packs describe what you sell and what each action costs. Your catalog is what you sell, described in credits. Each environment has its own, and you keep it in your repo with the [CLI](/cli). | Item | What it is | Example | | - | - | - | | [**Feature**](/configure-pricing/features-and-credits) | An action in your app and what one use costs | `chat_message` costs 1 credit | | [**Plan**](/configure-pricing/plans) | A monthly allowance, [free](/configure-pricing/free-plans) or paid | `pro` grants 1,000 credits for \$20 a month | | [**Credit pack**](/configure-pricing/credit-packs) | A one-time purchase of credits that never expire | `credits_500` sells 500 credits for \$10 | Credit packs are called *add-ons* in the API (`/v1/add-ons`, `addOnId`), with `type: "credit_pack"`. ## Plan versions Plans are versioned. When a plan's credits, price or overage terms change, customers already on it keep the old version and customers who start it afterwards get the new one. Publishing the same values again changes nothing (`"changed": false`). Features and packs change in place. ## IDs and limits | Field | Rules | | - | - | | `id` | 1–100 characters: letters, numbers, `_` and `-`. Can't be changed. | | `name` | 1–120 characters, shown to customers on Stripe Checkout. | | `creditCost`, `monthlyCredits`, `creditAmount` | Whole numbers above 0. | | `monthlyPriceCents` | Whole number, 0 or more, in US cents. | | `priceCents` | Whole number above 0, in US cents. | All prices are in USD, and all plans are monthly. ## Archiving Removing an item from your catalog file archives it, which hides it from new checks, tracks and purchases and keeps its history: * An archived **feature** returns `404 feature_not_found` from `check` and `track`. Its recorded usage stays. * An archived **plan** can't be attached. Customers already on it keep their subscription and credits; nothing is cancelled in Stripe. * An archived **credit pack** can't be bought. A Checkout already open can still complete. Publishing an archived item's ID again brings it back. ## Stripe sync Paid plans sync to Stripe after each change. A plan's `stripeSyncStatus` is `pending` while the sync runs, `synced` once Stripe has the Product and Price, or `failed`; a free plan's is `null`. Packs don't sync ahead of time: Ledger prices each pack's Checkout when it opens. If a paid plan hasn't synced when you attach it, attach syncs it first, and returns `502 stripe_checkout_failed` if Stripe refuses. # Credits and balances Source: https://docs.integrateledger.com/concepts/credits Where a customer's credits come from, when they expire, and the order Ledger spends them in. A customer's balance is the sum of their unexpired **grants**, minus what they've spent. Every grant comes from a plan, a credit pack or a manual adjustment, and Ledger records which grant each tracked use was charged to. ## Grants | Grant | Arrives | Expires | | - | - | - | | Free plan allowance | When you attach the plan, then each month | End of its period | | Paid plan allowance | When Stripe confirms the first payment, then with each renewal invoice | End of its period | | Credit pack | When Stripe confirms the payment | Never | | Manual adjustment | When you [add it](/customers/balance-adjustments) | When you choose, or never | A plan allowance is the plan's `monthlyCredits`; a pack grants its `creditAmount`. Creating a customer grants nothing. A customer can hold pack credits with no plan at all. ## Spend order When [`track`](/api-reference/usage/track) records a use, Ledger spends grants in this order: ```mermaid theme={"system"} %%{init: {"theme": "base", "themeVariables": {"fontFamily": "IBM Plex Sans, sans-serif", "fontSize": "14px", "primaryColor": "#f7f6f3", "primaryBorderColor": "#c4bfb5", "primaryTextColor": "#1a1816", "lineColor": "#6b655c", "edgeLabelBackground": "#ffffff"}}}%% flowchart LR plan["1 · Plan allowance"] -- "used up" --> packs["2 · Credit packs"] packs -- "used up" --> overage["3 · Overage
paid plans"] overage -- "cap reached" --> refused["Refused · 409"] style plan fill:#eff2fd,stroke:#2443c4 ``` 1. **Plan allowances**, the one that expires soonest first. After an [upgrade](/configure-pricing/upgrades-and-downgrades), the old plan's leftover credits expire first, so they're spent before the new plan's. 2. **Credit packs and manual adjustments**, the one that expires soonest first, then oldest first. Packs never expire, so they're kept for last. 3. **Overage**, on a paid Sandbox plan with [overage](/configure-pricing/overage) terms, up to the customer's monthly cap. A use that costs more than one grant holds is split across grants in that order. A use is never split between credits and a refusal: if the balance and overage together can't cover the whole cost, `track` refuses it and charges nothing. ## Worked example A customer on Pro (1,000 credits a month) has bought one 500-credit pack, then tracks 1,200 credits of usage this period: | Grant | Granted | Spent | Left | | - | - | - | - | | Pro allowance, Nov 7 – Dec 7 | 1,000 | 1,000 | 0 | | Top-up pack | 500 | 200 | 300 | | **Balance** | | | **300** | On Dec 7 Pro renews: a new allowance of 1,000 arrives and the old one expires. The balance becomes 1,300, and the pack's 300 credits wait until the new allowance is spent. A check reads the balance without reserving anything, so two requests can both see `allowed: true` for the last credit; `track` decides each in its own transaction, and one of them gets `409 insufficient_credits`. [Balances](/customers/balances) shows how to read a customer's balance. ## Next steps > [card] [Gating features](/getting-started/gating) > > Gate features on the balance and record each use once. > [card] [Billing lifecycle](/concepts/billing-lifecycle) > > When paid grants arrive, renew and end. # Sandbox and Live Source: https://docs.integrateledger.com/concepts/environments Every workspace has two environments. Build and test in Sandbox, then take real payments in Live. A Ledger workspace has two environments, **Sandbox** and **Live**. Each one has its own API keys, catalog, customers, balances, usage history and Stripe account. Nothing is shared between them, so a test in Sandbox can never touch a real customer. ```mermaid theme={"system"} %%{init: {"theme": "base", "themeVariables": {"fontFamily": "IBM Plex Sans, sans-serif", "fontSize": "14px", "primaryColor": "#f7f6f3", "primaryBorderColor": "#c4bfb5", "primaryTextColor": "#1a1816", "lineColor": "#6b655c", "clusterBkg": "#ffffff", "clusterBorder": "#dbd7cf"}}}%% flowchart LR sk["ledger_test_ key"] --> sd["Sandbox
catalog · customers · usage"] sd <-- "Checkout · webhook" --> ss["Stripe test account"] lk["ledger_live_ key"] --> ld["Live
catalog · customers · usage"] ld <-- "Checkout · webhook" --> ls["Your live Stripe account"] style ld fill:#eff2fd,stroke:#2443c4 ``` ## The API key chooses the environment There's one API host, `https://api.integrateledger.com`, for both environments. Ledger reads the environment from the key on each request: | Key prefix | Environment | Reaches | | - | - | - | | `ledger_test_` | Sandbox | Sandbox catalog, customers and Stripe test account | | `ledger_live_` | Live | Live catalog, customers and your live Stripe account | A request body or header can't switch environments. A Sandbox key that asks for a Live customer gets `404 customer_not_found`, because that customer doesn't exist in Sandbox. The same `customerId` can exist in both environments as two separate customers. In the dashboard, the switch at the top of the sidebar chooses which environment you're looking at. Sandbox pages carry a hatched band across the top; Live pages carry an ink band. ## Sandbox Sandbox is ready the moment your workspace exists: * **A Stripe test account.** Ledger creates one for your workspace on its Stripe Connect platform, shown in **Settings › Stripe** as created by Ledger. Paid plans sync to it, and Checkout runs on it, with no setup. You can connect your own `sk_test_` key instead (see [Stripe](/stripe)). * **Test payments.** Checkout takes [Stripe test cards](https://docs.stripe.com/testing), such as `4242 4242 4242 4242`. * **Simulated time.** Move a customer through renewals on a Stripe test clock. See [Simulated time](/customers/simulate-time). * **Overage.** Paid plans can bill usage past their allowance. See [Overage](/configure-pricing/overage). * **Deleting customers** removes their usage events too, so you can start over cleanly. ## Live Live takes real payments from your own Stripe account. Before it can, you finish a short setup in the dashboard: a Live API key, your live Stripe key, the payment webhook, Checkout redirect URLs and a paid plan synced to Stripe. Then you activate Live from Settings, and live charges start straight away. Ledger can suspend a workspace that breaks its terms. [Deploy](/getting-started/deploy) walks through each step. Free plans work in Live as soon as you have a Live API key, because they never touch Stripe. ### What Live doesn't support yet | Capability | Sandbox | Live | | - | - | - | | Free and paid monthly plans | Yes | Yes | | Credit packs | Yes | Yes | | Upgrades and downgrades | Yes | Yes | | Stop or resume renewal | Yes | Yes | | [Overage](/configure-pricing/overage) | Yes | Yes | | Simulated time (test clocks) | Yes | No | | [Automatic tax](/concepts/tax) (Stripe Tax) | Yes | Yes | | Promotion codes, or changing the plan of a subscription with discounts | No | No | Deleting a Live customer keeps its usage events as the record of what the customer used. ## Next steps > [card] [Deploy and go live](/getting-started/deploy) > > Finish Live setup and take your first real payment. > [card] [API keys and permissions](/api-keys) > > Create keys for each environment and limit what they can do. # Tax Source: https://docs.integrateledger.com/concepts/tax Charge sales tax, VAT and GST with Stripe Tax on everything Ledger sells. Turn on **Automatic tax** in **Settings › Tax** and Ledger asks Stripe Tax to calculate tax on every Checkout, subscription and invoice it creates. Stripe works out the rate from where the customer is. The setting is per environment: Sandbox and Live are turned on separately, and both start off. ## Set up Stripe Tax first Stripe Tax is set up in your own Stripe account, in **Settings › Tax**. Ledger checks it and shows what's missing: | In Stripe | Why | | - | - | | A head office address | Without it, Stripe refuses every request that asks for tax. | | A default tax behavior (exclusive or inclusive) | Ledger creates your plan prices without one, so the account default applies. Without it, Stripe refuses Checkout. | | A registration wherever you collect tax | Without one, nothing fails, but Stripe charges \$0 tax. | Ledger only lets you turn Automatic tax on once the first two are done. Products use your account's default tax code unless you set one on the product in Stripe. For a SaaS product, set the default to Software as a service: `txcd_10103001` for business use or `txcd_10103000` for personal use. Credit packs and overage are usually taxed like the service they pay for, so they can use the same code. > [!NOTE] > > Stripe calculates and collects the tax; you're still responsible for it. Stripe's threshold monitoring shows where your Stripe sales may require you to register, but you register before collecting, and file and pay the tax, yourself or through Stripe's filing partners. Stripe charges \$0 tax where you have no registration. > [!NOTE] > > A Sandbox account that Ledger created for you has no Stripe Dashboard. To try Stripe Tax in Sandbox, connect your own Stripe sandbox key in **Settings › Stripe**. ## What gets taxed | Purchase | With Automatic tax on | | - | - | | Paid plan through Checkout | Checkout asks for a billing address and a tax ID, saves them on the Stripe customer, and adds tax. The subscription keeps automatic tax, so every renewal is taxed. | | Credit pack through Checkout | The same. The pack's invoice shows the tax. | | Paid plan on a saved card | Taxed when Stripe knows the customer's location. Without one, the plan is charged without tax. | | Upgrade | Taxed when the subscription already has tax, or when the customer has a location (Ledger turns tax on for the subscription). Otherwise charged without tax. | | Overage on a renewal | Added to the renewal invoice, and taxed with it. | | Final overage invoice | Taxed when the customer has a location. Otherwise charged without tax, so it can still be collected. | | Prorated refund on an immediate downgrade | Refunds the share of the price difference plus the tax charged on it. | Ledger never emails an invoice: every purchase is charged to the customer's card, so a customer without an address is charged without tax rather than refused. ## Credits and amounts * Customers get a plan's full credits whatever the tax. Ledger checks the plan's line on the invoice at your price, before tax, and accepts the tax Stripe adds. This is true even if you turned on tax in Stripe directly, and for subscriptions with tax that you [import from Stripe](/migrate-from-stripe). * Discounts, coupons and customer balances are still not supported: an invoice with one grants no credits, and Ledger alerts. * Overage prices and the overage cap are **before tax**. A $50 cap bills at most $50 of overage, plus its tax. ## Turning it on or off Turning Automatic tax on only changes new purchases. Subscriptions that started without tax keep renewing without it until the customer upgrades, or until you turn on tax for them in Stripe (**Tax → Migrations** in the Stripe Dashboard). Turning it off stops Ledger asking for tax on new purchases; subscriptions that already have tax keep it in Stripe until you turn it off there. # Credit packs Source: https://docs.integrateledger.com/configure-pricing/credit-packs Sell a fixed number of credits as a one-time purchase that never expires. A credit pack is a one-time purchase: a fixed `creditAmount` for a `priceCents`. Customers buy one when they run out before their plan renews, or instead of a plan. Pack credits never expire, and Ledger spends them after the plan allowance. In the API and the JSON file, packs are *add-ons* with `type: "credit_pack"` (`/v1/add-ons`, `addOnId`). ## Define a pack ```ts ledger.config.ts theme={"system"} creditPacks: [ creditPack({ id: "credits_500", name: "Top-up", creditAmount: 500, priceCents: 1000 }), ], ``` ```json ledger.catalog.json theme={"system"} "addOns": [ { "id": "credits_500", "name": "Top-up", "type": "credit_pack", "creditAmount": 500, "priceCents": 1000 } ] ``` Publish with `npx integrateledger-cli push`. Packs aren't synced to Stripe ahead of time; Ledger prices each Checkout when it opens. ## Sell it Offer the pack when [`check`](/getting-started/gating) returns `allowed: false` or `track` returns `409 insufficient_credits`. The customer doesn't need a plan. Attach it with `addOnId` and redirect to Checkout: ```typescript TypeScript theme={"system"} const purchase = await billing.attach({ customerId, addOnId: "credits_500" }); if ("checkoutUrl" in purchase) { return res.redirect(303, purchase.checkoutUrl); } ``` ```python Python theme={"system"} purchase = billing.attach(customer_id=customer_id, add_on_id="credits_500") return redirect(purchase["checkoutUrl"], code=303) ``` The response is `{ checkoutUrl, addOnPurchaseId }`. Ledger grants the credits when Stripe's signed `checkout.session.completed` event confirms the payment; Stripe also sends the customer an invoice with a line such as "Top-up – 500 credits". [Balances](/customers/balances#purchases) shows the purchase history. Setting up Checkout redirects and testing are covered in [Display billing](/getting-started/display-billing#sell-through-checkout). ## Rules * **One open pack Checkout at a time.** Attaching a different pack while one is open returns `409 purchase_pending`. Attaching the same pack returns the same URL. Stripe expires an unpaid Checkout after 24 hours. * **Prices lock while a Checkout is open.** Changing the pack's `creditAmount` or `priceCents` returns `409 purchase_pending` until it completes or expires. Renaming is fine. * **Archiving stops new sales.** Removing a pack from the file archives it; a Checkout already open can still complete. * **Refunds happen in Stripe.** A full refund expires the pack's unused credits; credits already spent stay spent, so the balance never goes negative. A partial refund changes no credits. A lost dispute expires the unused credits as a full refund does. See [Refunds and disputes](/concepts/billing-lifecycle#refunds-and-disputes). > [!NOTE] > > **Other ways.** Packs can also be managed in the dashboard under **Add-ons**, or with the [add-on endpoints](/api-reference/add-ons/create). # Features and credits Source: https://docs.integrateledger.com/configure-pricing/features-and-credits Price every metered action in one currency, credits, so plans and packs work across all of them. Every metered action in your app is a **feature** with a `creditCost`: how many credits one successful use spends. Plans and packs grant credits, not uses, so one balance covers every feature, and you can price an expensive action higher without a new plan. ```ts ledger.config.ts theme={"system"} features: [ feature({ id: "generate_image", name: "Generate an image", creditCost: 1 }), feature({ id: "generate_image_hd", name: "Generate an HD image", creditCost: 4 }), feature({ id: "export_pdf", name: "Export a PDF", creditCost: 3 }), ], ``` ```json ledger.catalog.json theme={"system"} "features": [ { "id": "generate_image", "name": "Generate an image", "creditCost": 1 }, { "id": "generate_image_hd", "name": "Generate an HD image", "creditCost": 4 }, { "id": "export_pdf", "name": "Export a PDF", "creditCost": 3 } ] ``` A Pro plan with 1,000 credits then covers 1,000 images, 250 HD images, or any mix. Your backend passes the feature `id` to [`check` and `track`](/getting-started/gating). ## Choosing costs * **Start from your own cost.** If an HD image costs you four times a standard one, make it 4 credits. Set plan credits so the plan's price covers a heavy month. * **Keep costs whole numbers.** `creditCost` is a whole number above 0. For cheap actions, make a credit worth less: 10 credits per message rather than 0.1. * **Changing a cost applies from then on.** Usage already tracked keeps the cost it was charged at, and a feature change doesn't create a plan version. Removing a feature from the file archives it: `check` and `track` return `404 feature_not_found` for it, and its recorded usage stays. > [!NOTE] > > **Other ways.** Features can also be managed in the dashboard under **Features**, or with the [feature endpoints](/api-reference/features/create). # Free plans Source: https://docs.integrateledger.com/configure-pricing/free-plans Give every new customer a monthly allowance without Stripe, and move them to a paid plan when they're ready. A free plan is a plan with `monthlyPriceCents: 0`. It never touches Stripe: attaching it starts it at once, without Checkout, and Ledger renews it every month at the same time of day it started. ```ts ledger.config.ts theme={"system"} plan({ id: "free", name: "Free", monthlyCredits: 50, monthlyPriceCents: 0 }), ``` ## Start it on signup Attach it when the customer is created, as in [Setup](/getting-started/setup): ```typescript TypeScript theme={"system"} await billing.createOrGetCustomer({ customerId: user.id }); await billing.attach({ customerId: user.id, planId: "free" }); ``` ```python Python theme={"system"} billing.create_or_get_customer(customer_id=user.id) billing.attach(customer_id=user.id, plan_id="free") ``` The response is `{ "subscriptionId": 1, "status": "active" }`, and the credits are available straight away. Free plans work in Live as soon as you have a Live key, before live billing is activated. ## Move to a paid plan Attach the paid plan and send the customer to Checkout, as in [Display billing](/getting-started/display-billing#sell-through-checkout). They keep the free plan and its credits while Checkout is open. When Stripe confirms the payment, the free plan ends, its unspent credits expire, and the paid allowance begins. Pack credits stay. If they abandon Checkout, nothing changes. When a paid plan ends, the customer has no plan until you attach one. Attach the free plan again to give them their allowance back. # Overage Source: https://docs.integrateledger.com/configure-pricing/overage Let customers on a paid plan keep working past their allowance, billed on their next invoice, up to a monthly cap. Without overage, a customer who runs out of credits is refused until their plan renews or they buy a pack. With overage, a paid plan keeps them working: Ledger records the extra credits, and Stripe bills them on the next renewal invoice, up to a monthly cap. Buyers see the terms before they pay. Stripe's Checkout shows the plan's price, and above its Subscribe button Ledger adds what the plan includes and how overage bills, with the customer's own cap or negotiated rate when they have one: "Includes 1,000 credits each month. Unused credits don't roll over. After that, extra credits are billed on your next invoice at $0.50 per 10 credits, rounded up to the next 10, up to $20.00 a month. Renews monthly until you cancel. Extra credits used before you cancel are billed on a final invoice." ## Add overage to a plan Set `overageUnitAmountCents`, the price per `overageBillingUnits` credits (default 1), and optionally a default monthly cap, `overageCapCents`. Leave the cap out for no cap. This **Scale** plan charges $0.01 per extra credit, capped at $20 a month: ```ts ledger.config.ts theme={"system"} plan({ id: "scale", name: "Scale", monthlyCredits: 5000, monthlyPriceCents: 5000, overageUnitAmountCents: 1, overageBillingUnits: 1, overageCapCents: 2000, }), ``` ```json ledger.catalog.json theme={"system"} { "id": "scale", "name": "Scale", "monthlyCredits": 5000, "monthlyPriceCents": 5000, "overageUnitAmountCents": 1, "overageBillingUnits": 1, "overageCapCents": 2000 } ``` Adding overage to an existing plan is a new [plan version](/configure-pricing/plans#change-a-plan): customers already on it keep their current terms. ## How it's decided Your integration doesn't change. Ledger spends plan credits, then packs, then overage. A use that costs more than what's left spends the rest and records the remainder as overage, as long as the period's overage cost stays within the cap. A use that would pass the cap is refused whole with `409 overage_cap_reached`; one that lands exactly on it is allowed. `check` makes the same decision, so `allowed: true` can mean the use will be billed as overage. A customer with 1,000 credits left who tracks a 2,500-credit use gets: ```json Response theme={"system"} { "allowed": true, "availableCredits": 0, "chargedCredits": 2500, "overageCredits": 1500, "overageRemainingCents": 500, "request_id": "render_0003", "idempotent": false } ``` `overageCredits` is this period's overage so far, and `overageRemainingCents` is what's left under the cap ($20.00 − $15.00). The period's cost is `ceil(credits ÷ overageBillingUnits) × overageUnitAmountCents`, worked out for each rate separately. ## Per-customer caps and rates A customer's own cap replaces the plan default, higher or lower. `0` turns overage off for them and `null` restores the default. An override gives one customer a negotiated rate; the plan must already have overage terms, or it returns `409 plan_has_no_overage`. ```typescript TypeScript theme={"system"} await billing.setCustomerOverageCap({ customerId, body: { capCents: 5000 } }); // $0.01 per 2 credits for this customer on Scale. await billing.setCustomerOverageOverride({ customerId, body: { planId: "scale", unitAmountCents: 1, billingUnits: 2 }, }); ``` ```python Python theme={"system"} billing.set_customer_overage_cap(customer_id=customer_id, cap_cents=5000) # $0.01 per 2 credits for this customer on Scale. billing.set_customer_overage_override( customer_id=customer_id, plan_id="scale", unit_amount_cents=1, billing_units=2 ) ``` Both take effect on the next `track`. Leave out `planId` to apply an override to whichever overage plan the customer is on, so it carries across upgrades; a plan-specific override wins. Send `unitAmountCents: null` to clear it. The cap response's `overage.capSource` says where the effective cap comes from: `customer`, `override`, `plan`, or `null`. ## How it's billed Each renewal invoice gets one line per rate for the period that just ended, such as "Scale – overage, 1,500 credits". After a plan change, the old plan's overage bills at its own rate. When a subscription ends, Ledger bills any remaining overage on a final invoice. In the dashboard, a customer in overage shows **In overage** and a meter against their cap. # Plans Source: https://docs.integrateledger.com/configure-pricing/plans Define monthly plans in your catalog file, publish them, and change their terms safely with plan versions. A plan grants `monthlyCredits` at the start of each monthly period, for `monthlyPriceCents` (US cents). A price of `0` makes it a [free plan](/configure-pricing/free-plans); anything else is a paid plan that syncs to Stripe and is bought through [Checkout](/getting-started/display-billing#sell-through-checkout). ## Define a plan Add it to your catalog file, then publish with `npx integrateledger-cli push`: ```ts ledger.config.ts theme={"system"} plans: [ plan({ id: "free", name: "Free", monthlyCredits: 50, monthlyPriceCents: 0 }), plan({ id: "pro", name: "Pro", monthlyCredits: 1000, monthlyPriceCents: 2000 }), plan({ id: "scale", name: "Scale", monthlyCredits: 5000, monthlyPriceCents: 5000 }), ], ``` ```json ledger.catalog.json theme={"system"} "plans": [ { "id": "free", "name": "Free", "monthlyCredits": 50, "monthlyPriceCents": 0 }, { "id": "pro", "name": "Pro", "monthlyCredits": 1000, "monthlyPriceCents": 2000 }, { "id": "scale", "name": "Scale", "monthlyCredits": 5000, "monthlyPriceCents": 5000 } ] ``` * **Credits last one period.** Unspent plan credits expire when the period ends; the next period starts with a fresh allowance. [Credits and balances](/concepts/credits) has the spend order. * **Paid plans sync to Stripe** as a Product with a monthly Price. After a push the CLI reports each plan's sync: `synced`, `pending`, `failed`, or `awaiting_connection` until Stripe is connected. **Plans** in the dashboard has a retry for a plan that failed. * **The `name` is what customers see** on Stripe Checkout and invoices. ## Change a plan Edit the file and run `npx integrateledger-cli diff` before you push. Changing a plan's name, credits, price or overage creates a **new plan version**: ``` Plans ~ pro Pro new version · 1,000 credits/month, $29.00/month monthlyPriceCents $20.00 → $29.00 New plan version: customers who start this plan from now get these terms; current subscribers keep theirs Stripe: a new $29.00/month Price on the "Pro" Product ``` Current subscribers keep their terms. To move one onto different terms, [upgrade or downgrade](/configure-pricing/upgrades-and-downgrades) them to another plan. Removing a plan from the file **archives** it: it can't be attached any more, and customers already on it keep it. Nothing is cancelled in Stripe. Adding the ID back restores it. ## Offer several tiers Customers hold one plan at a time. List your plans on the billing page, sorted by price, and offer the pricier ones as upgrades and the cheaper ones as downgrades. [Display billing](/getting-started/display-billing) wires up the buttons. To let customers keep working past their allowance, add [overage](/configure-pricing/overage). To sell credits without a subscription, add [credit packs](/configure-pricing/credit-packs). > [!NOTE] > > **Other ways.** Plans can also be created in the dashboard under **Plans**, or with the [plan endpoints](/api-reference/plans/create). `npx integrateledger-cli pull` brings those edits back into your file. # Upgrades, downgrades and cancellation Source: https://docs.integrateledger.com/configure-pricing/upgrades-and-downgrades Move a paying customer to a pricier or cheaper plan, or stop their plan renewing at the end of the period. A customer on a **free** plan, or with no plan, changes plan with [`attach`](/getting-started/display-billing#sell-through-checkout). A customer on an active **paid** plan changes plan with the calls on this page. Each one takes an idempotency key that identifies this one intended change: store it and reuse it on every retry, so a retry never charges or refunds twice. | The customer wants | Call | When it takes effect | | - | - | - | | A pricier paid plan | `upgradePaidPlan` | Now, with a full charge and a new period | | A cheaper paid plan | `downgradePaidPlan` | At the period end (default), or now | | To stop paying | `setSubscriptionCancellation` | At the period end | One change at a time: while an upgrade is waiting for payment, or a downgrade or cancellation is scheduled, the others return `409` (`upgrade_pending`, `downgrade_scheduled`, `cancellation_scheduled`). Undo the scheduled one first. ## Upgrade ```typescript TypeScript theme={"system"} const upgrade = await billing.upgradePaidPlan({ idempotencyKey: `upgrade_${customerId}_scale`, body: { customerId, planId: "scale" }, }); ``` ```python Python theme={"system"} upgrade = billing.upgrade_paid_plan( customer_id=customer_id, plan_id="scale", idempotency_key=f"upgrade_{customer_id}_scale", ) ``` Stripe charges the full new price and starts a new monthly period. The old plan's unspent credits stay until the old period would have ended, and are spent first; packs are untouched. Act on `status`: | `status` | Meaning | What to do | | - | - | - | | `applied` | Charged; the new plan and its credits are active. | Show the new plan. | | `pending_payment` | The invoice needs the customer, usually for 3-D Secure. | Send them to `paymentUrl`; the upgrade applies when they pay. | | `processing` | Ledger is still reconciling with Stripe. | Retry with the same key in a moment. | | `closed` | The payment failed or expired. Nothing changed. | Offer it again with a **new** key. | The target must be a synced paid plan that costs more (`400 ineligible_upgrade`), and the Stripe subscription can't carry discounts (`400 ineligible_stripe_subscription`). Tax is fine: a taxed subscription stays taxed, and with [Automatic tax](/concepts/tax) on, an untaxed one starts being taxed when Stripe knows the customer's location. On `502 stripe_upgrade_failed` or `503 upgrade_reconciliation_required`, retry with the **same** key. ## Downgrade ```typescript TypeScript theme={"system"} const downgrade = await billing.downgradePaidPlan({ idempotencyKey: `downgrade_${customerId}_starter`, body: { customerId, planId: "starter" }, // timing: "period_end" by default }); ``` ```python Python theme={"system"} downgrade = billing.downgrade_paid_plan( customer_id=customer_id, plan_id="starter", idempotency_key=f"downgrade_{customer_id}_starter", ) # timing="period_end" by default ``` By default the customer keeps the current plan and its credits until the period ends, and the renewal starts the cheaper plan. The response is `scheduled`, with `effectiveAt` set to the renewal. Undo it any time before then with `cancelScheduledDowngrade({ downgradeId, customerId })`; `getScheduledDowngrade` returns the one that's pending. With `timing: "immediately"`, the cheaper plan starts now and the renewal date stays. The old plan's remaining credits end (`oldPlanCredits: "keep"` leaves them), the new plan's credits are prorated to the rest of the period, and `refund: "prorated"` refunds the unused share of the price difference to the card, plus the tax charged on it. The response is `applied`. Overage from the old plan always bills at its own rate. The target must be a cheaper, synced paid plan (`400 ineligible_downgrade`). Moving to a free plan isn't a downgrade: cancel at period end, then attach the free plan. ## Cancel at period end Renewal is set on the subscription's numeric `id`. Find the active paid subscription in `listCustomerSubscriptions` and offer the control only when its `cancellation.manageable` is `true`: ```typescript TypeScript theme={"system"} const { items } = await billing.listCustomerSubscriptions({ customerId }); const paid = items.find( (item) => item.status === "active" && item.cancellation?.manageable === true, ); if (paid) { await billing.setSubscriptionCancellation({ customerId, subscriptionId: paid.id, body: { cancelAtPeriodEnd: true }, // false resumes }); } ``` ```python Python theme={"system"} subscriptions = billing.list_customer_subscriptions(customer_id=customer_id) paid = next( ( item for item in subscriptions["items"] if item["status"] == "active" and (item["cancellation"] or {}).get("manageable") ), None, ) if paid: billing.set_subscription_cancellation( customer_id=customer_id, subscription_id=paid["id"], cancel_at_period_end=True, # False resumes ) ``` The response is `{ "cancelAtPeriodEnd": true, "cancelsAt": "2026-12-07T07:25:02.000Z" }`. Show `cancelsAt` and a **Resume** button: the plan and its credits last until then, and Stripe doesn't charge again. When the period ends, the subscription is `canceled`, its credits expire, and packs stay. Attach your free plan to give the customer an allowance again. `cancellation` is `null` for free plans, pending Checkouts, and when Ledger couldn't read the subscription from Stripe. `409 subscription_not_manageable` means Stripe has a change Ledger won't override, such as a pause or a custom cancellation date; resolve it in Stripe. Ledger never refunds a cancellation. To end a subscription immediately, cancel it in Stripe and Ledger follows. > [!NOTE] > > **Other ways.** Customers in the dashboard have a **Manage subscription** sheet for one-off changes. Over HTTP, see [upgrade](/api-reference/billing/upgrade), [downgrade](/api-reference/billing/downgrade) and [cancellation](/api-reference/customers/cancellation). Every error code is in [Errors](/errors). # Balance adjustments Source: https://docs.integrateledger.com/customers/balance-adjustments Add or remove a customer's credits by hand, with a reason, for goodwill credits, support fixes and missed payments. An adjustment changes a customer's credits outside a plan or credit pack: a goodwill credit after an outage, a support fix, or taking credits back after a refund. For one-off fixes, use **Adjust credits** on the customer's page in the dashboard. From code: ```typescript TypeScript theme={"system"} await billing.adjustCustomerBalance({ customerId, body: { credits: 500, reason: "Goodwill for the outage", idempotencyKey: `goodwill-${customerId}`, }, }); ``` ```python Python theme={"system"} billing.adjust_customer_balance( customer_id=customer_id, credits=500, reason="Goodwill for the outage", idempotency_key=f"goodwill-{customer_id}", ) ``` * **Positive `credits`** add a new grant that never expires, unless you set `expiresAt`. It's spent after plan credits, alongside packs ([spend order](/concepts/credits#spend-order)). * **Negative `credits`** remove credits in spend order and never take the balance below zero: asking for more returns `409 insufficient_credits` with the `availableCredits` you can remove. * **`reason` is required.** It shows on the customer's page and in billing history, with who made the change. * **`idempotencyKey` is required**, per customer. A retry with the same key and body returns the first adjustment and changes nothing; the same key with a different body returns `409 idempotency_key_reused`. The key needs the `billing:write` permission. ## Grant a payment Ledger didn't If a customer paid and Ledger granted nothing, for example an invoice with a coupon that Ledger refused, add the credits with the payment's `stripeInvoiceId`, or `stripeCheckoutSessionId` for a pack. Ledger checks with Stripe that the payment is paid and belongs to this customer, then marks it granted so it can't be granted twice (`409 stripe_object_already_granted`). [Adjust a customer's balance](/api-reference/customers/balance-adjustments) lists every error. # Balances Source: https://docs.integrateledger.com/customers/balances Read a customer's credits, current plan, subscriptions and purchases. A customer's balance is the sum of their unexpired grants from plans and packs, minus what they've spent. [Credits and balances](/concepts/credits) explains where grants come from and the order they're spent in. | You need | Call | Field | | - | - | - | | Whether one feature can run now | [`checkCredits`](/getting-started/gating) | `allowed` | | The balance and current plan | `getCustomer` | `availableCredits`, `currentPlan` | | What a tracked use left | [`trackUsage`](/getting-started/gating) | `availableCredits` | | Plan history, renewal controls | `listCustomerSubscriptions` | `items` | | Packs bought | `listCustomerPurchases` | `items` | ## The customer ```typescript TypeScript theme={"system"} const customer = await billing.getCustomer({ customerId }); ``` ```python Python theme={"system"} customer = billing.get_customer(customer_id=customer_id) ``` ```json Response theme={"system"} { "customerId": "user_123", "createdAt": "2026-11-07T07:23:35.086Z", "availableCredits": 1000, "currentPlan": { "id": "pro", "name": "Pro", "status": "active", "currentPeriodStart": "2026-11-07T07:25:02.000Z", "currentPeriodEnd": "2026-12-07T07:25:02.000Z" }, "overageCapCents": null, "overage": null } ``` * `availableCredits` is the prepaid balance, plan and pack credits together, and never goes below zero. * `currentPlan` is `null` when the customer has no plan. That isn't a zero balance: they may hold pack credits. * `currentPlan.status` is `pending_checkout` while a paid plan's Checkout is open; until it's paid, the customer keeps their previous plan's credits. * `overage` is this period's [overage](/configure-pricing/overage), or `null` when the plan has none. ## Subscriptions `listCustomerSubscriptions` returns each plan the customer has had, newest first. Each has its numeric `id`, `planId`, `status` (see [subscription statuses](/concepts/billing-lifecycle#subscription-statuses)), the current period, and a `cancellation` object that drives [renewal controls](/configure-pricing/upgrades-and-downgrades#cancel-at-period-end). ## Purchases `listCustomerPurchases` returns each credit pack the customer has bought, newest first, with its `status` (`pending_checkout` or `paid`) and `creditAmount`. While a purchase is `pending_checkout`, its `creditAmount` is `null` and nothing has been granted. Both lists return 25 items a page. Pass `limit` (up to 100), and the previous page's `nextCursor` as `cursor`, for more. ## In the dashboard **Customers** shows each customer's balance as a meter, one segment per grant. **Usage** lists every tracked request, charged or blocked, and opening one shows which grants paid for it. # Creating customers Source: https://docs.integrateledger.com/customers/creating-customers Create a Ledger customer for each user or organization in your app, keyed by your own ID. A customer is whoever pays in your app: a user, or an organization for team billing. Its `customerId` is your own ID for them, so you never store a Ledger ID. Create the customer when they sign up, and attach their first plan: ```typescript TypeScript theme={"system"} await billing.createOrGetCustomer({ customerId: user.id }); await billing.attach({ customerId: user.id, planId: "free" }); ``` ```python Python theme={"system"} billing.create_or_get_customer(customer_id=user.id) billing.attach(customer_id=user.id, plan_id="free") ``` * **It's safe to repeat.** `createOrGetCustomer` returns the existing customer if there is one, so you can also call it lazily, before a customer's first `check`. * **Creating grants nothing.** Credits come from attaching a [plan](/configure-pricing/plans) or buying a [pack](/configure-pricing/credit-packs). * **IDs are 1 to 255 characters**, and belong to one environment: the same `customerId` in Sandbox and Live is two separate customers. A Sandbox key asking for a Live customer gets `404 customer_not_found`. * **Ledger creates the Stripe customer** when it first needs one. To reuse one you already have, for example when [migrating from Stripe](/migrate-from-stripe), link it first. ## Read a customer `getCustomer` returns the balance and current plan. [Balances](/customers/balances) explains the fields. ## Delete a customer [`DELETE /v1/customers`](/api-reference/customers/delete) removes a customer; the SDKs don't have it yet. In Sandbox it removes their usage events too, so you can start over cleanly. In Live the usage events stay as the record of what the customer used. # Test with simulated time Source: https://docs.integrateledger.com/customers/simulate-time Move a Sandbox customer through renewals, overage and cancellation in minutes instead of months. Renewals happen once a month, which is too slow to test. In Sandbox, you can put a customer on a [Stripe test clock](https://docs.stripe.com/billing/testing/test-clocks) and move it forward. Stripe renews, invoices and charges as if the time had passed, and Ledger follows: grants, expiry, overage and cancellations all happen at the simulated time. Simulated time is Sandbox only. Live customers can't be on a test clock. ## Run a simulation **1. Open the customer** In the dashboard, switch to **Sandbox**, open **Customers**, and open the customer. Under the page header, **Run a simulation on this customer** has a **Run simulation** button. You can start before the customer has a paid plan, for example to test a signup at the end of a month, and attach the plan once the clock is running. **2. Choose a time and run** Pick a date and time (UTC) in the future and choose **Run simulation**. Ledger creates a test clock for the customer at the current time, then advances it. You can move up to two billing periods ahead at a time, or two years for a customer with no paid plan. Clocks only move forward. **3. Watch the renewal** Advancing takes a few seconds. When it passes a renewal, Ledger stops just after the period ends so it can add overage lines to the draft invoice, then carries on to your time. When both Stripe and Ledger have caught up, the sheet shows the new simulated time. The customer's page now shows **Simulated time** with the clock's time. Their balance, usage and invoices all use it. **4. Advance again, or finish** Choose **Advance time** to keep going. When you're done, choose **Finish simulation**. Stripe deletes the clock, with its Stripe customer and subscription. Ledger keeps the customer and its history; a free plan stays, a paid plan ends. ## Use the API while time is simulated `check`, `track` and `GET /v1/customers` work as usual and use the customer's simulated time: a grant that has expired on the clock is gone, and usage is recorded at the clock's time. In **Usage**, those requests are marked as simulated. While the clock is advancing, Stripe refuses changes to the customer, so wait until the dashboard shows the new simulated time before you track usage or change the plan. ## Scenarios worth testing | Scenario | How | | - | - | | A renewal | Attach a paid plan, then advance past `currentPeriodEnd`. The new allowance arrives and the old one expires. | | Overage on the invoice | On a plan with [overage](/configure-pricing/overage), track past the allowance, then advance past the renewal. The invoice carries an overage line. | | Cancellation | [Stop renewal](/configure-pricing/upgrades-and-downgrades#cancel-at-period-end), then advance past the period end. The subscription ends and its credits expire. | | An upgrade mid-period | Advance two weeks, then [upgrade](/configure-pricing/upgrades-and-downgrades). The new plan starts a new period from the simulated time. | Stripe deletes a test clock 30 days after it's created. # Errors Source: https://docs.integrateledger.com/errors How Ledger reports errors, and what to do about each one. Ledger uses HTTP status codes to say whether a request worked, and a short `error` code in the JSON body to say why it didn't. Codes are stable: branch on `error`, not on any other text. ```json Response (409) theme={"system"} { "error": "insufficient_credits", "availableCredits": 0, "creditCost": 1 } ``` Some errors carry extra fields that help you act, such as the balance on `insufficient_credits` or the `upgradeId` on an upgrade that needs a retry. ## Status codes | Status | Meaning | | - | - | | `200`, `201` | It worked. `201` means an item was created. | | `400` | The request is invalid: a missing or malformed field, or an action that isn't allowed in this state. | | `401` | The API key is missing, invalid, revoked, or not allowed to make this request. | | `402` | A saved card was declined. | | `404` | The customer, item or subscription doesn't exist in this environment. | | `409` | The request conflicts with the current state, such as a balance that's too low or a Checkout already open. | | `413` | The request body is larger than 1 MiB. | | `415` | The request body isn't sent as `application/json`. | | `422` | Stripe isn't connected in this environment. | | `429` | Too many requests for this key. Wait for `Retry-After` seconds, then retry. See [Rate limits](/rate-limits). | | `500` | Something went wrong in Ledger. Retry, and quote the `request_id` if it keeps happening. | | `502` | Stripe was unavailable, or refused the request. | | `503` | Live charges aren't enabled yet, or Ledger needs a retry to finish reconciling with Stripe. | Every response has an `x-request-id` header. Include it when you contact Ledger about a request. ## Retrying safely These requests are safe to retry as they are: * **`track`** with the same `request_id`: it never charges twice. * **`attach`** for the same plan or pack: it returns the same open Checkout. * **`upgrade`** with the same `Idempotency-Key`: it returns the upgrade's current state. * **Creating a customer**, **stopping or resuming renewal**, and **reads**. Retry `429` after its `Retry-After` header, and `500`, `502` and `503` with backoff. Don't retry `400`, `401`, `404` or `409` without changing something first. ## Error codes ### Usage | Status | `error` | Meaning and fix | | - | - | - | | `404` | `customer_not_found` | No customer with this ID in the key's environment. Create it, or check the key. | | `404` | `feature_not_found` | The feature doesn't exist or is archived. | | `409` | `insufficient_credits` | The balance can't cover the feature's cost; nothing was charged. Includes `availableCredits` and `creditCost`. | | `409` | `overage_cap_reached` | This use would pass the customer's monthly overage cap; nothing was charged. Includes `overageRemainingCents`. | | `409` | `request_id_conflict` | The `request_id` was already used for a different feature. Generate one ID per action. | ### Customers and renewal | Status | `error` | Meaning and fix | | - | - | - | | `404` | `subscription_not_found` | No subscription with this ID belongs to the customer. Use an `id` from the subscriptions list. | | `404` | `plan_not_found` | The `planId` in an overage override doesn't exist. | | `409` | `active_paid_subscription` | Deleting a customer with a paid subscription that isn't cancelled. Pass `deleteInStripe=true`. | | `409` | `subscription_not_manageable` | Renewal can't be changed: not an active paid plan, or Stripe has a pause, schedule or custom end date. | | `409` | `stripe_state_changed` | The subscription changed in Stripe during the request. Reload and retry. | | `409` | `plan_has_no_overage` | An overage override needs a plan with overage terms. | | `422` | `stripe_not_ready` | Stripe isn't connected in this environment. | | `502` | `stripe_unavailable` | Stripe didn't answer. Retry. | | `502` | `stripe_not_configured` | Deleting in Stripe was requested, but Stripe isn't connected. | ### Billing | Status | `error` | Meaning and fix | | - | - | - | | `400` | `plan_not_paid` | A paid-plan attach found a free plan. | | `400` | `checkout_urls_not_configured` | Set the Checkout success and cancel URLs in **Settings › Checkout**. | | `400` | `ineligible_subscription` | Upgrade needs an active paid subscription. | | `400` | `ineligible_upgrade` | The target isn't a synced paid plan that costs more than the current one. | | `400` | `ineligible_stripe_subscription` | Stripe has a discount, a schedule or a cancellation on the subscription. Clear it in Stripe, then try again. Tax is fine. | | `400` | `ineligible_stripe_adjustment` | Stripe can't charge the upgrade now: the customer needs a saved card, a zero balance and no pending invoice items. | | `400` | `overage_product_not_synced` | The overage price for the upgrade isn't in Stripe yet. Retry once the plan shows **Synced**. | | `400` | `invalid_idempotency_key` | Use printable ASCII without spaces, up to 255 characters. | | `402` | `payment_failed` | The saved card was declined. No subscription was created. | | `404` | `customer_not_found`, `plan_not_found`, `add_on_not_found` | The customer, plan or pack doesn't exist in this environment. | | `409` | `subscription_exists` | The customer already has an active or pending plan. [Upgrade](/configure-pricing/upgrades-and-downgrades) instead. | | `409` | `purchase_pending` | A different pack's Checkout is open. Let it finish or expire. | | `409` | `checkout_unavailable` | The saved Checkout can't be reopened. Attach again later. | | `409` | `idempotency_key_mismatch` | This upgrade key was used for a different plan. Use a new key. | | `409` | `upgrade_pending` | Another upgrade is pending. Retry it with its own key. | | `502` | `stripe_checkout_failed` | Stripe was unavailable or refused. Retry the same attach. | | `502` | `stripe_upgrade_failed` | Stripe was unavailable or refused. Retry with the same key. | | `502` | `stripe_not_configured` | Stripe isn't connected, or rejected the key. Check **Settings › Stripe**. | | `502` | `stripe_webhook_unhealthy` | The payment webhook is missing or misconfigured. Choose **Check again** in **Settings › Stripe**. | | `503` | `live_charges_disabled` | Live isn't activated, was turned off, or the workspace is suspended. See [Go live](/getting-started/deploy). | | `503` | `upgrade_reconciliation_required` | Retry the upgrade with the same key. Includes `upgradeId`. | | `503` | `payment_action_unavailable` | Stripe gave no payment page for the upgrade invoice. Includes `upgradeId`; contact Ledger. | ### Catalog | Status | `error` | Meaning and fix | | - | - | - | | `400` | `invalid_overage` | Overage terms are invalid or need a paid plan. Includes `issues`. | | `400` | `invalid_catalog` | A published catalog failed validation. Includes `issues`, each with a `path` and `message`. | | `400` | `environment_mismatch` | A published catalog's `environment` doesn't match the key. | | `404` | `not_found` | The plan, feature or pack doesn't exist or is archived. | | `409` | `already_exists` | An active item already has this ID. Update it instead. | | `409` | `purchase_pending` | A pack's price or credits can't change while its Checkout is open. | ### Any endpoint | Status | `error` | Meaning and fix | | - | - | - | | `400` | `invalid_request` | A field is missing or malformed. Check the request against the API reference. | | `401` | `unauthorized` | Check the key, its environment and its permissions. See [API keys](/api-keys). | | `413` | `payload_too_large` | The body is over 1 MiB. Send less at once. | | `429` | `rate_limited` | Over the key's rate limit; nothing was recorded. Wait `Retry-After` seconds and send the same request. | | `500` | `internal_error` | Includes `request_id`. Retry; if it persists, contact Ledger with the ID. | > [!NOTE] > > The plan, feature and credit pack endpoints currently answer invalid input with a `400` whose body describes each failing field, rather than `invalid_request`. Treat any `400` from them as a validation error. ## Handle errors in the SDKs Both SDKs raise an error for any non-2xx response, with the status and the `error` code. See [SDKs](/sdks#errors) for examples. # Fail open Source: https://docs.integrateledger.com/fail-open Keep your product running when Ledger can't answer. `check` sits in front of every metered action in your product. If Ledger can't answer it, the SDKs **fail open**: the check is allowed and the usage is queued for later, so an outage on our side never stops your customers. Business answers, such as an insufficient balance or an unknown customer, are never failed open. Fail open is on by default in the TypeScript and Python SDKs, for `checkCredits` / `check_credits` and `trackUsage` / `track_usage` only. Every other method throws as usual. Calling the API directly, for example with cURL, does none of this. ## When it fails open | Ledger… | `reason` | | - | - | | can't be reached: DNS, a refused or reset connection | `network_error` | | doesn't answer within the timeout (2 seconds by default) | `timeout` | | answers `5xx` | `server_error` | | answers `429`, after any retries you configured | `rate_limited` | These are still thrown, exactly as without fail open: * `400 invalid_request`, `401 unauthorized`, `404 customer_not_found` and `404 feature_not_found`. * Track's `409 insufficient_credits`, `409 overage_cap_reached` and `409 request_id_conflict`. * A `429` after a `401`, so a wrong API key is never hidden. * Your own aborted request. A check that Ledger answers with `allowed: false` stays `allowed: false`. ## What you get back Every result from these two methods has `failedOpen`. When it is `true`, the result also has a `reason`, and the numbers Ledger would have sent are `null` because they are unknown. ```typescript TypeScript theme={"system"} const decision = await client.checkCredits({ customerId: "user_123", featureId: "api_call" }); // Ledger answered: { allowed: true, availableCredits: 99, creditCost: 1, failedOpen: false } // Ledger was down: { allowed: true, availableCredits: null, creditCost: null, failedOpen: true, reason: "timeout" } if (decision.allowed) { // Run the action, then record it. const tracked = await client.trackUsage({ customerId: "user_123", featureId: "api_call", requestId: "operation_123" }); if (tracked.failedOpen) { // tracked.queued: the usage will be replayed with the same requestId. } } ``` ```python Python theme={"system"} decision = client.check_credits(customer_id="user_123", feature_id="api_call") # Ledger answered: {"allowed": True, "availableCredits": 99, "creditCost": 1, "failedOpen": False} # Ledger was down: {"allowed": True, "availableCredits": None, "creditCost": None, "failedOpen": True, "reason": "timeout"} if decision["allowed"]: tracked = client.track_usage(customer_id="user_123", feature_id="api_call", request_id="operation_123") if tracked["failedOpen"]: pass # tracked["queued"]: the usage will be replayed with the same request_id. ``` ## Usage is replayed, not lost A track that fails open is kept in memory and sent again in the background with the **same** `request_id`, so it is charged once however many times it is sent ([Record usage](/api-reference/usage/track)). Retries start after a second and back off to every 30 seconds while Ledger stays down. Replayed usage is charged at the balance when it lands. It is dropped, and reported to your callback, when: * Ledger answers it with a business error, such as `409 insufficient_credits` because the balance ran out meanwhile (`rejected`); * it is still queued an hour later (`expired`); * the client already holds 1,000 queued usages (`queue_full`). The queue lives in your process. Before a serverless function returns or a worker shuts down, flush it: ```typescript TypeScript theme={"system"} const { delivered, dropped, remaining } = await client.flushQueuedTracks(); ``` ```python Python theme={"system"} result = client.flush_queued_tracks() # {"delivered": 1, "dropped": 0, "remaining": 0} client.close() # stops the background timer ``` ## Logging fail-open events By default the SDKs write a short warning, at most every 10 seconds for each kind of event: `console.warn` in TypeScript, the `integrateledger` logger in Python. Pass a callback to send events to your own logging or alerting: ```typescript TypeScript theme={"system"} import { Ledger } from "integrateledger"; const client = new Ledger({ apiKey: process.env.LEDGER_API_KEY ?? "", failOpen: { onFailOpen: (event) => logger.warn(event, "ledger fail open"), }, }); ``` ```python Python theme={"system"} import logging import os from integrateledger import FailOpen, ledger log = logging.getLogger("billing") client = ledger( api_key=os.environ["LEDGER_API_KEY"], fail_open=FailOpen(on_fail_open=lambda event: log.warning("ledger fail open: %s", event)), ) ``` | Event | When | | - | - | | `check_failed_open` | A check returned the allowed fallback | | `track_queued` | A track failed open and its usage was queued | | `track_delivered` | Queued usage reached Ledger; `attempts` says how many replays it took | | `track_dropped` | Queued usage won't be replayed; `cause` is `rejected`, `expired` or `queue_full` | Events carry the customer, feature and request IDs, the `reason` or `cause`, and the SDK error. A callback that throws is ignored. ## Settings | TypeScript `failOpen` | Python `FailOpen` | Default | | - | - | - | | `check` | `check` | `true`: allow checks Ledger can't answer | | `track` | `track` | `true`: queue usage Ledger can't record | | `timeoutMs` | `timeout` (seconds) | 2,000 ms: per attempt, for check and track only. A per-call `timeoutMs` still wins. | | `onFailOpen` | `on_fail_open` | A throttled warning | `failOpen: false` (Python `fail_open=False`) turns it off: both methods throw on an outage, as before. ```typescript TypeScript theme={"system"} const strict = new Ledger({ apiKey: process.env.LEDGER_API_KEY ?? "", failOpen: false }); const checksOnly = new Ledger({ apiKey: process.env.LEDGER_API_KEY ?? "", failOpen: { track: false } }); ``` ```python Python theme={"system"} strict = ledger(api_key=os.environ["LEDGER_API_KEY"], fail_open=False) checks_only = ledger(api_key=os.environ["LEDGER_API_KEY"], fail_open=FailOpen(track=False)) ``` # Deploy and go live Source: https://docs.integrateledger.com/getting-started/deploy Connect your live Stripe account, publish your catalog to Live, and take your first real payment. When your integration works in Sandbox, going live is a short checklist. Live has its own keys, catalog, customers and Stripe account, so nothing from Sandbox carries over and nothing in Live can touch Sandbox. [Sandbox and Live](/concepts/environments) explains the split, and what Live doesn't support yet. Switch the dashboard to **Live** with the switch at the top of the sidebar. Until setup is done, Live pages carry a **Finish setup** button that counts the steps left. **1. Create a Live API key** In **Settings › API keys**, choose **Create key**. Live keys start with `ledger_live_`. Give your production backend only the permissions it uses (see [API keys](/api-keys)) and keep the key in your secret manager. Sandbox and Live keys only work in their own environment. **2. Connect your live Stripe account** In **Settings › Stripe**, choose **Connect Stripe** and paste a live secret key (`sk_live_…`) from [Stripe's API keys page](https://dashboard.stripe.com/apikeys). This is the account your customers pay into. Ledger checks the key, encrypts it and never shows it again. Ledger then creates a signed webhook endpoint in your Stripe account. **Settings › Stripe** shows it as **Receiving** when Stripe can deliver to it; if it shows **Not active**, choose **Check again**. Ledger won't open a paid Checkout while the webhook is unhealthy. [Stripe](/stripe) lists the events. **3. Set the Checkout redirects** In **Settings › Checkout**, set the success and cancel URLs of your production app. In Live they must be `https` and can't point to `localhost`. **4. Decide on tax** You're responsible for the sales tax, VAT or GST on your sales. To have Stripe calculate it, set up Stripe Tax in your live Stripe account (head office, default tax behavior, and a registration wherever you must collect), then turn on **Automatic tax** in **Settings › Tax** while the dashboard is on Live. Stripe calculates and collects the tax; registering and filing stay with you. Coupons, promotion codes and customer balances aren't supported. [Tax](/concepts/tax) has the details. **5. Publish your catalog to Live** Push the same catalog file with your Live key. A Live key only acts with `--live`, and a Live push asks you to type `live` before it publishes: ```bash theme={"system"} LEDGER_API_KEY=ledger_live_... npx integrateledger-cli push --live ``` Each paid plan syncs to your live Stripe account; every one must show **Synced** before you activate. To publish from CI after merge, run `npx integrateledger-cli diff --exit-code` on pull requests and push with `--yes`: ```yaml theme={"system"} - run: npx integrateledger-cli push --live --yes env: LEDGER_API_KEY: ${{ secrets.LEDGER_LIVE_API_KEY }} ``` **6. Activate live billing** When the steps are done, choose **Activate live billing**. Ledger rechecks each one, and live charges start straight away; there's no review. Before you activate, free plans, `check` and `track` work in Live, and anything that would charge returns `503 live_charges_disabled`. **7. Swap the key in production** Point your production backend at the `ledger_live_` key. Nothing else in your integration changes. Then buy your cheapest plan or pack with a real card, check that the credits arrive, and refund it in Stripe. A refunded pack's unused credits expire; a refunded plan keeps its credits. To stop taking new payments, choose **Turn off Live** in **Settings › Workspace**: new Checkouts and upgrades are refused, and existing subscriptions keep renewing in Stripe. Ledger can also suspend a workspace that breaks its terms; email [support@integrateledger.com](mailto:support@integrateledger.com) to resolve it. ## Troubleshooting | You see | Why | Fix | | - | - | - | | `503 live_charges_disabled` | Live isn't activated, was turned off, or is suspended | Finish the checklist and activate. If suspended, contact support. | | `401 unauthorized` | A Sandbox key, or a Live key without the endpoint's permission | Use a `ledger_live_` key with the right permissions. | | `400 checkout_urls_not_configured` | Live has no Checkout redirects | Set them in **Settings › Checkout** while viewing Live. | | `502 stripe_webhook_unhealthy` | The live endpoint was deleted or disabled in Stripe | **Check again** in **Settings › Stripe**, or reconnect. | | `502 stripe_not_configured` | Stripe rejected the key, or it lacks a permission | Connect a full `sk_live_` secret key again. | # Display billing Source: https://docs.integrateledger.com/getting-started/display-billing Build the billing page in your app, with plan, balance and history, and sell plans and credit packs through Stripe Checkout. Most apps need one page where a customer sees what they're on and changes it. Load the data from your backend, render it, and send each button to one Ledger call. Resolve the signed-in user's `customerId` on your server, never from a value the browser sends, so one customer can't read or change another's billing. ## Load the page ```typescript TypeScript theme={"system"} const customerId = session.user.id; const [customer, subscriptions, purchases, plans, packs] = await Promise.all([ billing.getCustomer({ customerId }), billing.listCustomerSubscriptions({ customerId }), billing.listCustomerPurchases({ customerId }), billing.listPlans(), billing.listAddOns(), ]); ``` ```python Python theme={"system"} customer_id = session.user.id customer = billing.get_customer(customer_id=customer_id) subscriptions = billing.list_customer_subscriptions(customer_id=customer_id) purchases = billing.list_customer_purchases(customer_id=customer_id) plans = billing.list_plans() packs = billing.list_add_ons() ``` | Show | From | | - | - | | Credits left | `customer.availableCredits` | | Current plan and when it renews | `customer.currentPlan.name`, `.currentPeriodEnd` | | "Confirming your payment" | `customer.currentPlan.status` is `pending_checkout` | | Plan and pack history | `subscriptions.items`, `purchases.items` | | What they can buy | `plans.items`, `packs.items` | [Balances](/customers/balances) explains each field. Listing plans and packs needs a key with **Catalog** read access. ## Wire up the buttons | The customer chooses | Call | | - | - | | A plan, with no paid plan today | `attach` with `planId`, then redirect to Checkout (below) | | A credit pack | `attach` with `addOnId`, then redirect to Checkout (below) | | A pricier or cheaper plan, on a paid plan today | [Upgrade or downgrade](/configure-pricing/upgrades-and-downgrades) | | Cancel, or undo it | [Stop or resume renewal](/configure-pricing/upgrades-and-downgrades#cancel-at-period-end) | ## Sell through Checkout **1. Set where Checkout returns** In **Settings › Checkout**, set a **success URL** and a **cancel URL**: the pages in your app that Stripe sends the customer back to. They must be `https`; plain `http` is accepted only for `localhost` in Sandbox. Until you set them, Sandbox returns the customer to the Ledger dashboard. Live requires them. **2. Attach and redirect** ```typescript TypeScript theme={"system"} const result = await billing.attach({ customerId, planId: "pro" }); // or addOnId if ("checkoutUrl" in result) { return res.redirect(303, result.checkoutUrl); } // status "active": Ledger charged the customer's saved card. ``` ```python Python theme={"system"} result = billing.attach(customer_id=customer_id, plan_id="pro") # or add_on_id if "checkoutUrl" in result: return redirect(result["checkoutUrl"], code=303) # status "active": Ledger charged the customer's saved card. ``` A customer with a saved card is charged straight away and gets `status: "active"`, unless the card needs 3-D Secure, which returns a `checkoutUrl` instead. Calling `attach` again while a Checkout is open returns the same URL, so a double click is safe. A customer on a free plan keeps it until the paid plan's payment lands. **3. Confirm from your backend** When the customer lands on your success URL, load the page again from your backend. Ledger grants credits only when Stripe's signed webhook confirms the payment, usually within seconds; the redirect proves nothing. While `currentPlan.status` is `pending_checkout`, show "Confirming your payment" and reload in a moment. ### Test it In Sandbox, pay with [Stripe test cards](https://docs.stripe.com/testing), any future expiry and any CVC: | Card | Result | | - | - | | `4242 4242 4242 4242` | Succeeds. | | `4000 0025 0000 3155` | Asks for authentication, then succeeds. | | `4000 0000 0000 9995` | Declined. The plan stays `pending_checkout`. | To see renewals without waiting a month, [simulate time](/customers/simulate-time). ### Errors from attach | Status | `error` | What to do | | - | - | - | | `402` | `payment_failed` | The saved card was declined. Nothing was created; the customer can attach again. | | `409` | `subscription_exists` | The customer already has an active or pending plan. [Upgrade or downgrade](/configure-pricing/upgrades-and-downgrades) instead. | | `409` | `purchase_pending` | A different pack's Checkout is open. Let it finish or expire. | | `400` | `checkout_urls_not_configured` | Set the Checkout URLs in **Settings › Checkout**. | | `502` | `stripe_webhook_unhealthy`, `stripe_checkout_failed` | Check **Settings › Stripe**, then retry the same attach; it can't open a second Checkout. | [Billing lifecycle](/concepts/billing-lifecycle) shows what happens from attach to credits, and at each renewal. > [!NOTE] > > **Other ways.** Without an SDK, use [`POST /v1/billing/attach`](/api-reference/billing/attach). # Gating features Source: https://docs.integrateledger.com/getting-started/gating Check a customer's balance before an action, then track each successful use exactly once. Metering is two calls from your backend: `check` before an action asks whether the customer can afford it, and `track` after it succeeds spends the credits. ```mermaid theme={"system"} %%{init: {"theme": "base", "themeVariables": {"fontFamily": "IBM Plex Sans, sans-serif", "fontSize": "14px", "actorBkg": "#f7f6f3", "actorBorder": "#c4bfb5", "actorTextColor": "#1a1816", "signalColor": "#3d3934", "signalTextColor": "#1a1816", "noteBkgColor": "#eff2fd", "noteBorderColor": "#bfcbf7", "noteTextColor": "#1d36a0", "labelBoxBkgColor": "#f7f6f3", "labelBoxBorderColor": "#c4bfb5", "labelTextColor": "#1a1816", "loopTextColor": "#1a1816"}}}%% sequenceDiagram participant App as Your backend participant L as Ledger App->>L: check(customerId, featureId) L-->>App: allowed, availableCredits, creditCost alt allowed is false Note over App: Offer an upgrade or a credit pack else allowed is true Note over App: Run the action App->>L: track(customerId, featureId, requestId) L-->>App: credits spent App->>L: Retry with the same requestId L-->>App: idempotent: true, nothing charged end ``` The examples continue from [Setup](/getting-started/setup): a `chat_message` feature that costs 1 credit, and a customer on the Free plan. ## Check, run, track ```typescript TypeScript theme={"system"} const { allowed } = await billing.checkCredits({ customerId: user.id, featureId: "chat_message", }); if (!allowed) { return showUpgradeOffer(); } const message = await sendChatMessage(prompt); await billing.trackUsage({ customerId: user.id, featureId: "chat_message", requestId: message.id, // unique to this one use }); ``` ```python Python theme={"system"} decision = billing.check_credits(customer_id=user.id, feature_id="chat_message") if not decision["allowed"]: return show_upgrade_offer() message = send_chat_message(prompt) billing.track_usage( customer_id=user.id, feature_id="chat_message", request_id=message.id, # unique to this one use ) ``` * **`check` never spends.** It returns `{ "allowed": true, "availableCredits": 50, "creditCost": 1 }`, and `allowed: false` with a `200` when the balance can't cover the cost. That's the moment to offer an [upgrade](/configure-pricing/upgrades-and-downgrades) or a [credit pack](/configure-pricing/credit-packs). * **Track only what succeeded.** If the action failed, don't track it and the customer isn't charged. * **The request ID makes `track` idempotent.** Use an ID you already store with the action: a message ID, a job ID, or a UUID you save with it. Up to 255 characters, and one ID belongs to one feature. * **Retry with the same ID.** If a `track` response is lost, send it again with the **same** `requestId`. Ledger returns the original result with `"idempotent": true` and charges nothing more. A new ID records a second use. `track` returns what it spent: ```json Response theme={"system"} { "allowed": true, "availableCredits": 49, "chargedCredits": 1, "overageCredits": 0, "overageRemainingCents": null, "request_id": "msg_0001", "idempotent": false } ``` ## Handle a refused track `check` doesn't reserve credits, so another request can spend the last credit between your `check` and your `track`. `track` decides again in its own transaction and can refuse with a `409`. Nothing is charged, and the same request ID can succeed later, for example after the customer buys a pack. ```typescript TypeScript theme={"system"} import { TrackError } from "integrateledger/models/errors"; try { await billing.trackUsage({ customerId, featureId: "chat_message", requestId }); } catch (error) { if (!(error instanceof TrackError) || error.statusCode !== 409) { throw error; } console.warn(error.data$.error); // "insufficient_credits", ... } ``` ```python Python theme={"system"} from integrateledger import LedgerAPIError try: billing.track_usage(customer_id=customer_id, feature_id="chat_message", request_id=request_id) except LedgerAPIError as error: if error.status != 409: raise print(error.code) # "insufficient_credits", ... ``` | `error` | Meaning | What to do | | - | - | - | | `insufficient_credits` | The balance can't cover the cost. The body has `availableCredits` and `creditCost`. | Treat it like `allowed: false`. | | `overage_cap_reached` | This use would pass the customer's monthly [overage](/configure-pricing/overage) cap. | Offer a higher cap, an upgrade or a pack. | | `request_id_conflict` | The request ID was already used for a different feature. | Make IDs unique per action. | A `404 customer_not_found` or `feature_not_found` means the customer or feature doesn't exist with this key's environment. [Errors](/errors) lists every code. ## When Ledger is unreachable The SDKs [fail open](/fail-open) by default: `check` is allowed and `track` is replayed later with the same request ID, both marked `failedOpen: true`, so an outage never blocks your users. Pass `failOpen: false` (Python `fail_open=False`) to handle outages yourself. Call Ledger from your backend only, never from a browser or mobile app. **Usage** in the dashboard lists every tracked request, charged or blocked, with the reason. > [!NOTE] > > **Other ways.** Without an SDK, call [`POST /v1/check`](/api-reference/usage/check) and [`POST /v1/track`](/api-reference/usage/track); over HTTP the request ID field is `request_id`. # Setup Source: https://docs.integrateledger.com/getting-started/setup Install the SDK and CLI, publish your catalog to Sandbox, and start your first customer on a plan. This page sets up credit billing for a chat app. Each chat message costs 1 credit, a **Free** plan grants 50 credits a month, and **Pro** grants 1,000 credits for \$20 a month. Everything runs in **Sandbox**, with Stripe test cards, so no real money moves. You keep your catalog in a file in your repo and publish it with the `ledger` CLI. Your backend talks to Ledger through the SDK. > [!TIP] > > **Using a coding agent?** Paste this into Claude Code, Codex, Cursor or any other agent in your app's repository: > > ```text Prompt wrap theme={"system"} > Add Ledger billing to my app: docs.integrateledger.com/skill.md > ``` > > The setup skill does every step on this page: it reads your pricing, asks about the edge cases, writes `ledger.catalog.json`, publishes it to Sandbox once you approve the diff, and wires up check and track. Along the way `ledger skills` writes its two companion skills, `ledger-catalog` and `ledger-integrate`, into your repository's `.agents/skills/`. [Build with AI](/ai#ledger-skills) has the details. **1. Create a Sandbox API key** Sign in to the [dashboard](https://dashboard.integrateledger.com), check that the sidebar switch is on **Sandbox**, then open **Settings › API keys** and choose **Create key**. The key starts with `ledger_test_` and is shown once. Put it in your server's environment, for example in a gitignored `.env`: ```bash .env theme={"system"} LEDGER_API_KEY=ledger_test_... ``` Keep it on your server: it can spend any customer's credits. [API keys](/api-keys) covers permissions. **2. Install the SDK and the CLI** The SDK runs in your backend. The CLI manages the catalog and needs Node 22.18 or later, or Bun, whatever your app is written in. ```bash TypeScript theme={"system"} npm install integrateledger npm install --save-dev integrateledger-cli ``` ```bash Python theme={"system"} pip install integrateledger # Run the CLI with npx integrateledger-cli; nothing to install. ``` **3. Define your catalog** `npx integrateledger-cli init` writes a starter config: `ledger.config.ts` in a JavaScript project, `ledger.catalog.json` anywhere else. Edit it to describe what you sell: ```ts ledger.config.ts theme={"system"} import { defineCatalog, feature, plan } from "integrateledger-cli"; export default defineCatalog({ features: [ feature({ id: "chat_message", name: "Chat message", creditCost: 1 }), ], plans: [ plan({ id: "free", name: "Free", monthlyCredits: 50, monthlyPriceCents: 0 }), plan({ id: "pro", name: "Pro", monthlyCredits: 1000, monthlyPriceCents: 2000 }), ], creditPacks: [], }); ``` ```json ledger.catalog.json theme={"system"} { "schemaVersion": 1, "features": [ { "id": "chat_message", "name": "Chat message", "creditCost": 1 } ], "plans": [ { "id": "free", "name": "Free", "monthlyCredits": 50, "monthlyPriceCents": 0 }, { "id": "pro", "name": "Pro", "monthlyCredits": 1000, "monthlyPriceCents": 2000 } ], "addOns": [] } ``` A **feature** is an action and what one use costs. A **plan** grants credits every month; a price of `0` makes it free. [Catalog](/concepts/catalog) explains each item. **4. Publish it to Sandbox** ```bash theme={"system"} npx integrateledger-cli diff # what would change npx integrateledger-cli push # publish it ``` `push` syncs each paid plan to Stripe. Sandbox comes with a Stripe test account that Ledger creates for your workspace, so there's nothing to set up in Stripe yet. From now on, change pricing the same way: edit the file, review the diff in a pull request, push. The [CLI reference](/cli) has every command. **5. Create the customer on signup** Use your app's own user or organization ID as the `customerId`, and attach the free plan when they sign up. Creating a customer grants nothing; attaching a plan does. A free plan starts at once, without Checkout. ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; export const billing = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); // In your signup handler: await billing.createOrGetCustomer({ customerId: user.id }); await billing.attach({ customerId: user.id, planId: "free" }); ``` ```python Python theme={"system"} import os from integrateledger import ledger billing = ledger(api_key=os.environ["LEDGER_API_KEY"]) # In your signup handler: billing.create_or_get_customer(customer_id=user.id) billing.attach(customer_id=user.id, plan_id="free") ``` The customer now has 50 credits. [Creating customers](/customers/creating-customers) covers the details. > [!NOTE] > > **Other ways.** You can build the catalog in the dashboard (**Plans**, **Features**, **Add-ons**) and bring it into your file with `npx integrateledger-cli pull`, or call the [API](/api-reference/introduction) directly. Keep one source of truth: `ledger push` archives anything that isn't in the file. ## Next steps > [card] [Gate features](/getting-started/gating) > > Check the balance before an action and track it after. > [card] [Display billing](/getting-started/display-billing) > > Show the balance and sell plans with Stripe Checkout. # Ledger documentation Source: https://docs.integrateledger.com/index Sell plans and credit packs, meter usage in credits, and let Stripe take the payments. Ledger is credit billing for your application. You describe what you sell in a catalog, ask Ledger whether a customer can use a feature, record the use when it succeeds, and Ledger keeps every balance in step with Stripe. ```mermaid theme={"system"} %%{init: {"theme": "base", "themeVariables": {"fontFamily": "IBM Plex Sans, sans-serif", "fontSize": "14px", "primaryColor": "#f7f6f3", "primaryBorderColor": "#c4bfb5", "primaryTextColor": "#1a1816", "lineColor": "#6b655c", "clusterBkg": "#ffffff", "clusterBorder": "#dbd7cf", "edgeLabelBackground": "#ffffff"}}}%% flowchart LR app["Your backend"] ledger["Ledger API
catalog · balances · usage"] stripe["Stripe
Checkout · subscriptions · invoices"] customer(["Your customer"]) app -- "check · track" --> ledger app -- "attach a plan or pack" --> ledger ledger -- "syncs plans,
opens Checkout" --> stripe customer -- "pays on Checkout" --> stripe stripe -- "signed webhooks" --> ledger style ledger fill:#eff2fd,stroke:#2443c4,color:#1a1816 ``` ## How it fits together * **Catalog.** [Features](/configure-pricing/features-and-credits) say what an action costs in credits, [plans](/configure-pricing/plans) grant credits every month, and [credit packs](/configure-pricing/credit-packs) sell credits once. It lives in your repo and you publish it with the [CLI](/cli). * **Gating.** Your backend calls [`check`](/getting-started/gating) before an action and `track` after it succeeds, through the SDK. * **Billing.** `attach` starts a free plan at once or returns a Stripe Checkout URL. Ledger grants paid credits when Stripe's signed webhook confirms the payment. * **Environments.** [Sandbox and Live](/concepts/environments) are separate: their own keys, catalog, customers and Stripe account. ## Get started > [card] [Setup](/getting-started/setup) > > Install the SDK and CLI, publish a catalog, start a customer. > [card] [Gating features](/getting-started/gating) > > Check the balance before an action and track it after. > [card] [Display billing](/getting-started/display-billing) > > Show the balance and sell plans through Stripe Checkout. > [card] [Deploy](/getting-started/deploy) > > Connect your live Stripe account and take real payments. ## For agents These docs are built to be read by coding agents as well as people. Start an agent at [`llms.txt`](https://docs.integrateledger.com/llms.txt), the index of every page, or give it [`llms-full.txt`](https://docs.integrateledger.com/llms-full.txt), the whole site in one file. Any page is Markdown at its URL plus `.md`, and the API is described by the [OpenAPI spec](https://docs.integrateledger.com/openapi.json). # Migrate from Stripe Billing Source: https://docs.integrateledger.com/migrate-from-stripe Move customers you already bill with Stripe subscriptions onto Ledger with one command, without re-subscribing anyone or charging them. If you already sell monthly subscriptions with Stripe, you can put Ledger on top of them. Each customer keeps their subscription, card and renewal date. Ledger grants their plan's credits for the current period now, and the credits for every later period when Stripe renews. ## Connect your Stripe account Ledger has to work in the Stripe account your subscriptions live in. On the dashboard's Developer page, connect that account's secret key: `sk_test_…` in Sandbox, `sk_live_…` in Live. Ledger creates a webhook endpoint in your account; check that it shows as healthy, since every renewal's credits arrive through it. Try the whole migration in Sandbox first, with test-mode copies of a few customers. ## Run one command From your app's repo, with a Sandbox API key in `LEDGER_API_KEY` (or saved with `ledger login`): ```bash theme={"system"} npx integrateledger-cli migrate stripe ``` It reads your Stripe account (nothing changes yet) and: 1. **Proposes a catalog.** One paid plan for each monthly USD Price your customers are on, named after its Product and priced like it. It asks each new plan's monthly credits, and a feature with its credit cost if your `ledger.catalog.json` has none. A plan already in your config keeps its credits. 2. **Shows a report.** Each plan, each customer it will import with their next renewal date, and each subscription it will skip, with the reason (see [what can be imported](#which-subscriptions-can-be-imported)). 3. **Asks before changing anything.** Then it writes the plans to `ledger.catalog.json`, publishes the catalog, and imports each customer, one progress line each. 4. **Ends with a summary.** Imported, already imported, skipped by reason, failed, and the range of next renewals. ```text theme={"system"} Sandbox · workspace · https://api.integrateledger.com · key from LEDGER_API_KEY Plans pro $49/mo 2000 credits/mo new price_1Pro… · 2 to import starter $19/mo 500 credits/mo new price_1Sta… · 2 to import Import 4 user_di → pro sub_1… · renews 2026-12-07 … Skip 2 cus_Fay… sub_1… unsupported_subscription (discount) cus_Ed… sub_1… unsupported_subscription (not_monthly) [1/4] user_di → pro imported, renews 2026-12-07 … Imported 4, already imported 0, skipped 2, failed 0. Next renewals: 2026-12-07 to 2026-12-07. Ledger grants each one's credits when Stripe renews it. ``` | Flag | Use it to | | - | - | | `--dry-run` | See the report only. | | `--customer-id user_id` | Use your own customer ID from that Stripe customer metadata key. By default the Ledger customer ID is the Stripe customer ID (`cus_…`). Your app passes the same ID to check and track. | | `--credits price_…=1000` | Give a new plan's monthly credits without the prompt (repeatable; for CI and agents). | | `--feature generate=1` | Add a feature when the config has none (repeatable). | | `--yes` | Skip the confirmation. | | `--live` | Migrate Live. You type `live` to confirm, or pass `--yes`. | **Safe to rerun.** Every step is idempotent. Progress and your answers are saved in `.ledger/migrate-stripe.sandbox.json` (gitignored), so after a failure or an outage you run the same command again: it asks nothing, skips the customers already imported, and retries the rest. It exits `1` when any customer failed. The command carries no usage over: each customer starts the current period with the plan's full credits. To carry over what they've used, see [Other ways](#other-ways). ## Other ways > [!NOTE] > > `ledger migrate stripe` is the way to migrate. If you need to script it yourself, for example to carry over usage with `startingBalance`, the command is built on calls you can make directly: [Preview a Stripe migration](/api-reference/migrate/stripe-preview) (`client.previewStripeMigration()`) returns the same report as JSON, then for each customer [create](/api-reference/customers/create) them, [link their Stripe customer](/api-reference/customers/stripe-customer) and [import their subscription](/api-reference/billing/import) onto a plan with the same monthly price. Every call is safe to repeat. > > The [onboarding skill](/ai) runs the command for you when it finds Stripe subscriptions in your app. Connecting Stripe with OAuth and migrating in one click from the dashboard is planned next, on the same calls. ## Customers on a free plan Customers on a free plan don't need an import. Create them and [attach](/api-reference/billing/attach) the free plan. You can still link their Stripe customer, so a later upgrade uses the card they already saved. ## What changes in Stripe Ledger only changes two things in your Stripe account: * **Metadata.** The customer and subscription get `ledger_organization_id` and `ledger_env`, and the customer also gets `ledger_customer_id`. Your own metadata keys stay as they are. * **The subscription's Price.** Its item moves from your Price to the Ledger plan's Price, which has the same amount, with no proration. Nothing is charged, no invoice is created (a trialing subscription gets a \$0 one), and the renewal date stays the same. From the next renewal on, the invoice line shows the Ledger plan's name. Ledger needs the subscription on its own Price because it grants credits only for invoices it can verify against the plan. Your old Price stays in your account, unused by the imported subscription. ## Which subscriptions can be imported Ledger imports active subscriptions, subscriptions set to cancel at the period end (the plan ends when Stripe ends the subscription), and trialing subscriptions (the trial becomes the plan's first period, with its credits). It refuses, without changing anything, a subscription it couldn't bill correctly, and says why. The command reports these as skipped: | Error | What to do | | - | - | | `subscription_past_due`, `latest_invoice_unpaid` | Collect or void the open invoice in Stripe, then import. | | `subscription_incomplete` | The first payment never succeeded: let the customer pay, or cancel it. | | `subscription_ended`, `subscription_not_billable` | A canceled, expired, unpaid or paused subscription has nothing to import. | | `multiple_subscriptions` | Ledger keeps one plan per customer. Consolidate the customer's subscriptions in Stripe first. | | `stripe_customer_owned_elsewhere` | The customer's Ledger metadata names another Ledger workspace or environment. | | `unsupported_subscription` | The `reason` names what Ledger can't bill yet: several items or a quantity above one, a price that isn't the plan's monthly USD amount, a yearly (`not_monthly`) or metered price, invoices sent for manual payment, a coupon (`discount`), a subscription schedule or pending update, paused collection, billing thresholds, pending invoice items, or a customer balance. | Fix the cause in Stripe and run the command again; it picks up the newly importable customers. ## Live Migration works the same way in Live, with your Live API key and `sk_live_` connection: `ledger migrate stripe --live`. It doesn't need Ledger's approval for live charges, because it starts no charge: your customers keep paying what they already paid, on the same dates. A later upgrade through Ledger is a new charge, and does need it. See [Import a Stripe subscription](/api-reference/billing/import) and [Link a Stripe customer](/api-reference/customers/stripe-customer) for every field and error. # Rate limits Source: https://docs.integrateledger.com/rate-limits How fast you can call Ledger, and how to handle a 429. Ledger limits requests per API key, so one runaway loop can't slow down everyone else. Each limit is a token bucket: you can send a burst at once, then keep up the sustained rate. | Requests | Sustained | Burst | | - | - | - | | `POST /v1/check` and `POST /v1/track` | 200 a second | 1,000 | | Billing (`/v1/billing/*`, cancellation) and catalog writes, which call Stripe | 5 a second | 25 | | Everything else: customers and catalog reads | 50 a second | 100 | Each kind of request has its own budget: billing calls don't use up a key's `check` budget, and sandbox and live keys are counted separately. ## Headers Every API response says where the key stands: | Header | Meaning | | - | - | | `RateLimit-Limit` | The burst | | `RateLimit-Remaining` | Requests you can send right now | | `RateLimit-Reset` | Seconds until the full burst is back | | `RateLimit-Policy` | The sustained rate and burst, such as `200;w=1;burst=1000` | ## Handling a 429 Over the limit, Ledger answers `429` with `{ "error": "rate_limited" }` and a `Retry-After` header in seconds. Nothing was recorded: wait that long and send the same request again. Retrying `track` with the same `request_id` is always safe. The SDKs [fail open](/fail-open) on a `429` from `check` or `track`. Spread out bulk work, such as a backfill or a catalog script, rather than sending it all at once. If your traffic needs more than these limits, [contact us](mailto:support@integrateledger.com). ## Failed requests Repeated `401` responses from one IP address, about 30 in a row, make Ledger refuse further requests from that address for a short time. A key that has worked recently keeps working. Check your key if you see `401`s. ## Request size Request bodies are limited to 1 MiB, far more than the largest catalog. A bigger body gets `413` with `{ "error": "payload_too_large" }`. ## Webhooks Signed Stripe and Clerk webhook deliveries are never limited. Only deliveries that fail verification count, per sending address and endpoint. # SDKs Source: https://docs.integrateledger.com/sdks The integrateledger packages for TypeScript and Python. Ledger has official server-side SDKs for TypeScript and Python, both named `integrateledger`. They cover every endpoint in the API reference except deleting a customer, which you can call over HTTP. Use them from your backend only: they need your secret API key. | Language | Package | Requires | | - | - | - | | TypeScript and JavaScript | [`integrateledger` on npm](https://www.npmjs.com/package/integrateledger) | A Node.js LTS release, Bun 1+ or Deno | | Python | [`integrateledger` on PyPI](https://pypi.org/project/integrateledger/) | Python 3.9+ | ## Install and configure ```bash TypeScript theme={"system"} npm install integrateledger ``` ```bash Python theme={"system"} pip install integrateledger ``` Create one client with your API key and reuse it. The key decides the environment, Sandbox or Live. ```typescript TypeScript theme={"system"} import ledger from "integrateledger"; const client = new ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" }); const plans = await client.listPlans(); console.log(plans.items.length); ``` ```python Python theme={"system"} import os from integrateledger import ledger client = ledger(api_key=os.environ["LEDGER_API_KEY"]) plans = client.list_plans() print(len(plans["items"])) ``` | Option | TypeScript | Python | Default | | - | - | - | - | | API key | `apiKey` | `api_key` | Required | | API URL | `serverURL` | `base_url` | `https://api.integrateledger.com` | | Timeout | `timeoutMs` | `timeout` (seconds) | None in TypeScript; 30 seconds in Python | ## Responses Both SDKs return the API's JSON. Python returns it as a `dict`, unchanged. TypeScript returns typed objects with two differences from the raw JSON: * Timestamps such as `createdAt` are `Date` objects. * `request_id` on `track` is `requestId`, in both the request and the response. ## Errors A response with a 4xx or 5xx status raises an error that carries the status and Ledger's error code. [Errors](/errors) lists every code. ```typescript TypeScript theme={"system"} import { LedgerError } from "integrateledger/models/errors"; try { await client.getCustomer({ customerId: "user_unknown" }); } catch (error) { if (error instanceof LedgerError) { console.log(error.statusCode, error.body); // 404 {"error":"customer_not_found"} } else { throw error; // A network failure or timeout. } } ``` ```python Python theme={"system"} from integrateledger import LedgerAPIError, LedgerConnectionError try: client.get_customer(customer_id="user_unknown") except LedgerAPIError as error: print(error.status, error.code) # 404 customer_not_found except LedgerConnectionError: pass # Ledger couldn't be reached. Retry later. ``` In TypeScript, each method also has its own error class with the parsed body in `data$`, such as `TrackError` with `error.data$.error` set to `"insufficient_credits"`. In Python, `error.body` holds the parsed body. ## Methods | Endpoint | TypeScript | Python | | - | - | - | | `POST /v1/check` | `checkCredits` | `check_credits` | | `POST /v1/track` | `trackUsage` | `track_usage` | | `POST /v1/customers` | `createOrGetCustomer` | `create_or_get_customer` | | `GET /v1/customers` | `getCustomer` | `get_customer` | | `DELETE /v1/customers` | Not in the SDK yet | Not in the SDK yet | | `GET /v1/customers/subscriptions` | `listCustomerSubscriptions` | `list_customer_subscriptions` | | `GET /v1/customers/purchases` | `listCustomerPurchases` | `list_customer_purchases` | | `POST /v1/customers/subscriptions/{id}/cancellation` | `setSubscriptionCancellation` | `set_subscription_cancellation` | | `POST /v1/customers/overage-cap` | `setCustomerOverageCap` | `set_customer_overage_cap` | | `POST /v1/customers/overage-override` | `setCustomerOverageOverride` | `set_customer_overage_override` | | `POST /v1/billing/attach` | `attach` | `attach` | | `POST /v1/billing/upgrade` | `upgradePaidPlan` | `upgrade_paid_plan` | | `POST /v1/billing/downgrade` | `downgradePaidPlan` | `downgrade_paid_plan` | | `GET /v1/billing/downgrade` | `getScheduledDowngrade` | `get_scheduled_downgrade` | | `POST /v1/billing/downgrade/{id}/cancel` | `cancelScheduledDowngrade` | `cancel_scheduled_downgrade` | | `GET /v1/plans` | `listPlans` | `list_plans` | | `POST /v1/plans` | `createPlan` | `create_plan` | | `GET /v1/plans/{id}` | `getPlan` | `get_plan` | | `POST /v1/plans/{id}` | `updatePlan` | `update_plan` | | `DELETE /v1/plans/{id}` | `archivePlan` | `archive_plan` | | `GET`, `POST`, `DELETE` `/v1/features…` | `listFeatures`, `createFeature`, `getFeature`, `updateFeature`, `archiveFeature` | `list_features`, `create_feature`, `get_feature`, `update_feature`, `archive_feature` | | `GET`, `POST`, `DELETE` `/v1/add-ons…` | `listAddOns`, `createAddOn`, `getAddOn`, `updateAddOn`, `archiveAddOn` | `list_add_ons`, `create_add_on`, `get_add_on`, `update_add_on`, `archive_add_on` | | `POST /v1/catalog` | `publishCatalog` | `publish_catalog` | TypeScript methods take one object. Path and query values sit at its top level, and a request body goes under `body` when the method also has path or query values, as in `updatePlan({ id, body })`. Python methods take keyword arguments in snake case. ## Next steps > [card] [Setup](/getting-started/setup) > > Install the SDK and CLI, publish a catalog, start a customer. > [card] [API reference](/api-reference/introduction) > > The endpoints the SDKs call. # Stripe Source: https://docs.integrateledger.com/stripe The Stripe account behind each environment, what Ledger creates in it, and the webhook that keeps balances in step. Ledger runs payments through Stripe and never handles card details itself. Each environment has its own Stripe account, connected in **Settings › Stripe**. ## The Stripe account in each environment | | Sandbox | Live | | - | - | - | | Account | A Stripe test account Ledger creates for your workspace, or your own test mode | Your own Stripe account, in live mode | | Key | Managed by Ledger, or your `sk_test_` key | Your `sk_live_` key | | Set up | Ready when the workspace is created | **Settings › Stripe › Connect Stripe** while viewing Live | The Sandbox test account lives on Ledger's Stripe Connect platform, so it needs no Stripe sign-up. To use your own Stripe test mode instead, for example to see objects in your own Stripe Dashboard, choose **Use a different key** in **Settings › Stripe** and paste an `sk_test_` secret key. Ledger accepts standard secret keys (`sk_test_`, `sk_live_`). It encrypts them, never shows them again, and checks a key with Stripe before saving it. ## What Ledger creates in Stripe | Ledger | Stripe | | - | - | | A paid plan | A product with a monthly USD price, updated when the plan changes | | A customer's first purchase | A customer, carrying Ledger metadata that links it back | | Attaching a paid plan | A Checkout Session in subscription mode, then a subscription | | Attaching a credit pack | A one-time Checkout Session and an invoice for the pack | | Overage (Sandbox) | Lines on the renewal invoice, one per rate | Free plans and features never reach Stripe. ## The webhook Stripe tells Ledger about payments through a signed webhook. Ledger treats it as the only proof that money arrived: it grants credits from these events, never from a browser redirect. When you connect a key, Ledger creates the endpoint in that Stripe account for you, pinned to the Stripe API version Ledger is built against and subscribed to these events: | Event | What Ledger does | | - | - | | `checkout.session.completed` | Links the new subscription; grants a credit pack's credits once paid. | | `checkout.session.expired` | Closes the pending plan or pack, so a new Checkout can open. | | `invoice.created` | Grants a renewal's credits and adds overage lines while the invoice is a draft. | | `invoice.paid` | Activates a paid plan and grants its first credits; applies a paid upgrade. | | `invoice.voided` | Ends the credits a voided renewal invoice granted. | | `customer.subscription.deleted` | Ends the plan, and bills any remaining overage. | | `customer.subscription.pending_update_applied` | Confirms an upgrade Stripe applied. | | `customer.subscription.pending_update_expired` | Closes an upgrade whose payment never completed. | | `test_helpers.test_clock.ready`, `customer.deleted` | Sandbox only: follows [simulated time](/customers/simulate-time). | Each delivery is verified against the endpoint's signing secret, and handling it twice changes nothing, so Stripe's retries are safe. ### Webhook health Before it opens a paid Checkout or starts an upgrade, Ledger checks with Stripe that the endpoint exists, is enabled, points at Ledger, uses the expected API version and subscribes to the events above. If it doesn't, the request returns `502 stripe_webhook_unhealthy` and no Checkout opens, because the payment couldn't reach the customer's balance. **Settings › Stripe** shows the webhook as **Receiving** when it's healthy, or **Not active** with a **Check again** button. If you deleted or edited the endpoint in Stripe, reconnecting the key creates a fresh one. > [!WARNING] > > Don't edit or delete Ledger's endpoint in your Stripe Dashboard. If payments stop updating balances, check **Settings › Stripe** first. ## You don't handle Stripe webhooks Ledger consumes Stripe's events for you. Your app reads the results from Ledger's API: the customer's plan, balance, subscriptions and purchases. To react to billing changes in your app, subscribe to Ledger's own [webhooks](/webhooks). ## Next steps > [card] [Billing lifecycle](/concepts/billing-lifecycle) > > How these events move a customer through billing. > [card] [Go live](/getting-started/deploy) > > Connect your live Stripe account. # Terms of Service Source: https://docs.integrateledger.com/terms The agreement between your organization and The Monetization Company for using Ledger. **Effective date: November 8, 2026** These Terms of Service ("Terms") are an agreement between The Monetization Company, a Delaware corporation at 350 Sansome St, San Francisco, CA 94104, United States ("Ledger", "we", "us"), and the organization that creates or uses a Ledger workspace ("you"). They cover the Ledger dashboard, API, SDKs, CLI and documentation (the "Service"). When you take real payments through Ledger, the [Merchant Terms](/merchant-terms) also apply. When you send Ledger information about your own customers, the [Data Processing Addendum](/dpa) applies. Our [Privacy Policy](/privacy) explains how we handle information about the people who use the dashboard. Together, these documents are the "Agreement". ## 1. Accepting these Terms You accept these Terms when you create an account, check the box at sign-up, or use the Service. If you accept on behalf of an organization, you confirm that you have authority to bind it. The Service is for businesses. Don't use it as a consumer, and don't use it if you are under 18. ## 2. Ledger is in beta Ledger is in beta. Features may change, and we may add, change or remove them. We'll give at least 30 days' notice before removing a feature you use in Live, unless the change is needed for security, legal reasons or to protect the Service. We don't promise any level of uptime during the beta. ## 3. Accounts, workspaces and keys * **Your account.** Keep your sign-in details accurate. You are responsible for everything done through your workspace, including by anyone you invite. * **Keys.** Ledger API keys, and the Stripe keys you connect, are your responsibility. Store them securely, give each key only the permissions it needs, and tell us at [security@integrateledger.com](mailto:security@integrateledger.com) if you think one has been exposed. * **Sandbox and Live.** Every workspace has a Sandbox environment for testing and a Live environment for real payments. Sandbox payments are not real, and Sandbox data may be reset or removed. ## 4. Fees Ledger is free during the beta. We'll give at least 30 days' notice by email or in the dashboard before we start charging for the Service, and before we raise a fee after that. Fees exclude taxes; you pay any taxes on them other than taxes on our income. Fees you pay us are separate from the payments your customers make to you through Stripe. ## 5. Acceptable use You must not, and must not let others: * break any law, or use the Service for a business that Stripe restricts or prohibits ([Stripe's restricted businesses](https://stripe.com/legal/restricted-businesses)), or that the Merchant Terms prohibit; * use the Service for fraud, card testing, deceptive billing, or to charge people without their consent; * send Ledger payment card numbers, bank details, government ID numbers, passwords, health information, or other sensitive personal information, including in customer IDs, feature IDs or metadata; * probe, scan or test the Service's security without our written permission (report vulnerabilities to [security@integrateledger.com](mailto:security@integrateledger.com)); * get around rate limits, usage limits, the live charge controls, or any other restriction we put in place; * interfere with the Service or other customers' use of it, or send malware; * copy, resell or offer the Service to others as a standalone product, or use it to build a competing product; * reverse engineer the Service, except where the law allows it despite this restriction. ## 6. Your data * **You own your data.** "Your Data" means the catalog, customer records, usage events and other information you or your systems send to Ledger. You keep all rights in it. * **Our licence to use it.** You give us a worldwide, non-exclusive licence to host, copy, process and display Your Data only as needed to provide, secure and support the Service, and as the [Data Processing Addendum](/dpa) allows. * **Aggregate data.** We may use aggregated and de-identified data about how the Service is used, which doesn't identify you or your customers, to operate and improve the Service. * **No sale and no model training.** We don't sell Your Data, and we don't use it to train machine learning models. * **Accuracy.** You are responsible for Your Data being accurate and lawfully collected, and for having any notices and consents your customers need. ## 7. Feedback If you send us suggestions or feedback, we may use them without any obligation to you. ## 8. Our property We and our licensors own the Service, including its software, design and documentation. These Terms give you a limited, non-exclusive, non-transferable right to use the Service while the Agreement is in force. Ledger's SDKs and other code that we publish under an open-source licence are governed by that licence. ## 9. Third-party services The Service works with services that we don't control, including Stripe (payments), Clerk (sign-in) and Google (if you choose Google sign-in). Your use of those services is governed by their own terms. We aren't responsible for them, including when they are unavailable or change. ## 10. Suspension and termination * **By you.** You can stop using the Service at any time. To close your workspace and delete its data, email [support@integrateledger.com](mailto:support@integrateledger.com). * **By us.** We may suspend or terminate your access, or stop new Live charges for your workspace, if you break the Agreement, if your use creates a security, fraud, legal or financial risk to us, Stripe, your customers or other users, if Stripe or a law requires it, or if fees are more than 30 days overdue. Where it's reasonable, we'll tell you first and give you a chance to fix the problem. We may end the Service for everyone with 60 days' notice. * **What happens next.** For 30 days after termination you can ask us to export Your Data. After that we delete it as described in the [Privacy Policy](/privacy), except for Live financial records, which we keep for 7 years (see the Merchant Terms). Ending Ledger doesn't cancel the subscriptions in your Stripe account; you manage those in Stripe. Sections 5 to 8 and 11 to 15 continue after termination. ## 11. Disclaimers THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE", AND IS A BETA. TO THE FULLEST EXTENT THE LAW ALLOWS, WE DISCLAIM ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. WE DON'T PROMISE THAT THE SERVICE WILL BE UNINTERRUPTED, ERROR-FREE OR SECURE, OR THAT CREDIT BALANCES, USAGE RECORDS OR CHARGES WILL ALWAYS BE ACCURATE. YOU ARE RESPONSIBLE FOR CHECKING YOUR BILLING AGAINST YOUR STRIPE ACCOUNT. ## 12. Limitation of liability TO THE FULLEST EXTENT THE LAW ALLOWS: * NEITHER PARTY IS LIABLE FOR INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL OR PUNITIVE DAMAGES, OR FOR LOST PROFITS, REVENUE, DATA OR GOODWILL, EVEN IF TOLD THEY WERE POSSIBLE. * EACH PARTY'S TOTAL LIABILITY ARISING OUT OF OR RELATING TO THE AGREEMENT IS LIMITED TO THE GREATER OF (A) THE FEES YOU PAID US FOR THE SERVICE IN THE 12 MONTHS BEFORE THE EVENT THAT GAVE RISE TO THE CLAIM AND (B) US \$100. * THESE LIMITS DON'T APPLY TO YOUR OBLIGATIONS UNDER SECTION 13 (INDEMNITY), YOUR BREACH OF SECTION 5 (ACCEPTABLE USE), YOUR OBLIGATION TO PAY FEES, OR LIABILITY THAT THE LAW DOESN'T ALLOW TO BE LIMITED, SUCH AS FOR FRAUD. ## 13. Indemnity You will defend us and our officers, employees and agents against any third-party claim, and pay the resulting losses, costs and reasonable legal fees, to the extent the claim arises from Your Data, your products or services, your relationship with your customers (including charges, refunds, disputes, taxes and consumer-protection claims), or your breach of the Agreement or the law. We'll tell you promptly about the claim, let you control its defence, and cooperate at your cost. You may not settle a claim in a way that admits fault on our behalf without our consent. ## 14. Changes to these Terms We may update these Terms. For a material change we'll give at least 30 days' notice by email or in the dashboard, and the effective date at the top of this page will change. If you keep using the Service after a change takes effect, you accept it. If you don't agree, stop using the Service and close your workspace before then. ## 15. General * **Governing law and courts.** California law governs the Agreement, without regard to conflict-of-laws rules. The state and federal courts in San Francisco County, California have exclusive jurisdiction, and both parties consent to them. Either party may seek an injunction in any court to protect its intellectual property or confidential information. * **Notices.** We send notices to the email address of your workspace's owner or in the dashboard. Send legal notices to [legal@integrateledger.com](mailto:legal@integrateledger.com), with a copy by post to the address above. * **Assignment.** You may not transfer the Agreement without our consent, except to a successor of your whole business that agrees to it. We may transfer it to an affiliate or a successor of our business. * **Force majeure.** Neither party is liable for delays caused by events beyond its reasonable control, including outages of Stripe, our hosting providers or the internet. * **Sanctions and export.** You confirm that you aren't on a U.S. government sanctions list and won't use the Service in a sanctioned country or region. * **Whole agreement.** The Agreement is the whole agreement between us about the Service and replaces earlier agreements on the subject. If documents conflict, the Data Processing Addendum controls for personal data, then the Merchant Terms for Live payments, then these Terms. Purchase orders or other terms you send don't apply. * **Other.** If a court finds part of the Agreement unenforceable, the rest still applies. Not enforcing a right isn't a waiver of it. The parties are independent contractors. ## Contact Questions about these Terms: [legal@integrateledger.com](mailto:legal@integrateledger.com). Support: [support@integrateledger.com](mailto:support@integrateledger.com). # Webhooks Source: https://docs.integrateledger.com/webhooks Receive signed events when customers, subscriptions, invoices and balances change. Ledger sends an HTTPS `POST` to your endpoint when something changes: a customer subscribes, a renewal is paid, credits are granted, a balance runs low. Use it to provision access, send emails or keep your own database in step without polling. Each environment has its own endpoints: Sandbox events go to Sandbox endpoints, Live events to Live ones. ## Add an endpoint In the dashboard, open **Settings › Webhooks**, choose **Add endpoint**, enter your URL and pick the events. Store the signing secret (`whsec_…`) with your other server secrets: it is shown once. Subscribing to all events (`*`) includes ones Ledger adds later. Live endpoints must be public `https://` URLs. In the dashboard, **Send test event** sends a `webhook_endpoint.test` event to that endpoint alone. ## Verify the signature Every request carries a `Ledger-Signature` header: ``` Ledger-Signature: t=1762539631,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` `t` is when Ledger signed it (Unix seconds) and `v1` is the hex HMAC-SHA256 of `{t}.{raw body}` with your signing secret. After you rotate the secret, Ledger signs with both the new and the old secret for 24 hours, so the header carries two `v1` values and either one verifies. Verify the **raw** body: JSON that was parsed and serialized again won't match. Refuse signatures older than five minutes, so a captured request can't be replayed. ```typescript TypeScript SDK theme={"system"} import { verifyWebhook, WebhookSignatureError } from "integrateledger/webhooks"; export async function POST(request: Request): Promise { let event; try { event = await verifyWebhook({ payload: await request.text(), signature: request.headers.get("ledger-signature"), secret: process.env.LEDGER_WEBHOOK_SECRET ?? "", }); } catch (error) { if (error instanceof WebhookSignatureError) { return new Response("Invalid signature", { status: 400 }); } throw error; } await queueForProcessing(event); // answer fast; do the work afterwards return new Response(null, { status: 200 }); } ``` ```python Python SDK theme={"system"} import os from flask import Flask, abort, request from integrateledger import WebhookSignatureError, verify_webhook app = Flask(__name__) @app.post("/webhooks/ledger") def ledger_webhook(): try: event = verify_webhook( request.get_data(), request.headers.get("Ledger-Signature"), os.environ["LEDGER_WEBHOOK_SECRET"], ) except WebhookSignatureError: abort(400) queue_for_processing(event) # answer fast; do the work afterwards return "", 200 ``` ## Handle the event ```json theme={"system"} { "id": "evt_3QeV8vK2mN7pL1xR9sT4uW6y", "object": "event", "type": "subscription.created", "apiVersion": "2026-11-07", "created": "2026-11-07T18:20:31.000Z", "livemode": false, "environment": "sandbox", "data": { "object": { "object": "subscription", "id": 42, "customerId": "user_123", "planId": "pro", "planName": "Pro", "monthlyPriceCents": 4900, "monthlyCredits": 1000, "status": "active", "currentPeriodStart": "2026-11-07T18:20:05.000Z", "currentPeriodEnd": "2026-12-07T18:20:05.000Z", "createdAt": "2026-11-07T18:19:12.000Z" } } } ``` * **`id`** is the same on every retry of an event. Store it and skip events you've already handled. * **`created`** is when the change happened, not when the request was sent. * **`apiVersion`** names the shape. A breaking change ships as a new version; an endpoint keeps the version it was created with. * **Order isn't guaranteed.** A retry can arrive after a later event. Compare `created`, or read the current state from the API, before acting on an old event. * **Return a 2xx within 10 seconds.** Do the work after you answer: anything else (a redirect, a 4xx, a 5xx, a timeout) counts as a failure. ## Events | Type | When | `data.object` | | - | - | - | | `customer.created` | A customer is created (not when an existing ID is returned) | `customer`: `customerId`, `createdAt` | | `customer.deleted` | A customer is deleted. Their subscriptions and credits go with them, without separate events | `customer`: `customerId`, `deleted: true` | | `subscription.created` | A plan becomes active: a free plan is attached, or a paid plan's first invoice is paid | `subscription` | | `subscription.updated` | The plan changed (an upgrade was applied), a new period started (a renewal), or Stripe changed its status (`past_due`, `unpaid`, `paused`, or back to `active`). `data.previousAttributes` has the earlier `planId`, period or `status` | `subscription` | | `subscription.canceled` | A subscription ended: it was canceled in Stripe, reached the end of a scheduled cancellation, or a paid plan replaced a free one | `subscription` | | `invoice.paid` | Ledger verified a paid Stripe invoice: the first payment, a renewal or an upgrade | `invoice`: `id` (Stripe's), `customerId`, `subscriptionId`, `planId`, `billingReason`, `amountPaidCents`, `currency`, `periodStart`, `periodEnd`, `hostedInvoiceUrl` | | `credits.granted` | Credits were added: a plan period, a renewal, an upgrade, a credit pack or a manual adjustment | `credit_grant`: `id`, `customerId`, `source` (`plan`, `add_on` or `adjustment`), `planId` or `addOnId`, `credits`, `periodStart`, `expiresAt` | | `balance.low` | A tracked use took the customer's balance from above your threshold to at or below it | `balance`: `customerId`, `availableCredits`, `threshold`, `featureId` | | `balance.depleted` | The first refused use in the customer's billing period | `balance`: `customerId`, `availableCredits`, `featureId`, `creditCost`, `reason` (`insufficient_credits` or `overage_cap_reached`), `periodStart` | | `checkout.completed` | A plan or credit-pack Checkout was completed | `checkout`: `id` (Stripe's), `customerId`, `mode`, `planId` or `addOnId`, `subscriptionId` or `addOnPurchaseId` | | `checkout.expired` | A Checkout expired without payment | `checkout`, as above | A paid plan's `checkout.completed` arrives before Ledger has verified the invoice; act on `subscription.created` to give access. Ledger doesn't send failed-payment events yet: the subscription keeps its state until Stripe cancels it. ### Low and depleted balances A customer's credits are one balance, whatever feature spends them, so both balance events are about the customer, with the feature that triggered them. * **`balance.low`** is off until you set a threshold in credits under **Settings › Webhooks › Low balance alert** (one per environment). It fires when a use crosses the threshold, once; it fires again only after the balance goes back above it (a renewal or a credit pack) and crosses again. * **`balance.depleted`** fires on the first refused request in the customer's billing period (the plan's period, or the calendar month in UTC for a customer without a plan). Later refusals in that period send nothing. ## Retries and turned-off endpoints If a delivery fails, Ledger tries again after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours twice, and then every 12 hours: 12 attempts over about three days, each a little later at random so endpoints that failed together don't all retry together. The delivery log in **Settings › Webhooks** shows each delivery's attempts and lets you resend one. When every delivery to an endpoint has failed for three days, Ledger turns the endpoint off and says so in **Settings › Webhooks**. No email is sent. Events that happen while it's off aren't sent to it. Fix the receiver, then turn it back on there or with [`disabled: false`](/api-reference/webhooks/update); deliveries that were still retrying resume. Ledger keeps events and their delivery log for 30 days. > [!NOTE] > > **Other ways.** To manage endpoints from code, use the [webhook endpoint API](/api-reference/webhooks/create). Without an SDK, verify the `v1` HMAC described above with your language's crypto library.