# Agent-serve Source: https://tanso.mintlify.app/agent-serve Make your product buyable by AI agents: machine-readable pricing, programmatic signup, scoped keys, off-session payments, and a burndown API Self-serve funnels assume hands and eyeballs — signup forms, checkout pages, email loops. An AI agent either completes your funnel programmatically or it doesn't convert. Tanso gives the product you build on it an agent-facing surface out of the box: your customers' buying agents can discover pricing, sign up, pay, and monitor usage without a human touching anything. Everything here is **opt-in per account** and fails closed: until you set a slug and flip the toggles, none of these surfaces exist. ## 1. Discover: machine-readable pricing ```bash theme={null} curl https://YOUR-INSTANCE/public/v1/catalog/{slug}/pricing.json ``` No authentication. Returns your ACTIVE plans (price, interval, features, included credits), the current credit weight table and price book, governance flags, and integration pointers, in the [agent-serve pricing.json schema](https://github.com/katrinalaszlo/agent-serve). Enable it in settings: set a `slug` and `publicCatalogEnabled`. An unknown slug and a disabled catalog return the same 404. ## 2. Sign up: one call, no CAPTCHA ```bash theme={null} curl -X POST https://YOUR-INSTANCE/public/v1/catalog/{slug}/signup \ -H 'Content-Type: application/json' \ -d '{"email": "principal@example.com"}' ``` Returns a customer (`agent_…` reference ID), a subscription to your designated **free** default plan, and a customer-scoped API key — once. Enable with `agentSignupEnabled` + `agentSignupDefaultPlanId` (validated free and ACTIVE at save). An hourly per-account cap returns `429` with `Retry-After`. ## 3. Scoped keys Customer-scoped keys (`ck_live_…`/`ck_test_…`) are pinned to one customer and carry scopes: `read` (balances, entitlements, usage) and `purchase` (actions that spend money). Endpoints not deliberately opened to customer keys deny them — a leaked agent key cannot see other customers or touch tenant configuration. Tenants manage keys with their own `sk_` key: ```bash theme={null} POST /api/v1/client/customers/{ref}/keys # create (plaintext once) GET /api/v1/client/customers/{ref}/keys # list (hints only) POST /api/v1/client/customers/{ref}/keys/{id}/rotate # rotate that one key DELETE /api/v1/client/customers/{ref}/keys/{id} # revoke ``` ## 4. Pay: off-session with a saved card, 402 fallback without **Pre-authorize once** (SetupIntent — card data never touches Tanso): ```bash theme={null} POST /api/v1/client/customers/{ref}/payment-methods/setup-intent # → {setup_intent_id, client_secret} — principal confirms with Stripe POST /api/v1/client/customers/{ref}/payment-methods/default # → {"paymentMethodId": "pm_..."} ``` **Subscribe** (`POST /api/v1/client/subscriptions`) with a saved or supplied `paymentMethodId` charges off-session and returns the created subscription synchronously. Without one, customer-key callers get **HTTP 402** (`payment_required`) carrying a hosted `checkoutUrl` for the principal plus a `checkoutSessionId` to poll: ```bash theme={null} GET /api/v1/client/checkout-sessions/{id} # → {"status": "PENDING" | "COMPLETED", "subscriptionId": ...} ``` **Buy credits** at the current price book rate: ```bash theme={null} POST /api/v1/client/credits/purchases # {"creditPoolId": "...", "credits": 1000, "paymentMethodId": "pm_..."} ``` Success grants a `PURCHASED` credit grant stamped at book price, idempotent by payment intent. The same 402 + polling fallback applies. Every agent-initiated charge is checked against the account's spend cap (`agentMaxTopupAmount`) **before** money moves. ## 5. Use: pre-flight quotes and the burndown API Entitlement checks return a `creditQuote` with estimated credits and cost — an agent can ask "what will this run cost me, and can I afford it" before doing the work. For the standing question — *when do I run out* —: ```bash theme={null} GET /api/v1/client/customers/{ref}/usage ``` Per-feature current-period usage with a linear end-of-period projection, and per-pool credit balances with average daily burn, projected depletion date, and the current credit price. ## Dev readiness * Every error carries a stable `code` (`unauthorized`, `payment_required`, `insufficient_credits`, `idempotency_conflict`, …) in one envelope shape — branch on codes, not messages. * Mutating client-API requests accept an `Idempotency-Key` header: identical retries within 24h replay the stored response; a reused key with a different body returns `409 idempotency_conflict`. * OpenAPI at `/v3/api-docs`, Swagger UI at `/swagger-ui.html`. ## MCP for customer agents The [MCP server](/mcp) includes a curated customer-facing tool set that works with `ck_` keys: `listPlans`, `getCreditPrices`, `checkEntitlement`, `getUsageForecast`, `subscribePlan`, `purchaseCredits`. Spend tools require `confirmAction: true`. Tenant-configuration tools (`Admin*`, Stripe setup) are gated behind `app.mcp.admin-tools.enabled` (default false) so an agent key can never reconfigure your pricing. ## Deliberately not yet Rate-limit headers, outbound webhooks (usage thresholds, spend alerts), per-key budgets, Web Bot Auth, and A2A agent cards are roadmap. The `governance` block in pricing.json reports only what is true. # Authentication Source: https://tanso.mintlify.app/authentication API keys for machines, JWTs for humans Tanso has two authentication surfaces, matching its two APIs. ## Client API - API keys Everything under `/api/v1/client/**` (and the MCP server at `/mcp`) authenticates with a long-lived API key. Keys are prefixed `sk_test_` or `sk_live_`, and both header forms work: ```bash theme={null} # Either of these -H "X-API-Key: sk_test_..." -H "Authorization: Bearer sk_test_..." ``` Requests without a valid key get `401`. Every call is scoped to the key's account, so there is no cross-account access. ## Managing API keys API key management requires an Admin API JWT. Retrieval returns only a masked value: ```bash theme={null} curl http://localhost:8080/api/v1/account/api-key \ -H "Authorization: Bearer $TOKEN" ``` Rotate the key when you need a new secret: ```bash theme={null} curl -X POST http://localhost:8080/api/v1/account/api-key \ -H "Authorization: Bearer $TOKEN" ``` Rotation invalidates all previous account keys atomically. The response to the `POST` contains the new key in full exactly once; later `GET` responses are masked. Store the rotated key in your secret manager before leaving the response. ## Admin API - JWT All `/api/v1/**` routes outside the Client API require a session token from login. This JWT-protected surface includes: * `/api/v1/monetization/**` for the catalog, rules, subscriptions, credits, and billing. * `/api/v1/account/**` for account details. * `/api/v1/tanso/**` for events, imports, settings, and model pricing. * `/api/v1/data/**` for Stripe data and imports. * `/api/v1/analytics/**` for analytics and insights. Log in to obtain a token: ```bash theme={null} curl -X POST http://localhost:8080/public/v1/login \ -H "Content-Type: application/json" \ -d '{"username":"test","password":"password"}' ``` The response contains a JWT; pass it as `Authorization: Bearer `. Tokens are signed with your `JWT_SECRET` and expire after 2 hours. ## Which API do I use? | You are... | Use | | ------------------------------------------------------------- | -------------------- | | Your product's backend checking entitlements, reporting usage | Client API, API key | | Defining plans, features, pricing rules | Admin API, JWT | | An AI agent operating the platform | [MCP](/mcp), API key | The split exists so the credentials you embed in your application can never alter your catalog or pricing. # Billing lifecycle Source: https://tanso.mintlify.app/billing-lifecycle From empty database to attributed revenue — every call verified This walkthrough goes from a fresh install to a paying, metered customer. Every request and response below was run against a clean instance; where the API's behavior might surprise you, that's called out rather than smoothed over. Assumes the [quickstart](/quickstart) stack and its seeded credentials. `$TOKEN` is a JWT from [login](/authentication); `$KEY` is the seeded API key. ## 1. Create a feature ```bash theme={null} curl -X POST http://localhost:8080/api/v1/monetization/features \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"name":"API Calls","key":"api-calls","description":"Metered API calls","featureType":"METERED"}' ``` ## 2. Create a plan — it starts as a DRAFT ```bash theme={null} curl -X POST http://localhost:8080/api/v1/monetization/plans \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"key":"starter","name":"Starter","description":"Starter tier","priceAmount":10.00,"intervalMonths":1}' ``` Plans are created in `DRAFT` status regardless of what you pass at creation, and draft plans reject subscriptions. Activation is a deliberate, separate step (step 4). ## 3. Link the feature with a pricing rule ```bash theme={null} curl -X POST http://localhost:8080/api/v1/monetization/rules/plan-features \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "planId": "", "featureId": "", "isEnabled": true, "type": "BASE", "value": { "pricing": { "model": "usage", "price_per_unit": 0.10, "usage_unit_type": "api_calls" } } }' ``` `type` is always `BASE`; the behavior lives in `value.pricing`. For usage pricing, `price_per_unit` and `usage_unit_type` are both required — the API returns a specific error naming the missing field if you omit either. ## 4. Activate the plan ```bash theme={null} curl -X PATCH http://localhost:8080/api/v1/monetization/plans/ \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"status":"ACTIVE","description":"Starter tier"}' ``` Activation is guarded: the plan must have at least one linked feature (step 3) and a non-empty description, or the API returns an error naming what's missing. ## 5. Create a customer — keyed by your ID ```bash theme={null} curl -X POST http://localhost:8080/api/v1/client/customers \ -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"customerReferenceId":"cust_demo_1","email":"demo@example.com"}' ``` `customerReferenceId` is your system's ID for this customer. You never need Tanso's internal UUID on the client API. ## 6. Subscribe ```bash theme={null} curl -X POST http://localhost:8080/api/v1/client/subscriptions \ -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"customerReferenceId":"cust_demo_1","planId":""}' ``` The client subscribe endpoint takes `customerReferenceId` — not `customerId`. The subscription is created **inactive**, and a `DUE` invoice for the base price is generated. Access begins when that invoice is paid. ## 7. Pay the invoice Without Stripe connected, invoices are settled explicitly: ```bash theme={null} curl -X POST http://localhost:8080/api/v1/client/billing/invoices//mark-paid \ -H "X-API-Key: $KEY" ``` This activates the subscription and grants its entitlements. With Stripe connected, behavior depends on the account's Stripe mode: * **Tanso handles billing** (`PAYMENT_PASS_THROUGH`) — Tanso stays the source of truth. `mark-paid` still works, or fetch a hosted payment page with `POST /api/v1/client/billing/subscriptions//stripe/checkout` and let the `invoice.paid` webhook settle it. * **Stripe drives billing** (`STRIPE_INTEGRATION`) — subscribing a paid plan returns a Stripe Checkout URL instead of creating the subscription immediately; the subscription is created by webhook once the customer pays, and `mark-paid` is rejected. ## 8. Check the entitlement — now allowed ```bash theme={null} curl http://localhost:8080/api/v1/client/entitlements/cust_demo_1/api-calls \ -H "X-API-Key: $KEY" ``` ```json theme={null} {"data":{"referenceCustomerId":"cust_demo_1","featureKey":"api-calls", "usage":{"used":0},"allowed":true},"success":true} ``` Before payment this same call returned `allowed: false`. Enforcement is real-time: the check reflects subscription state at the moment you ask. ## 9. Report usage — revenue is attributed immediately ```bash theme={null} curl -X POST http://localhost:8080/api/v1/client/events \ -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{ "eventName": "api_call", "featureKey": "api-calls", "customerReferenceId": "cust_demo_1", "eventIdempotencyKey": "evt-0001", "usageUnits": 12 }' ``` The event books `12 × $0.10 = $1.20` of revenue at ingestion — margin analytics don't wait for invoice time. Replaying the same `eventIdempotencyKey` returns `409 Conflict`; generate a unique key per event. At cycle close, the scheduler rolls the period's usage into the next invoice automatically. # Concepts Source: https://tanso.mintlify.app/concepts The two-halves mental model: Monetization and Internal spend on one ledger. Tanso is one ledger fed from two directions. A customer's usage event and a vendor's admin API both land on the same store, so the numbers on both sides of your AI business come from the same source instead of two spreadsheets that never quite agree. Vendor admin APIs and customer usage events both feed one ledger: Internal spend reconciles to the invoice, Monetization prices per customer. ## Monetization — the AI you sell A customer's usage event carries what it cost you to serve, next to what you billed for it. Metering, entitlements, credits, subscriptions and Stripe billing all read and write the same ledger, so margin per customer, per feature and per model falls out of it rather than requiring a separate report. Start at [Introduction](/introduction) and [Quickstart](/quickstart). ## Internal spend — the AI you buy Usage and cost are pulled from the vendors' own admin APIs (Anthropic, OpenAI, Cursor, GitHub Copilot, LiteLLM) on a schedule — no proxy in the request path, no agent on anyone's laptop. It's reconciled against the invoice, allocated to teams and projects, budgeted, and joined to what it produced. Start at [Internal AI spend](/internal-spend). ## Where they meet Feature P\&L links a project on the Internal spend side to the feature it shipped on the Monetization side, and reports that feature's revenue and serving cost next to what it cost to build — net margin, in one number. Nobody else can draw that table because nobody else has both halves on one engine. ## Either half runs on its own Neither side depends on the other being configured. Set `APP_MODULES_MONETIZATION_ENABLED=false` for an internal-spend-only install, or `APP_MODULES_BUILD_ENABLED=false` to run monetization alone — the other half's routes disappear (`404 module_disabled`) and its console section hides. See [Self-hosting](/self-hosting) for the full flag list. # Credit forecasts Source: https://tanso.mintlify.app/credit-forecasts Embeddable, provider-neutral credit and usage burndown forecasting for your dashboard [`credit-estimator`](https://github.com/tansohq/credit-estimator) is a separate open-source project, published to npm under the `@tansohq` scope. It forecasts credit or usage runway from observed history and explicit low/base/high burn assumptions, and renders the result as an accessible React widget. It is **provider-neutral**: the deterministic core and React UI have no Tanso dependency and work the same way for any host. An optional adapter maps a Tanso snapshot into the same neutral input any other host would supply. This is a read-only forecasting tool. It does not own wallets, ledgers, usage events, entitlements, subscriptions, or payments — it projects a balance from data you supply. It never writes back to Tanso. ## Packages | Package | Purpose | | --------------------------------- | ------------------------------------------------------------------ | | `@tansohq/credit-forecast-schema` | Provider-neutral Zod schemas and TypeScript contracts | | `@tansohq/credit-forecast-core` | Deterministic burndown calculation, browser and Node.js compatible | | `@tansohq/credit-burndown-react` | Controlled, accessible React components | | `@tansohq/credit-forecast-json` | Deterministic JSON import and export | | `@tansohq/credit-forecast-csv` | Portable multi-file CSV import and export | | `@tansohq/credit-forecast-tanso` | Optional Tanso snapshot-to-neutral mapping | All are ESM-only; CommonJS hosts need dynamic `import()`. React peers are `^18.2 || ^19`. ## Calculate a forecast ```bash theme={null} npm install @tansohq/credit-forecast-core ``` ```typescript theme={null} import { forecastCreditUsage } from "@tansohq/credit-forecast-core"; const result = forecastCreditUsage({ schemaVersion: "1.0", methodologyVersion: "1.0", asOf: "2026-01-03", period: { startDate: "2026-01-01", endDate: "2026-01-06", allocation: "500", lowBalanceThreshold: "50", }, lookbackDays: 2, dailyUsage: [ { date: "2026-01-01", creditsUsed: "40" }, { date: "2026-01-02", creditsUsed: "60" }, ], balance: { current: "400", schedule: [] }, scenarios: [ { key: "low", burnMultiplier: "0.75" }, { key: "base", burnMultiplier: "1" }, { key: "high", burnMultiplier: "1.5" }, ], }); ``` All credit values are canonical decimal strings, not floats. The core reads no clock, filesystem, network, or credentials — `asOf` is explicit, and invalid input throws a structured `ForecastValidationError`. ## Plan credits before committing The same core package answers the buyer-side question — "how many credits does this period need?" — with no usage history: ```typescript theme={null} import { planCreditUsage } from "@tansohq/credit-forecast-core"; const plan = planCreditUsage({ schemaVersion: "1.0", methodologyVersion: "1.0", period: { startDate: "2026-02-01", endDate: "2026-03-01" }, metricEstimates: [ { key: "api-calls", label: "API calls", estimatedUnits: "1000", creditsPerUnit: "0.5" }, { key: "reports", label: "Generated reports", estimatedUnits: "20", creditsPerUnit: "5" }, ], allocation: "700", scenarios: [ { key: "low", burnMultiplier: "0.8" }, { key: "base", burnMultiplier: "1" }, { key: "high", burnMultiplier: "1.25" }, ], }); ``` Each metric's planned credits is `estimatedUnits × creditsPerUnit`, scenarios scale that by their multipliers, and the optional `allocation` yields utilization, surplus or shortfall, and a `WITHIN_ALLOCATION` / `OVER_ALLOCATION` status per scenario. Everything is an explicit input — the calculator never recommends weights or allocations, never produces money amounts, and never predicts usage from history. Invalid input throws a structured `PlanValidationError`. For a Tanso host, no adapter is needed: `creditsPerUnit` is the resolved weight your [tariff](/credit-weights) returns as `creditQuote.weight`, and `allocation` is the candidate credit grant. The React package renders it with buyer-facing defaults (Conservative / Expected / Aggressive scenario labels, an allocation meter, a per-metric breakdown, and the calculation trace): ```tsx theme={null} import { planCreditUsage } from "@tansohq/credit-forecast-core"; import { CreditPlan } from "@tansohq/credit-burndown-react"; import "@tansohq/credit-burndown-react/styles.css"; const result = planCreditUsage(input); ; ``` ## Embed the widget ```bash theme={null} npm install @tansohq/credit-burndown-react ``` ```tsx theme={null} import { forecastCreditUsage } from "@tansohq/credit-forecast-core"; import { CreditBurndown } from "@tansohq/credit-burndown-react"; import "@tansohq/credit-burndown-react/styles.css"; const result = forecastCreditUsage(input); ; ``` The component is result-controlled: you calculate (in your browser or your backend) and pass both `input` and `result` in as props. It has no fetch, authentication, persistence, or billing dependency, so it never resolves a stale forecast on its own. Theme it with the `--credit-burndown-*` CSS variables, override any message string, inject your own action slot, and control the selected scenario from outside. ## Feeding it from Tanso `@tansohq/credit-forecast-tanso` maps a Tanso snapshot plus explicit assumptions into the same `ForecastInput` any other host would build by hand: ```typescript theme={null} import { forecastCreditUsage } from "@tansohq/credit-forecast-core"; import { mapTansoSnapshotToForecastInput, type TansoForecastAssumptions, type TansoForecastSnapshot, } from "@tansohq/credit-forecast-tanso"; const snapshot: TansoForecastSnapshot = { sourceSchemaVersion: "1.0", asOf: "2026-01-03", currentBalance: "400", dailyUsage: [ { date: "2026-01-01", creditsUsed: "40" }, { date: "2026-01-02", creditsUsed: "60" }, ], }; const assumptions: TansoForecastAssumptions = { schemaVersion: "1.0", methodologyVersion: "1.0", period: { startDate: "2026-01-01", endDate: "2026-01-06", allocation: "500", lowBalanceThreshold: "50", }, lookbackDays: 2, scheduledBalanceDeltas: [], scenarioMultipliers: { low: "0.75", base: "1", high: "1.5" }, }; const input = mapTansoSnapshotToForecastInput(snapshot, assumptions); const result = forecastCreditUsage(input); ``` The adapter does not fetch data, call a Tanso API, or reconstruct a balance — you assemble `snapshot` from your own authenticated call to the [credits Client API](/credits), such as a pool's `/pools/{poolId}/transactions` history. There is no automatic Tanso source connector. Mapping failures throw `TansoMappingError`, whose `toJSON()` returns `{ code: "TANSO_MAPPING_FAILED", issues }`. Tanso remains authoritative for actual balances, grants, deductions, and billing state. The forecast is a projection from the snapshot you supplied at call time — if it and Tanso's current state have since diverged, refetch the snapshot and recalculate. Nothing in this package reserves credits, alters an entitlement decision, or writes back to the ledger. See the [optional Tanso adapter boundary](https://github.com/tansohq/credit-estimator/blob/main/docs/tanso-integration.md) for the full contract, including what the adapter is forbidden from doing. # Credit prices Source: https://tanso.mintlify.app/credit-prices The price book: what one credit costs the buyer, per denomination — the second pricing dial Credit pricing has two independent dials. The [weight table](/credit-weights) sets how many credits an action burns; the price book sets what one credit costs in money. Keeping them separate means you can reprice credits without touching plans, allocations, or weights — and when a price moves, it's unambiguous which dial moved. The price book holds one current price per `(account, denomination)`: a `pricePerCredit` and an ISO 4217 currency, versioned by `effectiveFrom`. A denomination with no published price is simply unpriced — nothing invents a price for it. ## Publishing prices Use the console (**Credits → Pricing**) or the admin API: ```bash theme={null} curl -X POST http://localhost:8080/api/v1/monetization/credits/prices/publish \ -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \ -d '{ "effectiveFrom": "2026-09-01T00:00:00Z", "entries": [ {"denomination": "credits", "pricePerCredit": 0.01, "currency": "USD"} ] }' ``` The rules are the same ones that keep the weight tariff trustworthy: * **One batch, one effective time.** All entries share `effectiveFrom` and land in one transaction. * **`effectiveFrom` must be in the future.** The settled price book is never rewritten; publishing "as of yesterday" is rejected with `400`. * **Effective rows are append-only.** To change a price, publish a new row with a later effective time. Only rows that haven't taken effect yet can be deleted (`DELETE /prices/{id}`). * **Same-instant collisions return `409`.** An identical replay of the same batch is an idempotent no-op; a different batch at the same instant is rejected. * Prices are positive, at most 6 decimals, capped at 1,000,000. Each `denomination` must match a credit model on your account, and `currency` defaults to `USD`. Read it back with `GET /api/v1/monetization/credits/prices` (current and scheduled rows) or `GET /api/v1/monetization/credits/prices/history?denomination=credits`. ## Where the price shows up **Entitlement quotes.** When the denomination is priced, the `creditQuote` on an entitlement check carries the money view alongside the credit view: ```json theme={null} {"creditQuote":{"weight":8,"estimatedCredits":8, "pricePerCredit":0.01,"currency":"USD","estimatedCost":0.08}} ``` `estimatedCost` is `estimatedCredits × pricePerCredit`. All three fields are null when the denomination is unpriced. **Purchased grants.** A grant with `grantType: "PURCHASED"` and no explicit `unitPrice` is stamped with the book price current at grant time, so every sale carries the price it happened at even after the book moves. An explicit `unitPrice` on the grant request (a negotiated top-up) always wins; sending `currency` without `unitPrice` is rejected. **Client API.** Integrators can read the current list prices to render "buy credits" UI: ```bash theme={null} curl http://localhost:8080/api/v1/client/credits/prices -H "X-API-Key: $KEY" ``` This returns only prices already in effect — scheduled future changes are not visible to API keys. Per-grant negotiated prices are never in the book; they live on the grant. # Credit weights Source: https://tanso.mintlify.app/credit-weights The server-side tariff: how many credits one usage unit burns, per feature and model By default one usage unit burns one credit. The weight table lets you change that server-side — "a `deep-research` call costs 5 credits, and 8 when it runs on `gpt-4.1`" — without redeploying your client. Requires `@tansohq/sdk` >= 0.3.0 for typed access; the raw API works from any version. The weight table is one of two pricing dials: it sets how many credits an action burns. What one credit costs in money is the [price book](/credit-prices), versioned and published the same way. ## Resolution Weights resolve most-specific first, at the event's `occurredAt`: 1. `(feature, model)` — exact match on the model string 2. `(feature, any model)` — the feature's default row 3. `1.0` — identity, when no row exists Model matching is **exact**, unlike cost resolution (which fuzzy-matches model names). The `model` you send on evaluate and the `costInput.model` you send on ingest must be the same string, or the charge silently falls back to the feature default. Charge = `usageUnits × weight`, rounded once before the pool draw-down. Every response tells you which row matched via `weightMatch`: `MODEL`, `FEATURE_DEFAULT`, or `NONE`. ## Publishing a tariff Use the console (**Credits → Weights**) or the admin API: ```bash theme={null} curl -X POST http://localhost:8080/api/v1/monetization/credits/weights/publish \ -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \ -d '{ "effectiveFrom": "2026-08-01T00:00:00Z", "entries": [ {"featureId": "…", "creditsPerUnit": 5}, {"featureId": "…", "model": "gpt-4.1", "creditsPerUnit": 8} ] }' ``` The rules that keep a tariff trustworthy: * **One batch, one effective time.** All entries share `effectiveFrom`, applied in one transaction — there is no window where half the tariff is live. * **`effectiveFrom` must be in the future.** The settled ledger is never repriced. Publishing "as of yesterday" is rejected with `400`. * **Effective rows are append-only.** To change a price, publish a new row with a later effective time. Only rows that haven't taken effect yet can be deleted (`DELETE /weights/{id}`, the correction path for a fat-fingered future tariff). * **Same-instant collisions return `409`.** An identical replay of the same batch is an idempotent no-op; a *different* batch at the same instant is rejected. * Weights are positive, at most 6 decimals, capped at 1,000,000. If you find yourself needing `0.1`, your credit denomination is 10× too large. Trying it out: since `effectiveFrom` must be in the future, publish your test tariff a minute out and wait for it — there is no "effective immediately." That friction is the feature: production tariffs get the same deliberate cutover. ## Quote, then record Ask what an action would burn before doing the billable work: ```bash theme={null} curl -X POST http://localhost:8080/api/v1/client/entitlements \ -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \ -d '{"customerReferenceId":"demo-user","featureKey":"ai.chat", "usage":{"usageUnits":1,"model":"gpt-4.1"}}' ``` ```json theme={null} {"data":{"allowed":true, "creditQuote":{"weight":8,"estimatedCredits":8, "weightId":"…","weightMatch":"MODEL", "pricePerCredit":0.01,"currency":"USD","estimatedCost":0.08}},"success":true} ``` The quote is **a quote, not a promise**. It resolves at request time; the charge resolves at the event's `occurredAt`. A tariff cutover between the two can change the outcome — by design, so that a burst of pre-cutover checks can't lock in pre-cutover prices indefinitely. Ingestion returns the receipt: ```json theme={null} {"data":{"creditsDeducted":8,"weightApplied":8,"weightId":"…", "weightMatch":"MODEL","remainingBalance":42},"success":true} ``` These fields are absent when the feature isn't backed by a credit model. Events are never rejected on `occurredAt` — timestamps are clamped to `[now − 48h, now + 5min]` for weight resolution, so backfills and webhook retries keep working; a stale replay can't reach an old, cheaper rate. ## Migrating an existing integration This is the one place you can double-charge your customers, so read it before publishing your first tariff. If the weight table didn't exist when you integrated, your client probably sends **pre-multiplied units**: `usageUnits: 5` for a "5-credit action", because that was the only lever. If you publish a `weight: 5` tariff while that client is still deployed, the server multiplies your multiplied number — a 25-credit charge for a 5-credit action. The safe order: 1. **Deploy the client change first** — send raw usage units (`usageUnits: 1`). 2. **Accept the gap.** Until the tariff is published, raw units burn at the identity weight of 1.0. That's an undercharge — annoying, bounded, and safe. The reverse order is an overcharge to real customers. 3. **Publish the tariff** with a future `effectiveFrom`. Repricing `usageUnits` semantics is an **account-wide event**, not just a weight-table change. Everything calibrated against the old numbers needs re-checking in the same cutover: `maxUsage` caps on plan rules, Stripe meter prices, and credit grant sizing. # Credits Source: https://tanso.mintlify.app/credits The credit wallet: prepaid balances with grants, FIFO draw-down, and a full ledger Credits in Tanso are a standalone **credit wallet**, linked to subscriptions, not welded into pricing rules. That keeps two questions separate: *what does usage cost* (pricing rules) and *what balance pays for it* (credit pools). Limits are enforced at the moment of use — an empty pool declines the action before you incur the cost of serving it, not at invoice reconciliation. ## The pieces | Object | What it does | | ---------------- | ------------------------------------------------------------------------------------------------------------------------ | | **Credit model** | Defines a denomination (`api_credits`), rollover policy, and hard-limit behavior | | **Credit pool** | A customer's balance in one denomination, with running totals for granted / consumed / expired / reversed | | **Grant** | An addition to a pool, such as plan-included, purchased, or promotional credits, with optional expiry and FIFO draw-down | | **Transaction** | Append-only ledger entry with `balance_before` / `balance_after`, so every balance is auditable to the unit | Pools link to subscriptions with a **draw priority** and optional **draw limit**, so multiple pools can back one subscription and drain in a defined order. ## How deduction works When an event arrives for a feature backed by a credit model, credits are deducted synchronously at ingestion, FIFO across grants and respecting draw priority. The amount is `usageUnits × weight`, where the weight comes from the server-side tariff — see [Credit weights](/credit-weights). With no tariff published, one unit burns one credit. The deduction is stamped into the event's context, and credit-covered usage is excluded from Stripe meter forwarding so it isn't billed twice. If a pool has `hardLimit: true` and the balance cannot cover the event, the service rejects ingestion with `409 Conflict`. The response has `success: false` and an error message beginning with `Credit pool depleted`. The transaction rolls back, so the event is not stored and no credits are deducted. Use an entitlement check before serving the feature for an earlier allow/deny decision. Event rejection remains the final enforcement boundary. ## Reading balances (Client API) ```bash theme={null} # All pools for a customer curl http://localhost:8080/api/v1/client/credits/{customerReferenceId}/pools \ -H "X-API-Key: $KEY" # One pool's grants or full transaction history curl http://localhost:8080/api/v1/client/credits/{customerReferenceId}/pools/{poolId}/grants \ -H "X-API-Key: $KEY" curl http://localhost:8080/api/v1/client/credits/{customerReferenceId}/pools/{poolId}/transactions \ -H "X-API-Key: $KEY" # Current list price of one credit, per denomination (see /credit-prices) curl http://localhost:8080/api/v1/client/credits/prices -H "X-API-Key: $KEY" ``` Pool and grant management (creating models, granting credits, reversing transactions) lives on the admin API and the [MCP server](/mcp)'s admin tools. ## Design notes worth knowing * **Deduction is 1:1 by default** between usage units and the credit denomination; a published [weight tariff](/credit-weights) changes that server-side. * **Purchased grants carry their sale price.** A `PURCHASED` grant without an explicit `unitPrice` is stamped with the current [price book](/credit-prices) entry for the pool's denomination; an explicit `unitPrice` (a negotiated top-up) always wins. * **Expiry is a scheduled job** (`CreditExpirationJob`): expired grants move their remainder to the pool's `total_expired`, visible in the ledger. * **Plan-included credits** are granted on subscription cycle rollover and clawed back on cancellation; upgrades grant the delta. ## Showing customers their balance For customer-facing burndown views and depletion forecasts, use the standalone [credit forecast packages](/credit-forecasts) — they work from Tanso's pool snapshots or from your own data, with embeddable React components. # Internal AI spend Source: https://tanso.mintlify.app/internal-spend Internal spend: pull your own Anthropic and OpenAI bills, reconcile them, allocate to teams and people, budget them, and read cost per shipped thing Tanso has two halves. **Monetization**, everything else in these docs, answers what it costs to serve each customer. **Internal spend** answers what your own AI costs you: the Anthropic and OpenAI bills for your engineers and agents, and what that spend produced. It works from the vendors' admin APIs. Nothing sits in your request path and nothing runs on anyone's laptop: connect an admin key, and Tanso pulls the usage and cost reports the vendor already keeps. Everything lives under **Internal spend** in the console and `/api/v1/spend/**` on the Admin API. Either half runs on its own. `APP_MODULES_MONETIZATION_ENABLED=false` gives you internal spend without the billing engine: plans, customers, credits, invoices, the client API, Stripe and the billing jobs are off, their routes answer `404` with `"code": "module_disabled"`, and the console opens on Internal spend. `APP_MODULES_BUILD_ENABLED=false` is the reverse. Nothing about the other half needs to be configured first, and flipping the flag later needs no reinstall. Internal AI usage by model, person and day ## 1. Connect a vendor Vendor connections with status, last sync, check and sync actions **Internal spend → Connections → Connect vendor.** Paste an Anthropic admin key (`sk-ant-admin01-…`, created under Console → Settings → Organization), an OpenAI admin key, a Cursor admin API key (Enterprise plan), a GitHub token with the *View Organization Copilot Metrics* permission plus the org name, or a LiteLLM proxy's master key plus the proxy URL. The key is encrypted at rest under `APP_SECRETS_KEY` and only its last four characters are ever shown. An Anthropic Console admin key has no read-only scope: it can administer the whole organization. Use a dedicated reporting organization where you can, or a Claude Enterprise scoped key if your org has one. * **Check key** makes one cheap call and records `ACTIVE` or `ERROR` (with the vendor's own message) on the row. * **Sync now** pulls the last 30 days. After that an hourly job re-pulls the last three days — vendor reports lag by up to an hour, so yesterday is never final on the first pass. * **Replace key** swaps a key that stopped working; pulled usage stays. ```bash theme={null} POST /api/v1/spend/connections {"provider":"ANTHROPIC","label":"Engineering org","adminKey":"sk-ant-admin01-…"} POST /api/v1/spend/connections/{id}/probe POST /api/v1/spend/connections/{id}/sync?from=2026-08-01&to=2026-09-01 # [from, to), 31-day chunks PUT /api/v1/spend/connections/{id}/key {"adminKey":"…"} ``` A vendor refusal comes back as `502` with `error.code: "vendor_error"` and the vendor's message. ### What is pulled | Vendor | Report | Dimensions | | -------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Anthropic | `usage_report/messages` | model, workspace, API key, service tier; uncached / cache-read / cache-write / output tokens | | Anthropic | `cost_report` | workspace, description; the vendor's price in cents | | Anthropic | `usage_report/claude_code` | per user (email or key name), per model; tokens and the vendor's estimate | | OpenAI | `usage/completions` | project, user, API key, model; cached tokens split out of input | | OpenAI | `costs` | project, line item; dollars, stored as cents | | Cursor | `teams/filtered-usage-events` | per person and model per day: tokens incl. cache, requests, and what Cursor actually charged | | Cursor | `teams/daily-usage-data` | per person per day: accepted lines, accepts/rejects, tab accepts, agent/chat/composer requests | | GitHub Copilot | `copilot/metrics/reports/users-1-day` | per person per day: interactions, accepted code, lines, AI credits; CLI/app tokens. No dollar figure — GitHub says credits are not for invoicing | | LiteLLM | `spend/logs` | per team, key and user per day, per model: prompt/completion tokens, requests, and the spend LiteLLM computed — every provider behind the proxy in one source | Rows land in `vendor_usage_buckets` in the vendor's own dimensions. A window is deleted and rewritten on every pull, so re-syncing is idempotent. Per-person data is uneven and Tanso says so rather than guessing: Anthropic reports people only for Claude Code; OpenAI only for user-scoped keys; Cursor and Copilot per seat; usage through Bedrock or Vertex is invisible to all of them. Cursor windows are capped at 30 days by Cursor; a longer sync is pulled in 30-day chunks. ## 2. Usage **Internal spend → Usage** shows a window by model, by day, and by person, two ways: * **Metered** — tokens × the price book (`model_pricing`, including cache read/write rates). A model the price book does not know prices to zero and is named in `unpricedModels`; a model without cache rates prices cached tokens at the full input rate and is marked `~cache`. * **Vendor** — what the vendor's own cost report says. Token totals come from usage reports only. Claude Code rows are the same traffic seen per person, so they appear under **By person** and are never added on top. **By person** also shows what each vendor reports per seat, beside cost: Claude Code sessions, commits, pull requests and tool accept/reject; Cursor accepted lines, accepts/rejects and requests; Copilot interactions, accepted code and AI credits. A column is empty where the vendor reports nothing. These rows only exist while person-level attribution is on. ```bash theme={null} GET /api/v1/spend/reports/usage?from=2026-08-01&to=2026-09-01 # to is exclusive ``` ## 3. Reconcile Reconcile: metered vs vendor-reported vs invoiced per vendor **Internal spend → Reconcile** compares one period three ways per vendor: metered, vendor-reported, and invoiced, with the two variances. Dates here are inclusive — invoices are dated, not timestamped. Import the bill as a CSV with a header row: `description`, `amount` (in dollars), and optionally `kind` (`TOKEN`, `SEAT`, `TOOL`, `OTHER`), `model`, `quantity`. Seat lines count toward "invoiced" but never appear in a token cost report, so "vendor − invoice" carries the seats. An invoice only counts toward a window it sits entirely inside; a straddling one is not pro-rated. ```bash theme={null} curl -X POST https://YOUR-INSTANCE/api/v1/spend/invoices \ -H "Authorization: Bearer $JWT" \ -F provider=ANTHROPIC -F periodStart=2026-07-01 -F periodEnd=2026-07-31 \ -F currency=USD -F file=@july.csv GET /api/v1/spend/reports/reconcile?from=2026-07-01&to=2026-07-31 ``` ## 4. Teams, people and rules Teams and budgets allocation table **Internal spend → Teams.** A **unit** is a team, a project, or a person; units nest, and a unit's total is its own spend plus every descendant's. An **attribution rule** maps a vendor dimension onto a unit: | `matchKind` | Matches | | -------------- | ------------------------------------------- | | `WORKSPACE_ID` | Anthropic workspace id or OpenAI project id | | `API_KEY_ID` | The vendor's API key id | | `ACTOR` | Claude Code email or OpenAI user id | Rules apply at report time, never materialised — edit one and history re-allocates. When several rules match one row, the lowest `priority` number wins. Whatever no rule claims shows as **Unattributed**, and always sums with the rows back to the metered total. ```bash theme={null} POST /api/v1/spend/units {"type":"TEAM","name":"Backend","parentId":"…"} POST /api/v1/spend/rules {"spendUnitId":"…","provider":"ANTHROPIC","matchKind":"WORKSPACE_ID","matchValue":"wrkspc_01…","priority":100} GET /api/v1/spend/reports/allocation?from=&to= ``` Unit sheet with rules and budget ### Person-level attribution is off by default Attributing spend to a named employee is a monitoring capability — in Germany a works council can veto it. The switch under **Spend settings** stays off until you have written the **worker notice**: what staff were told. While it is off, people cannot be created, person rules are skipped, and the by-person view stays empty. A person's Claude Code estimate is shown on the person and **not** rolled up into the team: the same traffic already reaches the team through its key rules. ## 5. Budgets and alerts Each unit can carry two ceilings on UTC calendar windows: * a **daily** ceiling, small, to catch a runaway agent within the day; * a **monthly** ceiling, the real number. An alert fires once per (unit, kind, window): | Kind | When | | ----------- | ------------------------------------------------------------------------------ | | `THRESHOLD` | spend crosses `alertThreshold` % of a ceiling (default 80) | | `BREACH` | spend crosses the ceiling | | `SPIKE` | today is at least \$5 and more than twice the trailing-seven-day daily average | Spend alerts: breach and threshold, once per window, with acknowledge Budgets are checked after every sync and hourly; **Internal spend → Alerts** lists what fired, **Acknowledge** clears it. Store a Slack incoming webhook under Spend settings (it is stored encrypted and never shown again) and each alert is posted there as it fires. Tanso is not in your request path, so on its own a budget cannot stop a request. A budget set to **Block** only alerts — its message says so — unless a gateway is connected (below). Without one, enforce at your gateway by hand (Portkey and Bifrost also take per-key budgets) or revoke the key. ### Where alerts go Under **Internal spend → Teams → Settings**: | Channel | What is sent | Setup | | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | Slack | one line per alert; the digest as text | an incoming webhook URL (`https://hooks.slack.com/…`) | | Webhook | `POST` JSON: `{"type":"spend.alert","accountId":…,"alert":{…}}` or `{"type":"spend.digest",…,"digest":{…}}`; header `X-Tanso-Event`; with a secret, `X-Tanso-Signature: sha256=` | any https URL, optional signing secret — both stored encrypted | | Email | subject + text and an HTML table for the digest | comma-separated addresses; the server needs `APP_RESEND_API_KEY` and `APP_SPEND_ALERT_FROM` | Each channel fails on its own and is logged; an alert is recorded before any of them is tried, so a dead endpoint never loses it. Payload shape: `{"type":"spend.alert","accountId":"…","alert":{…the alert as GET /alerts returns it…}}` or `{"type":"spend.digest","accountId":"…", "digest":{…as GET /digest…}}`. `POST /digest/send` answers with a `delivery` block — `slack` / `webhook` / `email` each `SENT`, `FAILED` or `NOT_CONFIGURED` — and the console toast says the same, so a dead email leg is never reported as sent. The Slack field only accepts `hooks.slack.com` URLs; anything else goes in the webhook field. Verify a signature in Node: ```js theme={null} const sig = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); ``` ### Projected overspend Once a month, when at least a fifth of the month has passed and the unit is still under its ceiling, Tanso projects month-to-date spend straight-line (`spent × month length / elapsed`) and fires **Projected** if that lands above the ceiling — "Backend is on pace for $214 this month against a $200 budget." One heavy day early in the month would project to anything, hence the wait. The pace is `spent × (seconds in month ÷ seconds elapsed)` at the moment of the check. ### Temporary bump A launch week should not mean editing the real budget. On the unit's budget card, **Temporary bump** takes an amount above the standing ceiling, an end date and a reason. Until then the bump is the ceiling — for alerts, for the digest and for the gateway push; after, the standing number is back and, for a Block budget, re-pushed to LiteLLM. **End now** drops it early. ### Weekly digest Weekly digest card: last week per unit against the week before Switch it on under Settings; it goes out Monday 08:00 UTC to every configured channel: the last seven full UTC days (yesterday back, today excluded) per unit against the seven before, month-to-date against the ceiling in force (with any bump's reason), unattributed spend and how many alerts fired. **Internal spend → Alerts** previews it and has **Send now**. ### Gateway mode: enforce at LiteLLM Budget card: enforced at litellm:team:backend, bumped, LiteLLM's own count beside Tanso's If your traffic already goes through a [LiteLLM proxy](https://docs.litellm.ai/docs/proxy/users), Tanso can make Block real: 1. **Internal spend → Connections → Connect vendor**, provider *LiteLLM*: the proxy URL and its master key. *Check key* calls `/health/liveliness`; *Sync now* pulls `/spend/logs`. 2. On the unit, add a rule with provider *LiteLLM*: *Workspace* = the LiteLLM `team_id`, *API key* = the key, or *Actor* = the internal `user_id`. 3. Set the monthly ceiling and mode **Block**, save. Tanso then calls `/team/update`, `/key/update` or `/user/update` with `max_budget` (the ceiling in dollars) and `budget_duration: "1mo"`. LiteLLM refuses requests once its own spend on that object passes the ceiling and resets it monthly. The budget card shows *Enforced at litellm:team:backend* and the breach alert says the same; if the push failed it shows *Not enforced:* with LiteLLM's error, and the budget still saves. Switching the mode back to Alert — or deleting the budget — pushes `max_budget: null` so no stale limit stays behind. Only the monthly ceiling is pushed; LiteLLM keeps one duration per object, so the daily ceiling stays an alert. A bump pushes the bumped ceiling and its expiry pushes the standing one back; a push that fails still saves the budget and shows *Not enforced* with the proxy's error. **Two clocks.** Tanso measures a budget on its own price book; LiteLLM enforces `max_budget` against spend it priced from its own model map. They drift when the maps differ, and the gateway may block well before or after Tanso's alert. The budget card therefore also shows *LiteLLM itself counts \$…* — the proxy's month-to-date for the team/key/user the unit's rules name (`gatewaySpentCents` on the API). If the two disagree by more than the odd percent, fix the price book (Settings → Model pricing) or the proxy's `model_cost_map`. ```bash theme={null} PUT /api/v1/spend/units/{id}/budget {"dailyCents":2500,"monthlyCents":50000,"alertThreshold":80,"monthlyMode":"ALERT"} POST /api/v1/spend/budgets/evaluate # returns what fired this time GET /api/v1/spend/alerts?unackedOnly=true POST /api/v1/spend/alerts/{id}/ack ``` ## 6. Outcomes: cost per shipped thing Outcomes report: cost per outcome per unit, sources, recent outcomes **Internal spend → Outcomes** puts shipped work next to what it cost. An outcome is a merged pull request, a completed issue, or whatever you say it is. **Pulled.** Connect GitHub (a fine-grained token with read access to pull requests; scope is a comma-separated `owner/repo` list such as `acme/app, acme/site`) or Linear (an API key; scope is comma-separated team keys such as `BE, FE`, or `*` for every team). The token is checked when you connect, so a bad one shows `ERROR` immediately. Sync pulls merged PRs / completed issues for the window and re-pulls upsert; an hourly job covers the last three days. Disconnecting a source removes the outcomes it pulled; posted ones stay. A person's GitHub login goes on their PERSON unit under Teams so merged PRs attribute to them. **Posted.** Any CI job or script can record one with the tenant API key — the same `sk_` key that ingests events. Customer `ck_` keys are refused: this is your spend, not a customer's. ```bash theme={null} curl -X POST https://YOUR-INSTANCE/api/v1/client/outcomes \ -H "X-API-Key: $TANSO_API_KEY" -H 'Content-Type: application/json' \ -d '{"kind":"CUSTOM","externalId":"deploy-2026-08-25-1","title":"Prod deploy","url":"https://ci/…","actorEmail":"alice@acme.com"}' ``` Fields: `kind` (`PR_MERGED`, `ISSUE_DONE`, `CUSTOM`) and `externalId` are required; `title`, `url`, `actorEmail`, `actorLogin`, `spendUnitId` and `occurredAt` (default now) are optional. Posting the same `externalId` again updates only the fields you send. The console route `POST /api/v1/spend/outcomes` takes the same body with a JWT. **Attribution.** An outcome lands on the person whose email or GitHub login matches (person level on), else on the source's default unit, else on the unit you passed. Outcomes with no unit are counted and called out. **AI-assisted.** A merged pull request is tagged AI-assisted, with the tool, when GitHub already says so: a `claude-code-assisted`, `copilot` or `cursor` label, a `Co-authored-by: Claude` / `Made-with: Cursor` trailer in the body, or a bot author. Posted outcomes can say so with `aiAssisted` and `aiTool`. Absence is not evidence — an untagged PR is unknown, not human. The report counts AI-assisted outcomes per unit. **Report.** Per unit, metered spend (with descendants) over outcomes (with descendants): cost per merged PR, per team, per window. A person's Claude Code estimate is shown beside the metered figure, never inside it, so every row divides the same kind of number. Cost per outcome is empty when a unit shipped nothing or has no metered spend to divide. **Recent** lists the last 200 outcomes regardless of window. ```bash theme={null} GET /api/v1/spend/reports/outcomes?from=&to= ``` ## 7. Savings: what caching is worth, what a route would cost **Internal spend → Usage → Savings.** Per model, the input side of the bill as it was billed — uncached tokens at the input rate, cache reads and writes at theirs — against the same tokens with no cache. The difference is what prompt caching saved; it goes negative on a model that wrote more to the cache than it read back. Cache-read share is reads over all input tokens. A model with no cache rates in the price book is priced at its input rate for cached tokens, so its saving reads as zero and the row says *no cache rates* — add `cache_read_cost_per_million` / `cache_write_cost_per_million` under model pricing. Savings by model and a route simulation result ### Route simulator "What if the traffic on claude-opus-4-1 had gone to claude-sonnet-4-5?" Pick the model whose traffic to re-price, a target from the price book, and optionally one vendor workspace / project / team id. Tanso sums the matched tokens and prices them at both models' rates: ```http theme={null} POST /api/v1/spend/reports/simulate {"from":"2026-08-01","to":"2026-08-26","fromModel":"claude-opus-4-1","toModel":"claude-sonnet-4-5","workspaceId":"wrkspc_backend_01"} ``` The answer carries `currentCents`, `simulatedCents`, `deltaCents` and a list of `caveats` that is never empty: token counts are carried over as-is (a different tokenizer would change them), quality and latency are not modelled, and it says when the target has no cache rates or a model was matched fuzzily. It is advice from the price book. Tanso does not route requests and will not — that was decided before internal spend was started. ## 8. Feature P\&L: what it cost to build next to what it earns Feature P&L: build, outcomes, revenue, serving cost, net per project This is the join the two halves of the engine exist for. Internal spend knows what a project cost in AI spend; monetization knows what a feature earns from customers and what serving it costs. Link them and one report shows both. 1. **Internal spend → Teams**, open (or create) a *project* unit and pick the feature it shipped under **Feature** — the same features monetization prices in plans. 2. **Internal spend → Feature P\&L** (`GET /api/v1/spend/reports/pnl?from&to`): per project, its attributed AI spend with descendants (*build*), what it shipped (outcomes), and from the feature's customer events in the same window: revenue, serving cost, serve margin, and **net** = serve margin − build. Build cost per outcome is there too. Projects without a feature are listed under *unlinked* rather than hidden, and their build cost is not in the totals — a P\&L with half the ledger is worse than none. Revenue and serving cost come from `revenueAmount` / `costAmount` on `CLIENT_TRACKED` and `ENTITLEMENT_CHECKED` events, so a feature that is entitled but never metered reads as zero revenue, not as missing. ## Outbound URLs Two things the operator types are URLs the server will call: the LiteLLM proxy and the generic webhook. Both are checked when saved: `http(s)` only, no credentials in the URL, the host must resolve, and loopback, link-local (where cloud metadata services live) and unspecified addresses are refused. Private ranges are allowed by default — a self-hosted proxy is exactly what sits there; on a multi-tenant install set `APP_SPEND_OUTBOUND_ALLOW_PRIVATE=false`. Errors from the proxy are surfaced as its JSON `error.message` / `detail` only, never a raw response body. ## Settings and operations | Setting | Where | Notes | | ------------------------------------------------------------------------------------ | ---------------------------- | ------------------------------------------------------------------------- | | `APP_SECRETS_KEY` | env | Required. Encrypts stored credentials. See [self-hosting](/self-hosting). | | `APP_MODULES_BUILD_ENABLED` | env | `false` for a serve-side-only install. | | `APP_SPEND_ANTHROPIC_BASE_URL` etc. (`_OPENAI_`, `_CURSOR_`, `_GITHUB_`, `_LINEAR_`) | env | Point a pull at a gateway or proxy instead of the vendor. | | Person level, worker notice, Slack webhook | `PUT /api/v1/spend/settings` | Console: Internal spend → Teams → Spend settings. | Jobs (`application.yaml` → `jobs:`): `vendorUsageSync` hourly at :15, `spendBudget` at :30, `outcomeSync` at :45. All ShedLock-guarded. ## Limitations * Blocking is advisory unless a LiteLLM connection and rule exist; daily ceilings are never pushed. * Email needs a Resend key on the server; there is no SMTP path. * P\&L revenue is event revenue, not invoiced revenue — flat plan fees that are not attributed to a feature do not appear on it. * OpenAI's `group_by` parameter format was verified against OpenAI's cookbook, not a live admin key — check per-model rows against your dashboard once. * Employee self-view and a manager cohort minimum are not built; person-level data is visible to every console user once enabled. * Jira is not pulled; post outcomes from CI in the meantime. * Cursor's admin API is Enterprise-only; Teams plans cannot connect it. # Introduction Source: https://tanso.mintlify.app/introduction What Tanso is and who it's for **Tanso** is an open-source cost and pricing engine for AI products, on one ledger fed from two directions: what you charge the AI you sell (**Monetization**), and what you spend on the AI you buy (**Internal spend**). See [Concepts](/concepts) for the two-halves model, or keep reading for the Monetization side in detail. Every metered event carries its cost: input/output tokens, model, provider, and what that usage cost you — alongside what you billed for it. Billing tools meter usage but don't know your costs; observability tools know your costs but don't bill. Tanso does both in one ledger, so you can see margin per customer, per feature, per model. The same ledger enforces in real time: entitlement checks, usage caps, and credit limits are applied when the event is ingested, not reconciled at invoice time. Billing state lives in Tanso — Stripe is the payment adapter, not the source of truth. ## The model You define your pricing once, and everything else derives from it: | Object | What it is | | ---------------- | ------------------------------------------------------------------------------------------------------ | | **Feature** | A single capability you sell — `api-calls`, `seats`, `export` | | **Plan** | A bundle of features with a base price and billing interval | | **Rule** | How a feature behaves in a plan: flat, usage-based, or graduated pricing, plus optional credit backing | | **Customer** | Your customer, keyed by *your* ID (`customerReferenceId`) — no ID sync | | **Subscription** | A customer on a plan, with period tracking and proration | | **Event** | One unit of usage, idempotent, carrying units, cost, and revenue | | **Entitlement** | The materialized answer to "can this customer use this feature right now?" | | **Credit pool** | Prepaid balance with grants, FIFO draw-down, expiry, and an append-only transaction ledger | ## The integration loop Three calls from your backend: 1. **Check** an entitlement before serving a request. 2. **Serve** the request. 3. **Report** a usage event after. Tanso enforces limits at ingestion, rolls usage into invoices at cycle close, syncs Stripe when connected, and computes margin from the costs you stamp on events. For the operator side, the repo ships an admin console (`ui/`): manage the catalog, customers, subscriptions, credits, and invoices, and see margin per customer, per feature, per model. ## The other half: your own AI bill The same engine has a second half, **Internal spend**. Connect your Anthropic and OpenAI admin keys and Tanso pulls the usage and cost reports the vendors already keep, reconciles them against the invoice, allocates spend to teams and people, budgets it with daily and monthly ceilings, and puts it next to shipped work — cost per merged pull request. No proxy, no desktop agent. See [Internal AI spend](/internal-spend). Either half runs on its own. Internal spend: internal AI usage by model, person and day ## Next steps * [Concepts](/concepts) — the two-halves mental model, in one diagram * [Quickstart](/quickstart) — running with seeded data in about five minutes, console included * [Billing lifecycle](/billing-lifecycle) — from empty database to first revenue, every call spelled out * [Internal AI spend](/internal-spend) — your own vendor bills, allocated, budgeted, and joined to outcomes * [Self-hosting](/self-hosting) — configuration and production notes # MCP server Source: https://tanso.mintlify.app/mcp Operate your billing with an AI agent — same auth, same scoping, no separate path Tanso ships a built-in [MCP](https://modelcontextprotocol.io) server so AI agents can operate your instance directly. It authenticates with the same API keys as everything else and is scoped to the key's account — an agent gets no separate, weaker path. It's **off by default**. Enable it with two flags and restart: ```bash theme={null} APP_MCP_ENABLED=true SPRING_AI_MCP_SERVER_ENABLED=true ``` The tenant-configuration tools (`Admin*` — plans, rules, credit tariffs and prices — plus Stripe setup) require a third, separate opt-in: ```bash theme={null} APP_MCP_ADMIN_TOOLS_ENABLED=true ``` Leave it off unless every holder of a client API key is the operator. This gate is what lets you hand an MCP endpoint to an end customer's agent: with it off, a customer-scoped (`ck_`) key reaches only the curated customer tools — `listPlans`, `getCreditPrices`, `checkEntitlement`, `getUsageForecast`, `subscribePlan`, `purchaseCredits` (spend tools require `confirmAction: true`) — pinned to its own customer. See [Agent-serve](/agent-serve). Then connect any MCP client to `/mcp`: ```bash theme={null} claude mcp add tanso --transport http \ --header "X-API-Key: sk_test_your_key" \ http://localhost:8080/mcp ``` ## What you'd actually use it for **Billing ops from a chat window.** Connect Claude Desktop to your instance and ask questions that would otherwise mean writing queries: *"Which customers are near their usage caps?"* — *"Show cust\_demo\_1's credit transactions this month"* — *"Mark invoice X paid."* No code, no dashboard tab. **Customer support with receipts.** The credit ledger records `balance_before`/`balance_after` on every transaction, so an agent answering "where did my credits go?" can cite the exact entries. **Catalog work by conversation.** The admin tools cover features, plans, pricing rules, and credit models — an agent can draft a new plan's rule configuration and you review it in the dashboard. ## Guardrails Tools that spend money or make hard-to-reverse changes require an explicit `confirmAction: true` argument — the tool refuses with an explanation until the agent passes it. Generating AI insights (costs model tokens), Stripe setup, subscription cancellation, and credit grants/deductions are gated this way. Destructive tools also say so in their descriptions (`DESTRUCTIVE:`, `SIDE EFFECT:`) so agents can reason about them before calling. ## Known rough edge `ingestEvent` currently requires `occurredAt` (ISO-8601), unlike the REST API, which defaults it to now — and omitting it yields an unhelpful error (`{"error": "ingestion_failed", "message": "text"}`). Until that's fixed, always pass `occurredAt` explicitly when ingesting events over MCP. ## The design stance An agent operating your billing uses the same authenticated, account-scoped surface as your code — 62 tools mirroring the client and admin APIs. Nothing is agent-only, and nothing is weaker because an agent is calling it. If you wouldn't expose an operation to a human with that API key, it isn't exposed to an agent either. # Metering & entitlements Source: https://tanso.mintlify.app/metering-and-entitlements Idempotent events in, real-time allow/deny out ## Events An event is one unit of usage. The minimal shape: ```json theme={null} { "eventName": "api_call", "featureKey": "api-calls", "customerReferenceId": "cust_demo_1", "eventIdempotencyKey": "evt-0001", "usageUnits": 1 } ``` * **Idempotency is enforced, not advisory.** A repeated `eventIdempotencyKey` returns `409 Conflict`. Generate one unique key per logical event (a UUID is fine) and you can retry sends safely. * **Customers resolve by your ID.** `customerReferenceId` is the same ID you used when creating the customer. * **Cost fields are optional but are the margin story.** Send `costAmount` at the top level. Put model-aware fields inside `costInput`: `model`, `modelProvider`, `inputTokens`, and `outputTokens`. The older `costInput.costUnits` field is deprecated. Stamp what the usage cost you, and per-customer margin falls out of the same ledger that bills. * **Revenue is computed at ingestion** from the plan's pricing rule and stored on the event, not reconstructed at invoice time. * **Credit-backed features return a deduction receipt.** When the feature is backed by a credit model, the ingestion response includes `creditsDeducted`, `weightApplied`, `weightId`, `weightMatch`, and `remainingBalance` — see [Credit weights](/credit-weights). The fields are absent otherwise. For AI usage, the cost portion of an event looks like this: ```json theme={null} { "usageUnits": 3500, "costAmount": 0.0125, "costInput": { "model": "gpt-4o-mini", "modelProvider": "openai", "inputTokens": 3000, "outputTokens": 500 } } ``` ## Entitlements ```bash theme={null} curl http://localhost:8080/api/v1/client/entitlements/{customerReferenceId}/{featureKey} \ -H "X-API-Key: $KEY" ``` The response is a direct allow/deny with context: ```json theme={null} {"data":{"referenceCustomerId":"cust_demo_1","featureKey":"api-calls", "usage":{"used":8},"allowed":true},"success":true} ``` When denied, `meta.reason` says why (no subscription, entitlement revoked, limit reached). Checks **fail closed**: no subscription, unpaid first invoice, or missing configuration all mean `allowed: false`. The intended pattern in your request path: ``` check entitlement → serve if allowed → report event ``` ### Quoting the credit cost of a proposed action `POST /entitlements` (evaluate) accepts `usage.model` and returns a `creditQuote` — the weight that would apply and the credits the proposed usage would burn. The `model` string must exactly match the `costInput.model` you'll send on the event. The quote resolves at request time; the charge resolves at the event's `occurredAt` — a quote, not a promise. Details in [Credit weights](/credit-weights). ### Correlating a check to the event it authorized `POST /entitlements` (the evaluate variant, for simulating proposed usage) accepts an optional `context.flowId`. Pass one, or omit it and Tanso generates one and returns it in the response: ```json theme={null} { "customerReferenceId": "cust_demo_1", "featureKey": "llm.generate", "context": { "flowId": "flow_chat_turn_42" } } ``` Reuse that same value on the `POST /events` call that reports the resulting usage, and the event carries it too. `flowId` is a plain, indexed string column on `events` — not a foreign key, just a shared value you control (or let Tanso mint) so you can later query "what usage came out of this check." It's optional, and there's no automatic join between a check and an event without it. The plain `GET /entitlements/{customerReferenceId}/{featureKey}` check doesn't take a `flowId` — only the evaluate/simulate endpoint does. ## Credit hard limits Credit deduction and depletion detection happen synchronously during event ingestion. When linked hard-limit pools cannot cover the event, the Client API returns `409 Conflict` with `success: false` and a `Credit pool depleted` error. The event is not stored and no credits are deducted. An entitlement check before serving is still the preferred request pattern. The ingestion rejection protects against stale checks and direct event sends. See [Credits](/credits) for the deduction model. # Quickstart Source: https://tanso.mintlify.app/quickstart Run Tanso and prove a five-credit hard limit end to end Docker is the only prerequisite for the API. The runnable Next.js example also requires Node.js 20.9 or newer. ## 1. Start the stack ```bash theme={null} git clone https://github.com/tansohq/tanso-oss.git cd tanso-oss/deploy cp .env.example .env # set JWT_SECRET and APP_SECRETS_KEY, e.g. openssl rand -base64 48 each docker compose up -d --build ./setup.sh ``` `setup.sh` waits for the API to become healthy, seeds a test account, and creates a `demo-user` with five `AI_CREDITS`: ``` Login: test / password API key: sk_test_828df0fc77874c219f353417fbca1ef4 API: http://localhost:8080 Docs: http://localhost:8080/swagger-ui.html Demo: demo-user has 5 AI_CREDITS for feature ai.chat ``` These are the shared dev-quickstart credentials from `scripts/create-test-account.sql`. Change them before exposing the instance to anything real. ## 2. Run the working integration From the repository root, in a second terminal: ```bash theme={null} npm install cp examples/nextjs-ai-credits/.env.example \ examples/nextjs-ai-credits/.env.local npm run dev --workspace @tansohq/nextjs-ai-credits-example ``` Open [http://localhost:3000](http://localhost:3000). Run the request five times. Each successful call: 1. checks the entitlement before doing billable work; 2. runs a provider-free model stub; 3. records usage, cost, and revenue; 4. atomically deducts one credit. The sixth call returns `402` from the example route before the provider stub runs. Event ingestion remains the final enforcement boundary and returns `409 Conflict` if a caller races or skips the preflight check. Read [`app/api/generate/route.ts`](https://github.com/tansohq/tanso-oss/blob/main/examples/nextjs-ai-credits/app/api/generate/route.ts) for the complete check → work → record flow. ## 3. Open the admin console The repo also ships a web console for the operator side of the same instance. From the repository root: ```bash theme={null} npm run dev:ui ``` Sign in with the seeded `test` / `password` login. The console prints its URL — [http://localhost:3000](http://localhost:3000), or the next free port if the example app is still holding 3000. You'll see the seeded catalog (the `developer_demo` plan and its `ai.chat` rule), the demo customer with its credit pool and transaction ledger, and every event the example just recorded — cost and revenue on each row. The console talks to the API through a same-origin proxy, so no CORS configuration is needed. Details in [`ui/README.md`](https://github.com/tansohq/tanso-oss/blob/main/ui/README.md). ## 4. Verify the Client API Client API calls authenticate with the API key, via either header: ```bash theme={null} curl "http://localhost:8080/api/v1/client/entitlements/demo-user/ai.chat?record=false" \ -H "X-API-Key: sk_test_828df0fc77874c219f353417fbca1ef4" ``` ```json theme={null} { "data": { "referenceCustomerId": "demo-user", "featureKey": "ai.chat", "credit": { "denomination": "AI_CREDITS", "balance": 5, "hardLimit": true }, "allowed": true }, "success": true } ``` Without the key, the same call returns `401`. Re-run `./setup.sh` to reset only the fixed demo customer's five credits. The [billing lifecycle guide](/billing-lifecycle) shows how to create your own catalog and customer. ## There is no signup endpoint This is deliberate. A self-hosted billing engine's operator **is** the tenant; a public signup endpoint on your billing system would be pure attack surface. You bootstrap your account with `setup.sh` (or `scripts/create-test-account.sql` directly) and log in from there. # TypeScript SDK Source: https://tanso.mintlify.app/sdk Use @tansohq/sdk with your self-hosted Tanso instance The official [`@tansohq/sdk`](https://www.npmjs.com/package/@tansohq/sdk) package is typed, dependency-free, and supports Node.js 18 or newer. ```bash theme={null} npm install @tansohq/sdk ``` ```typescript theme={null} import { TansoClient } from "@tansohq/sdk"; const tanso = new TansoClient(process.env.TANSO_API_KEY!, { baseUrl: process.env.TANSO_BASE_URL ?? "http://localhost:8080", }); ``` Create the client only in server-side code. A Tanso API key authenticates the whole account and must never enter a browser bundle. Tanso is self-hosted, so `baseUrl` points at your own instance. From SDK 0.2.2 the default is `http://localhost:8080` — the quickstart compose stack — so local development works with no configuration. ## The integration loop ```typescript theme={null} const requestId = crypto.randomUUID(); // 1. Check before incurring provider cost. const decision = await tanso.entitlements.evaluate({ customerReferenceId: "demo-user", featureKey: "ai.chat", usage: { eventName: "ai.chat.generate", usageUnits: 1, model: "gpt-4.1-mini", // selects the credit weight row (sdk >= 0.3.0) }, context: { idempotencyKey: `check-${requestId}`, flowId: requestId, }, }); if (!decision.allowed) { return deny(decision.meta?.reason?.description); } // decision.creditQuote holds what this would burn — a quote, not a promise; // the charge resolves at the event's occurredAt. See /credit-weights. // 2. Perform the billable work. const result = await runModel(); // 3. Record usage, unit economics, and one credit deduction. await tanso.events.ingest({ customerReferenceId: "demo-user", featureKey: "ai.chat", eventName: "ai.chat.completed", eventIdempotencyKey: `usage-${requestId}`, flowId: requestId, usageUnits: 1, costAmount: result.providerCost, revenueAmount: 0.02, costInput: { model: "gpt-4.1-mini", modelProvider: "openai", inputTokens: result.inputTokens, // requires @tansohq/sdk >= 0.2.1 outputTokens: result.outputTokens, }, }); ``` ## Surface | Resource | Methods | | --------------- | ------------------------------------------------------------------------------- | | `customers` | `create`, `get`, `update` | | `subscriptions` | `create`, `cancel`, `revertCancellation`, `changePlan`, `cancelScheduledChange` | | `entitlements` | `check`, `evaluate`, `list` | | `events` | `ingest` | | `plans` | `list` | | `features` | `list`, `get` | | `credits` | `listPools`, `getPool`, `listGrants`, `listTransactions` | | `billing` | `listInvoices`, `markPaid`, `createCheckoutSession` | ## Errors are typed ```typescript theme={null} import { TansoApiError, TansoAuthenticationError, TansoConflictError, TansoNetworkError, TansoNotFoundError, } from "@tansohq/sdk"; try { await tanso.events.ingest(event); } catch (error) { if (error instanceof TansoConflictError) { // A duplicate eventIdempotencyKey or depleted hard-limit pool. } else if (error instanceof TansoApiError) { console.error(error.statusCode, error.detail); } } ``` `TansoApiError` exposes `statusCode` and optional `detail`. Authentication, not-found, conflict, and network failures have their own subclasses. The [included Next.js example](https://github.com/tansohq/tanso-oss/tree/main/examples/nextjs-ai-credits) uses the published package and is the canonical integration. List responses return `{ items, pagination }`. The client unwraps Tanso's top-level `{ success, data, error }` envelope and throws on non-success responses. # Self-hosting Source: https://tanso.mintlify.app/self-hosting Configuration, environments, and production notes The [quickstart](/quickstart) compose stack is the fastest path. This page covers what you'll want to know past the first run. ## Environment variables | Variable | Required | Notes | | --------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `JWT_SECRET` | **Yes** | The app refuses to start without it — no insecure default ships. Generate with `openssl rand -base64 48`. | | `APP_SECRETS_KEY` | **Yes** | Encrypts stored credentials at rest (Stripe keys, vendor admin keys, Slack webhook, GitHub/Linear tokens) with AES-256-GCM. The app refuses to start without it, and refuses a key that cannot read what is already stored. To rotate: `DELETE FROM external_api_keys`, `UPDATE vendor_connections SET admin_key=''`, then reconnect. | | `APP_MODULES_MONETIZATION_ENABLED` | No | `false` turns monetization off: plans, customers, credits, invoices, the client API, Stripe and the billing jobs. Their routes return `404` with `"code": "module_disabled"` and the console opens on Internal spend. Use it for an internal-spend-only install. Default `true`. | | `APP_MODULES_BUILD_ENABLED` | No | `false` turns [internal spend](/internal-spend) off: `/api/v1/spend/**` returns `404` and the console hides it. Default `true`. | | `APP_SPEND_ANTHROPIC_BASE_URL` / `_OPENAI_` / `_GITHUB_` / `_LINEAR_` | No | Where internal spend pulls from. Set to a gateway or proxy. | | `SPRING_DATASOURCE_URL` / `_USERNAME` / `_PASSWORD` | Yes | PostgreSQL connection. | | `STRIPE_API_KEY` / `STRIPE_WEBHOOK_SECRET` | No | Leave empty to run without Stripe — invoices settle via `mark-paid`. | | `APP_MCP_ENABLED` + `SPRING_AI_MCP_SERVER_ENABLED` | No | Both `true` to expose [`/mcp`](/mcp). Off by default. | | `SPRING_PROFILES_ACTIVE` | No | Profile config lives in `application-*.yaml`. | ## Stripe: optional, and an adapter Fresh accounts run without Stripe. Without it, the invoice lifecycle is fully functional — invoices generate at subscribe and cycle close, and you settle them via `POST /api/v1/client/billing/invoices/{id}/mark-paid`. Connecting a Stripe key (via the dashboard or the MCP Stripe setup tools) switches the account to Stripe-driven billing: checkout sessions, webhook sync, and meter forwarding. Billing state stays in Tanso either way — Stripe is the payment adapter, not the source of truth. ## Database & migrations Liquibase applies the full schema on startup — an empty PostgreSQL is all you need. Schema changes ship as new changelog files only; existing changelogs are never edited, so upgrades are a restart. ## Scheduled jobs Invoice generation, subscription cycle rollover, cancellation processing, and credit expiration run as in-process scheduled jobs (ShedLock-guarded, so multiple instances won't double-fire). Default crons live in `application.yaml` under `jobs:`. Internal spend adds three hourly jobs: `vendorUsageSync` (:15, re-pulls the last three days from each connected vendor), `spendBudget` (:30, checks every budget), and `outcomeSync` (:45, re-pulls merged PRs and completed issues). ## Admin console `ui/` in the repo is a Next.js app covering the JWT admin surface — catalog, customers, subscriptions, credits, invoices, events, and margin analytics. `npm run dev:ui` runs it in development; for a production build, `npm run build --workspace @tansohq/ui` then `npm run start --workspace @tansohq/ui`. One variable matters: `TANSO_BASE_URL`, the API address the Next.js server proxies to (default `http://localhost:8080`). API calls go through that same-origin proxy, so the API's CORS configuration needs no changes for the console. ## Production checklist * Change the seeded test account credentials and API key — they are public knowledge (they're in this documentation). * Set a strong, private `JWT_SECRET`; rotating it invalidates sessions. * Set `APP_SECRETS_KEY` and back it up outside the database — losing it means re-entering every stored credential. * The compose file ships Spring MVC and Hibernate logging at `INFO` and `show_sql` off. Do not raise them in production: at `DEBUG`, request bodies — including keys you paste into the console — are written to the logs. * Put the API behind TLS; API keys are bearer credentials. * `docker compose` in `deploy/` is a starting point, not an HA architecture — the app is a standard Spring Boot container and runs anywhere containers run. Health probes are at `/actuator/health/{liveness,readiness}`. * Watch the logs for the first cycle close — billing bugs are cheapest the day you can still read the whole ledger by hand.