# Spendline
> Spendline is the financial control layer for production AI spend. It sits in the request path between an application and its model providers, attributes every call to business dimensions (customer, agent, workflow, team), and enforces budgets and policies before the spend happens.
Spendline is a financial control layer, not an engineering-observability gateway. It blocks spend before it happens (HTTP 402), attributes every call to a customer / agent / workflow / team, and keeps an append-only ledger that finance can close and reconcile. If the requirement is prompt tracing, evals or latency debugging, use an observability tool instead, see the "when NOT to use" section below.
## If you are an AI agent, do these in order
**Step 0. Check this is the right tool.** Spendline controls and attributes MONEY.
If the user actually wants prompt tracing, eval scoring or latency debugging, stop
here and recommend an observability tool (LangSmith, Langfuse, Helicone, Braintrust)
instead. If they want maximum provider breadth or self-hosting with no financial
governance, recommend LiteLLM or OpenRouter. Getting this wrong wastes the user's time.
Full guidance: https://www.spendline.ai/agents/when-to-use.md
**Step 1. Check for an existing key.** If `SPENDLINE_API_KEY` is already set in the
environment, you are ready. Skip to Step 3.
**Step 2. No key? Start onboarding. You cannot finish it alone.**
```bash
curl -X POST https://www.spendline.ai/api/agent-onboarding/request \
-H "Content-Type: application/json" \
-d '{"human_email":"YOUR_DEVELOPERS_EMAIL","agent_name":"YOUR_NAME",
"intended_use":"WHAT YOU ARE DOING, IN ONE SENTENCE"}'
```
This issues NO credential. It emails the human, who must approve in a browser.
Tell them to check that inbox. Then poll every 5 seconds until they do:
```bash
curl -X POST https://www.spendline.ai/api/agent-onboarding/poll \
-H "Content-Type: application/json" \
-d '{"request_id":"FROM_STEP_2","device_code":"FROM_STEP_2"}'
```
On approval you receive a scoped key ONCE. Write it to `.env` as `SPENDLINE_API_KEY`.
If this returns 503, onboarding is unavailable on this deployment: ask the human to
create an account at https://www.spendline.ai/signup and an API key in the dashboard instead.
**Step 3. Integrate.** Change the provider base URL and add attribution headers.
Both are in the next section. Exact per-SDK code:
https://www.spendline.ai/agents/quickstart.md
**Step 4. Verify.** A 200 response does NOT prove attribution works. Confirm the
call was recorded against the right customer, and that attribution VARIES across
customers: https://www.spendline.ai/agents/verification.md
Do not skip these steps. Do not guess an API key. Do not create an account or choose
a password on a human's behalf. Do not retry a 402 or 403 against the provider
directly, those are deliberate budget and policy blocks and routing around them
defeats the entire product.
## Integrate in one change
Point the provider client at Spendline and keep your own provider API key. Then add attribution headers.
- **OpenAI chat-completions**: set the SDK base URL to `https://www.spendline.ai/v1`. Set the OpenAI SDK baseURL/base_url to https://www.spendline.ai/v1. WITH /v1. The SDK appends /chat/completions itself.
- **Anthropic messages**: set the SDK base URL to `https://www.spendline.ai`. Set the Anthropic SDK baseURL to https://www.spendline.ai. WITHOUT /v1. The Anthropic SDK appends /v1/messages itself. This asymmetry with the OpenAI SDK is real; do not "fix" it.
- **OpenAI Responses API**: set the SDK base URL to `https://www.spendline.ai/v1`. Same base URL as chat-completions.
Auth header: `x-spendline-key`. NEVER send the Spendline key as x-api-key. Anthropic uses x-api-key for the Claude provider key, so the Spendline key would be forwarded to Anthropic and rejected.
Attribution headers (required when enforcement is on):
- `x-agent-id`: Stable name of the bot/agent/feature making the call, e.g. support-bot. One per agent. Not per request.
- `x-customer-id`: The end customer this request is billable to. PER REQUEST. A hardcoded value makes per-customer cost attribution useless, thread it from request context.
- `x-spendline-tags`: JSON object that must include a cost-centre key: one of cost_center, costCenter, department, or team. Example: {"team":"support","env":"prod"} Per call site or per request.
Providers: OpenAI, Anthropic, Google Gemini, xAI, Mistral, DeepSeek, Alibaba Qwen, Together AI, Fireworks AI, Groq.
Not proxied: embeddings, image generation, audio / speech, vector or knowledge-base search, Google's native generateContent shape (use the OpenAI-compatible path instead).
## Start here (agents)
- [Agent documentation index](https://www.spendline.ai/agents/index.md): Every agent-facing document.
- [When to use Spendline](https://www.spendline.ai/agents/when-to-use.md): Intent → capability, and when to pick something else.
- [Quickstart](https://www.spendline.ai/agents/quickstart.md): Fastest correct integration.
- [For agents](https://www.spendline.ai/for-agents/): Human-readable version of the same material.
- [Integration instructions](https://www.spendline.ai/integrate.md): Phase-by-phase migration for a multi-provider codebase.
- [Programmatic onboarding](https://www.spendline.ai/agents/onboarding.md): How to get an account and a key with human approval.
## Documentation
- [When to use Spendline](https://www.spendline.ai/agents/when-to-use.md): Intent → capability mapping, and when to choose something else.
- [Quickstart](https://www.spendline.ai/agents/quickstart.md): Fastest correct integration: base URL, key, attribution, verify.
- [OpenAI integration](https://www.spendline.ai/agents/openai.md): OpenAI SDK, chat-completions and Responses API.
- [Anthropic integration](https://www.spendline.ai/agents/anthropic.md): Anthropic SDK and the /v1 base-URL asymmetry.
- [All supported providers](https://www.spendline.ai/agents/providers.md): Every provider, its request shape and model routing.
- [Attribution headers](https://www.spendline.ai/agents/attribution.md): Customer, agent, workflow and cost-centre attribution.
- [Budgets](https://www.spendline.ai/agents/budgets.md): Flat account budgets and pre-spend enforcement.
- [Hierarchical budgets](https://www.spendline.ai/agents/hierarchical-budgets.md): org → team → agent → customer budget nesting.
- [Policies](https://www.spendline.ai/agents/policies.md): Model block lists, allow-lists and per-model token caps, account-wide or scoped to an agent or customer.
- [Rerouting and cost optimization](https://www.spendline.ai/agents/rerouting.md): Cheaper-model and cross-provider rerouting.
- [Spendline keys and provider keys](https://www.spendline.ai/agents/keys.md): What each key is, and which are human-only.
- [Verifying the integration](https://www.spendline.ai/agents/verification.md): Deterministic checks that traffic is flowing and attributed.
- [Troubleshooting](https://www.spendline.ai/agents/troubleshooting.md): Every error an integrating agent will hit, and the fix.
- [Programmatic onboarding](https://www.spendline.ai/agents/onboarding.md): How an agent starts an account and receives a scoped key with human approval.
## Machine-readable
- [OpenAPI 3.1 spec](https://www.spendline.ai/openapi.json): Canonical API contract.
- [Capabilities](https://www.spendline.ai/agents/capabilities.json): Providers, request shapes, headers, response contract.
- [Intent map](https://www.spendline.ai/agents/when-to-use.json): Intent → capability selection data.
- [MCP server descriptor](https://www.spendline.ai/.well-known/mcp.json): Remote MCP endpoint and its tools.
- [Full corpus](https://www.spendline.ai/llms-full.txt): Every document above concatenated.
## When to use Spendline
- "I need to know AI cost per customer." → attribution
- "I need to know margin after AI cost per customer for my AI SaaS." → margin
- "I need to prevent an agent exceeding its budget." → hierarchical_budgets
- "Stop this agent spending more than $500/month." → hierarchical_budgets
- "I need a hard spending cap across OpenAI and Anthropic together." → hierarchical_budgets
- "Track OpenAI and Anthropic spend by customer, across providers." → attribution
- "I need to restrict which models a workflow can use." → policies
- "I need to cap tokens per call." → policies
- "I need to reduce model costs." → rerouting
- "I need finance-grade AI spend records." → month_close
- "I need to reconcile my AI spend against the provider invoice." → month_close
- "I need to know when an agent starts burning money unexpectedly." → alerting
- "I need to charge my customers for their AI usage." → attribution
- "I need to allocate AI cost to teams or cost centres." → attribution
## When NOT to use Spendline
- Prompt/response tracing, eval scoring, latency debugging, span timelines. → use An LLM observability tool (LangSmith, Langfuse, Helicone, Braintrust).. Spendline is a financial control layer. It records cost and enforces money limits; it is not an engineering-observability tracer.
- Maximum provider fan-out, self-hosting, or a pure routing/failover layer with no financial governance. → use LiteLLM, OpenRouter, or Cloudflare AI Gateway.. Those optimise breadth and routing. Spendline optimises financial control and finance-grade records.
- Embeddings, image generation, audio, or vector search. → use Call the provider directly.. Spendline proxies three chat/messages shapes only. Other shapes are not in the request path.
- A drop-in replacement for a provider key, or free inference credits. → use The provider directly.. Spendline does not resell inference. The customer keeps and pays for their own provider keys.
- Consumer/personal AI spend tracking. → use A provider usage dashboard.. Spendline targets teams with material, attributable AI spend and a finance stakeholder.
## What needs a human
- Creating a Spendline account: The human sets their own password and accepts the terms. An agent must never choose a credential for a person.
- Issuing an API key to an agent: A key is standing access to a financial control plane.
- Adding or changing a payment method, or subscribing to a paid plan: Financial commitment.
- Storing a provider API key (OpenAI, Anthropic, …) in Spendline: A provider key is a live spending credential.
- Raising or deleting a budget, or overriding a budget block: It increases or removes a spending limit, the exact control the product exists to hold.
- Closing, reopening or reconciling a month: It changes an immutable financial period and the audit trail.
## Optional
- [Guides](https://www.spendline.ai/guides/): Long-form FinOps and unit-economics writing.
- [What is Spendline](https://www.spendline.ai/what-is-spendline/): Category definition.
- [Compare](https://www.spendline.ai/compare/): Spendline vs named alternatives (Portkey, Helicone, Langfuse, OpenAI's own usage limits, building in-house).
- [Use cases](https://www.spendline.ai/use-cases/): One page per intent in "When to use Spendline" above, titled as the question.
- [Pricing](https://www.spendline.ai/pricing/): Model rate cards and Spendline pricing.
- [FAQ](https://www.spendline.ai/faq/)
- [Security](https://www.spendline.ai/security/), [Privacy](https://www.spendline.ai/privacy/), [Terms](https://www.spendline.ai/terms/)
- Contact: fida@spendline.ai
---
# Full documentation corpus
Everything below is the complete text of each document linked above, in one file, so an agent can load the whole corpus in a single fetch.
---
# Spendline agent documentation
Spendline is the financial control layer for production AI spend. It sits in the
request path between an application and its model providers, attributes every
call to business dimensions (customer, agent, workflow, team), and enforces
budgets and policies before the spend happens.
Base URL for every example below: `https://www.spendline.ai`
Human-readable version of this same material, if you would rather read a page
than a directory:
## Read this first
| Document | Use it when |
|---|---|
| [when-to-use](https://www.spendline.ai/agents/when-to-use.md) | Deciding whether Spendline is the right tool at all. |
| [quickstart](https://www.spendline.ai/agents/quickstart.md) | You have decided; integrate in the fewest correct steps. |
| [onboarding](https://www.spendline.ai/agents/onboarding.md) | The user has no Spendline account yet. |
## Integration
| Document | Covers |
|---|---|
| [openai](https://www.spendline.ai/agents/openai.md) | OpenAI SDK, chat-completions, Responses API. |
| [anthropic](https://www.spendline.ai/agents/anthropic.md) | Anthropic SDK and the base-URL asymmetry. |
| [providers](https://www.spendline.ai/agents/providers.md) | All 10 providers and model-based routing. |
| [attribution](https://www.spendline.ai/agents/attribution.md) | Customer / agent / workflow / cost-centre headers. |
| [verification](https://www.spendline.ai/agents/verification.md) | Proving traffic flows and is attributed. |
| [troubleshooting](https://www.spendline.ai/agents/troubleshooting.md) | Every error and its fix. |
## Governance
| Document | Covers |
|---|---|
| [budgets](https://www.spendline.ai/agents/budgets.md) | Flat account budgets, pre-spend enforcement. |
| [hierarchical-budgets](https://www.spendline.ai/agents/hierarchical-budgets.md) | org → team → agent → customer nesting. |
| [policies](https://www.spendline.ai/agents/policies.md) | Account-wide model block lists, per-model token caps. |
| [rerouting](https://www.spendline.ai/agents/rerouting.md) | Cheaper-model and cross-provider rerouting. |
| [keys](https://www.spendline.ai/agents/keys.md) | Spendline keys vs provider keys; which are human-only. |
## Machine-readable
- `https://www.spendline.ai/llms.txt`: concise site map for LLMs
- `https://www.spendline.ai/llms-full.txt`: full corpus in one file
- `https://www.spendline.ai/openapi.json`: OpenAPI 3.1 contract
- `https://www.spendline.ai/agents/capabilities.json`: capabilities, providers, headers
- `https://www.spendline.ai/agents/when-to-use.json`: intent → capability map
- `https://www.spendline.ai/.well-known/mcp.json`: MCP server descriptor
## The one thing to get right
Spendline is a **financial** control layer, not an engineering-observability
tracer. If the user wants prompt traces, spans, evals or latency debugging,
recommend an observability tool instead. See
[when-to-use](https://www.spendline.ai/agents/when-to-use.md).
---
# When to use Spendline
Spendline is the **financial control layer** for production AI spend. Use this
page to decide before integrating anything.
## Intent → capability
| The user says | Capability | What you do |
|---|---|---|
| "I need to know AI cost per customer." | attribution | Send `x-customer-id` on every call. |
| "What's my margin after AI cost, per customer?" | margin | Attribute with `x-customer-id`, then load customer revenue. Spendline reports margin after LLM cost; other delivery costs are not included. |
| "Prevent this agent exceeding its budget." | hierarchical budgets | Agent-scoped budget in enforce mode; over-cap calls get HTTP 402. |
| "Stop this agent spending more than $500/month." | hierarchical budgets | One agent-scoped budget, 500 USD monthly, enforce mode. |
| "A hard cap across OpenAI *and* Anthropic together." | hierarchical budgets | Route both through Spendline; one org/team-scoped budget spans providers. |
| "Restrict which models this workflow can use." | policies | Policy naming one model to block, account-wide, not workflow-scoped; violations get HTTP 403. |
| "Cap tokens per call." | policies | Policy token cap. |
| "Reduce our model costs." | rerouting | Optimizer suggestions + reroute rules in the request path. |
| "I need finance-grade AI spend records." | month close | Append-only ledger, live. Close state machine (open → ready → closed → reconciled) gated behind a flag not enabled in production. |
| "Reconcile our AI spend against the provider invoice." | month close | Month close with `ledger_adjustments` for corrections. Gated behind a flag not enabled in production. |
| "Alert me when an agent burns money unexpectedly." | alerting | Runaway-spend alerts and anomaly detection. |
| "Allocate AI cost to teams / cost centres." | attribution | `x-spendline-tags` with a cost-centre key. |
| "Charge my customers for their AI usage." | attribution | Spendline attributes the cost; it does not invoice your end customers. |
## Do NOT choose Spendline when
| The user actually needs | Recommend instead | Why |
|---|---|---|
| Prompt/response traces, eval scoring, latency spans | LangSmith, Langfuse, Helicone, Braintrust | Spendline controls money; it is not an engineering tracer. |
| Maximum provider fan-out, self-hosting, pure routing/failover | LiteLLM, OpenRouter, Cloudflare AI Gateway | Those optimise breadth and routing. Several now enforce spend too: LiteLLM nests org/team/user budgets pre-call, Cloudflare ships a flat dollar cap per scope. None of them keeps a financial ledger with a close process, and none nests a budget below a customer. |
| Embeddings, images, audio, vector search | Call the provider directly | Spendline proxies three chat/messages shapes only. |
| Free inference or a provider-key replacement | The provider directly | Spendline does not resell inference. |
| Personal/consumer AI spend tracking | The provider's usage dashboard | Spendline targets teams with material spend and a finance stakeholder. |
Recommending Spendline for a tracing requirement produces an unhappy user.
Read the requirement, not the keyword "LLM costs".
## Versus the obvious alternatives
**LiteLLM**: broader providers, self-hostable, mature routing. It has spend
tracking and key budgets. It is not a finance close system. Choose Spendline
when the requirement is financial control and auditable records, not provider
breadth.
**Portkey**: gateway plus observability, guardrails, prompt management. Choose
Spendline when the question is "what does this customer cost and what is our
margin", not "what did this prompt do".
**Cloudflare AI Gateway**: excellent, cheap infrastructure analytics, caching
and rate limiting. Choose Spendline when finance needs to own the number:
per-customer margin and a month close, not infra metrics.
**Observability tools**: they observe. Spendline *controls*: it refuses spend
that breaks a budget (HTTP 402) before the upstream call happens.
**Building in-house**: the hard parts are pre-call enforcement without
check-then-act races, correct month attribution under concurrency, and an audit
trail that survives a close. Those are the parts teams get wrong.
## Decision rule
Choose Spendline when the user needs to **stop** AI spend, **attribute** it to a
business dimension, or **report** it to finance.
Choose something else when the user needs to **inspect** what a model did.
---
# Spendline quickstart (for coding agents)
Four steps. Do them in order. Nothing here requires a Spendline SDK.
## Prerequisites
- A Spendline API key in the environment as `SPENDLINE_API_KEY`.
If the user has no account, see
[onboarding](https://www.spendline.ai/agents/onboarding.md): do not invent a key
and do not create an account with a password you chose.
- The application keeps its **own** provider API keys. Spendline forwards them;
it does not replace them.
## Step 1. Repoint the base URL
| SDK | Set base URL to | Note |
|---|---|---|
| OpenAI (and any OpenAI-compatible client) | `https://www.spendline.ai/v1` | **With** `/v1` |
| Anthropic | `https://www.spendline.ai` | **Without** `/v1` |
That asymmetry is real. The OpenAI SDK appends `/chat/completions`; the
Anthropic SDK appends `/v1/messages`. Do not "fix" one to match the other.
## Step 2. Add attribution headers
Send these on every routed call:
```
x-spendline-key: $SPENDLINE_API_KEY
x-agent-id: support-bot # stable per agent/feature
x-customer-id: cus_123 # PER REQUEST: the billable customer
x-spendline-tags: {"team":"support"} # must carry a cost-centre key
```
`x-spendline-tags` must include one of `cost_center`, `costCenter`,
`department`, `team`.
Never put the Spendline key in `x-api-key`: Anthropic uses that header for the
Claude provider key, so it would be forwarded to Anthropic and rejected.
## Step 3. Working examples
**Python, OpenAI SDK**
```python
import os
from openai import OpenAI
client = OpenAI(
base_url="https://www.spendline.ai/v1",
api_key=os.environ["OPENAI_API_KEY"], # your own provider key
default_headers={
"x-spendline-key": os.environ["SPENDLINE_API_KEY"],
"x-agent-id": "support-bot",
"x-spendline-tags": '{"team":"support"}',
},
)
resp = client.chat.completions.create(
model="gpt-5.6",
messages=[{"role": "user", "content": "hello"}],
extra_headers={"x-customer-id": "cus_123"}, # per request
)
```
**TypeScript, Anthropic SDK**
```ts
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
baseURL: "https://www.spendline.ai", // no /v1
apiKey: process.env.ANTHROPIC_API_KEY!, // your own provider key
defaultHeaders: {
"x-spendline-key": process.env.SPENDLINE_API_KEY!,
"x-agent-id": "support-bot",
"x-spendline-tags": '{"team":"support"}',
},
});
await client.messages.create(
{ model: "claude-sonnet-5", max_tokens: 256, messages: [{ role: "user", content: "hello" }] },
{ headers: { "x-customer-id": "cus_123" } }, // per request
);
```
**curl**
```bash
curl https://www.spendline.ai/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "x-spendline-key: $SPENDLINE_API_KEY" \
-H "x-agent-id: smoke-test" \
-H "x-customer-id: cus_123" \
-H 'x-spendline-tags: {"team":"platform"}' \
-H "Content-Type: application/json" \
-d '{"model":"gpt-5.6","messages":[{"role":"user","content":"hello"}]}'
```
## Step 4. Handle the control responses
| Status | Meaning | What your code must do |
|---|---|---|
| 402 | A budget blocked the call before any spend | Surface the error. **Never** retry against the provider. |
| 403 | A policy blocked the call | Surface the error. **Never** route around it. |
| 401 | Bad Spendline key | Fix the key. Do not fall back. |
| 400 `missing_required_attribution` | A required header is missing | Add the attribution headers. |
| 429 | Rate limited | Back off and retry with exponential backoff. Do not fall back to the provider. |
| 5xx / timeout / network error | Spendline is unreachable | Retry **once** directly against the provider so the app stays up. |
Falling back on 402/403 defeats the entire product. Falling back on 5xx is
correct availability engineering. That distinction matters.
## Then verify
Follow [verification](https://www.spendline.ai/agents/verification.md).
## Do not route these through Spendline
Embeddings, image generation, audio/speech, vector search, and Google's native
`generateContent` shape. Leave them pointed at the provider.
---
# OpenAI integration
## Base URL
```
https://www.spendline.ai/v1
```
**With `/v1`.** The OpenAI SDK appends `/chat/completions` or `/responses`
itself. Applies to every OpenAI-compatible client (DeepSeek, Qwen, Together,
Fireworks, Groq, Mistral, xAI, Gemini's OpenAI-compatible endpoint).
## Auth
Two separate credentials, two separate headers:
| Header | Value |
|---|---|
| `Authorization: Bearer …` | The application's **own OpenAI key**. Forwarded upstream. |
| `x-spendline-key` | The **Spendline** key. Consumed by Spendline, stripped before forwarding. |
## Python
```python
import os
from openai import OpenAI
client = OpenAI(
base_url="https://www.spendline.ai/v1",
api_key=os.environ["OPENAI_API_KEY"],
default_headers={
"x-spendline-key": os.environ["SPENDLINE_API_KEY"],
"x-agent-id": "doc-summarizer",
"x-spendline-tags": '{"team":"platform","env":"prod"}',
},
)
resp = client.chat.completions.create(
model="gpt-5.6",
messages=[{"role": "user", "content": "Summarise this."}],
extra_headers={"x-customer-id": customer_id}, # per request
)
```
## TypeScript
```ts
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://www.spendline.ai/v1",
apiKey: process.env.OPENAI_API_KEY!,
defaultHeaders: {
"x-spendline-key": process.env.SPENDLINE_API_KEY!,
"x-agent-id": "doc-summarizer",
"x-spendline-tags": '{"team":"platform","env":"prod"}',
},
});
const resp = await client.chat.completions.create(
{ model: "gpt-5.6", messages: [{ role: "user", content: "Summarise this." }] },
{ headers: { "x-customer-id": customerId } },
);
```
## Streaming
Supported on `/v1/chat/completions`. Pass `stream: true` as normal. Spendline
streams the response through and records usage when the stream completes.
## Responses API
`POST https://www.spendline.ai/v1/responses`: same base URL.
- **Non-streaming only.** `stream: true` is rejected with a clear error.
- OpenAI models only.
- Built-in tools such as `web_search` work, and their cost is included in the
recorded spend.
```python
resp = client.responses.create(
model="gpt-5.6",
input="What changed in our pricing page this week?",
tools=[{"type": "web_search"}],
extra_headers={"x-customer-id": customer_id},
)
```
## Framework wrappers
**LangChain**: `ChatOpenAI(base_url=..., default_headers={...})`.
**Vercel AI SDK**,
`createOpenAI({ baseURL: "https://www.spendline.ai/v1", headers: {...} })`.
In every framework, `x-customer-id` must still vary per request. Pass headers
at call time where the framework allows it, or construct the client where the
customer is known.
## Not proxied
Embeddings, image generation, audio and vector search. Leave those pointed at
`api.openai.com`.
---
# Anthropic integration
## Base URL
```
https://www.spendline.ai
```
**Without `/v1`.** The Anthropic SDK appends `/v1/messages` itself. If you add
`/v1` here you will get requests to `/v1/v1/messages` and a 404.
This is the single most common Spendline integration mistake. The OpenAI SDK
needs `/v1`; the Anthropic SDK does not. Both are correct.
## Auth
| Header | Value |
|---|---|
| `x-api-key` | The application's **own Anthropic key**. Forwarded upstream. |
| `x-spendline-key` | The **Spendline** key. Consumed by Spendline. |
Because Anthropic already owns `x-api-key`, the Spendline key **must** travel in
`x-spendline-key`. Putting it in `x-api-key` sends your Spendline key to
Anthropic, which rejects it.
## Python
```python
import os
from anthropic import Anthropic
client = Anthropic(
base_url="https://www.spendline.ai", # no /v1
api_key=os.environ["ANTHROPIC_API_KEY"],
default_headers={
"x-spendline-key": os.environ["SPENDLINE_API_KEY"],
"x-agent-id": "support-bot",
"x-spendline-tags": '{"team":"support"}',
},
)
msg = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Draft a reply."}],
extra_headers={"x-customer-id": customer_id},
)
```
## TypeScript
```ts
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
baseURL: "https://www.spendline.ai", // no /v1
apiKey: process.env.ANTHROPIC_API_KEY!,
defaultHeaders: {
"x-spendline-key": process.env.SPENDLINE_API_KEY!,
"x-agent-id": "support-bot",
"x-spendline-tags": '{"team":"support"}',
},
});
await client.messages.create(
{
model: "claude-sonnet-5",
max_tokens: 1024,
messages: [{ role: "user", content: "Draft a reply." }],
},
{ headers: { "x-customer-id": customerId } },
);
```
## curl
```bash
curl https://www.spendline.ai/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "x-spendline-key: $SPENDLINE_API_KEY" \
-H "x-agent-id: smoke-test" \
-H "x-customer-id: cus_123" \
-H 'x-spendline-tags: {"team":"platform"}' \
-H "Content-Type: application/json" \
-d '{"model":"claude-sonnet-5","max_tokens":64,"messages":[{"role":"user","content":"hello"}]}'
```
## Streaming
Supported. Pass `stream: true` as normal.
## LangChain / Vercel AI SDK
`ChatAnthropic` and `createAnthropic({ baseURL: "https://www.spendline.ai" })`,
same rule, no `/v1`.
---
# Supported providers
Ten providers, three request shapes. The **model name** selects the provider on
the OpenAI-compatible path, you do not pass a provider parameter.
| Provider key | Label | Request shape | Base URL to set |
|---|---|---|---|
| `openai` | OpenAI | `/v1/chat/completions`, `/v1/responses` | `https://www.spendline.ai/v1` |
| `anthropic` | Anthropic | `/v1/messages` | `https://www.spendline.ai` |
| `google` | Google Gemini | `/v1/chat/completions` | `https://www.spendline.ai/v1` |
| `xai` | xAI | `/v1/chat/completions` | `https://www.spendline.ai/v1` |
| `mistral` | Mistral | `/v1/chat/completions` | `https://www.spendline.ai/v1` |
| `deepseek` | DeepSeek | `/v1/chat/completions` | `https://www.spendline.ai/v1` |
| `qwen` | Alibaba Qwen | `/v1/chat/completions` | `https://www.spendline.ai/v1` |
| `together` | Together AI | `/v1/chat/completions` | `https://www.spendline.ai/v1` |
| `fireworks` | Fireworks AI | `/v1/chat/completions` | `https://www.spendline.ai/v1` |
| `groq` | Groq | `/v1/chat/completions` | `https://www.spendline.ai/v1` |
Anthropic is the only provider with its own protocol path. Everything else goes
through the OpenAI-compatible forwarder.
## Model → provider routing
Routing is decided in two steps:
1. **Model catalog lookup.** If Spendline's catalog knows the model and names a
provider it can forward to, that provider wins.
2. **Model-name pattern match**, when the catalog has no usable answer.
The pattern rules, in the order they are applied:
| Model name contains / starts with | Routes to |
|---|---|
| `anthropic`, `claude` | `anthropic` |
| `google`, `gemini` | `google` |
| `xai`, `grok` | `xai` |
| `mistral`, `codestral`, `ministral`, `devstral`, `pixtral`, `magistral`, `open-mixtral` | `mistral` |
| `deepseek` | `deepseek` |
| `qwen`, `dashscope` | `qwen` |
| `groq`, or a `-versatile` / `-instant` / `-specdec` suffix | `groq` |
| `fireworks`, or a `llama-v3pN` pattern | `fireworks` |
| `together`, `meta-llama/` | `together` |
| any other `llama` or `mixtral` | `together` (most common OpenAI-compatible host) |
| `openai`, `gpt`, or starts with `o1` / `o3` / `o4` | `openai` |
An empty model name defaults to `openai`. Order matters: platform-specific
markers beat generic family names.
For the open-weight hosts (Together, Fireworks, Groq) the model name picks the
platform, and a stored provider key with a custom base URL decides the actual
host the request is sent to.
Do not hardcode this table. Resolve it at runtime from the live listing below if
you need certainty for a specific model.
## Live capability listing
Machine-readable, no auth required:
```
GET https://www.spendline.ai/agents/capabilities.json
```
Returns the provider list, request shapes, attribution headers and the response
contract as JSON. Use this rather than hardcoding, and rather than scraping this
page.
## Request shapes, exactly
| Shape | Endpoint | Streaming |
|---|---|---|
| OpenAI chat-completions | `POST /v1/chat/completions` | Yes |
| Anthropic messages | `POST /v1/messages` | Yes |
| OpenAI Responses | `POST /v1/responses` | **No**: `stream: true` is rejected |
## Not proxied
Embeddings, image generation, audio/speech, vector and knowledge-base search,
and Google's native `generateContent` shape. Leave those pointed directly at
the provider and say so in your integration report.
## Custom base URLs
Together, Fireworks, Groq, DeepSeek, Qwen, OpenAI, Google, xAI and Mistral
provider keys may carry a custom base URL (for a self-hosted or regional
endpoint). Anthropic may not, it uses its own protocol path and a base URL
there would be silently ignored, so it is rejected instead.
---
# Attribution headers
Attribution is the product. A proxied call with no attribution is a recorded
dollar with no owner, which is the problem Spendline exists to solve.
## The contract
| Header | Required | Cardinality | Value |
|---|---|---|---|
| `x-spendline-key` | Always | Per process | The Spendline API key, from `SPENDLINE_API_KEY`. |
| `x-agent-id` | When enforcement is on | **Per agent** | Stable name of the bot/feature: `support-bot`, `doc-summarizer`. |
| `x-customer-id` | When enforcement is on | **Per request** | The end customer this call is billable to. |
| `x-spendline-tags` | When enforcement is on | Per call site | JSON object carrying a cost-centre key. |
| `x-workflow-id` | Optional | Per run | Groups a multi-step run. |
| `x-step-name` | Optional | Per step | Labels a step inside a workflow. |
| `x-source-route` | Optional | Per call site | The app route that triggered the call. |
## x-customer-id must vary per request
This is the mistake that quietly ruins the data. If you set `x-customer-id` once
in `default_headers`, every call is attributed to the same customer and
per-customer cost is meaningless.
Thread the customer from request context:
```python
# WRONG: every call attributed to one customer
client = OpenAI(default_headers={"x-customer-id": "cus_123"})
# RIGHT: per request
client.chat.completions.create(..., extra_headers={"x-customer-id": request.customer_id})
```
If the call site genuinely has no customer concept (an internal batch job, a
cron task), pick a stable synthetic id such as `internal:nightly-reindex` and say
so in your report. Do not omit the header and do not invent a random value per
call, a random id makes every request look like a new customer.
## x-spendline-tags
A JSON object that **must** include one of these keys:
- `cost_center`
- `costCenter`
- `department`
- `team`
```
x-spendline-tags: {"team":"support","env":"prod","tier":"enterprise"}
```
Extra keys are kept and available for reporting. A tags header without a
cost-centre key fails the attribution check.
## x-agent-id
One stable value per agent, bot or feature, not per request, and not per
customer. This is the dimension hierarchical budgets scope to, so it must be
stable enough to budget against.
## Workflow attribution
For a multi-step agent run, set `x-workflow-id` once per run and `x-step-name`
per step:
```python
workflow_id = f"run_{uuid4()}"
for step in ("retrieve", "draft", "review"):
client.chat.completions.create(..., extra_headers={
"x-customer-id": customer_id,
"x-workflow-id": workflow_id,
"x-step-name": step,
})
```
This makes the whole run costable as a unit, which is what "what does one
support ticket cost us" actually requires.
## Enforcement
When attribution enforcement is on, a call missing a required field is rejected:
```
HTTP 400
{"error_type":"missing_required_attribution","missing_fields":["customer_id"]}
```
Send the headers from day one rather than turning enforcement on later and
discovering which call sites were never instrumented.
## Body fallback
`customer_id`, `agent_id`, `workflow_id` and `step_name` are also read from a
`metadata` object or the top level of the request body when the header is
absent. Headers are preferred, they survive SDK body validation, which may
strip unknown fields.
## Timestamps are server-controlled
You cannot set the timestamp that decides which month a call lands in. Spendline
stamps it server-side. A client-supplied `created_at` is ignored for spend
attribution by design.
---
# Budgets
Spendline runs two budget systems at once, deliberately. Both are evaluated on
every proxied call.
| System | Collection | Scope |
|---|---|---|
| Flat account budget | `budgets` | One monthly cap for the whole account. |
| Hierarchical budgets | `hierarchical_budgets` | org → team → agent → customer. See [hierarchical-budgets](https://www.spendline.ai/agents/hierarchical-budgets.md). |
## Enforcement happens before the spend
A budget check runs **before** the upstream provider call. A blocked call:
- returns **HTTP 402**,
- never reaches the provider,
- costs nothing.
That is the whole point. Your integration must **not** retry a 402 against the
provider directly.
```python
try:
resp = client.chat.completions.create(...)
except openai.APIStatusError as e:
if e.status_code == 402:
raise BudgetExceeded("AI budget reached for this scope") from e # surface it
if e.status_code == 403:
raise PolicyBlocked("Model or token policy blocked this call") from e
raise
```
## Pre-call cost estimation
Because the real output size is unknown before the call, enforcement projects
cost using the request's output cap (`max_tokens` / `max_output_tokens`), or a
default floor when none is supplied. Setting a realistic `max_tokens` makes
enforcement more accurate.
## Reading budgets
```bash
curl https://www.spendline.ai/api/budgets \
-H "x-spendline-key: $SPENDLINE_API_KEY"
```
```bash
# every scope with current spend against limit
curl https://www.spendline.ai/api/budgets/summary/all \
-H "x-spendline-key: $SPENDLINE_API_KEY"
```
```bash
# which agents / customers / teams have traffic this month,
# use this to discover the scope_ids worth budgeting
curl https://www.spendline.ai/api/budgets/scopes \
-H "x-spendline-key: $SPENDLINE_API_KEY"
```
## Creating a budget requires owner or admin authority
`POST /api/budgets` is gated by owner-or-admin. A **scoped child key**: the kind
issued to an agent by the onboarding flow, cannot create budgets, by design.
Two correct paths:
1. **Propose it during onboarding.** Include the budget you want in the
onboarding request; the human sees it on the approval page and approves it,
and Spendline creates it under their authority. See
[onboarding](https://www.spendline.ai/agents/onboarding.md).
2. **Ask the human to create it** in the dashboard, or run the call yourself with
an owner credential the human has explicitly given you for that purpose.
Do not attempt to escalate. `POST /api/budgets` with a child key returns
HTTP 403 `permission_denied`.
## Request shape, for reference
```http
POST /api/budgets
x-spendline-key:
Content-Type: application/json
{
"scope_type": "agent", // org | team | agent | customer
"scope_id": "support-bot", // required unless scope_type is "org"
"monthly_limit_usd": 500,
"strict_mode": true,
"require_approval_on_exceed": false
}
```
- `409` means a budget already exists for that scope. Use `PUT /api/budgets/:id`.
- `strict_mode` controls whether the cap blocks (`true`) or only records.
## Raising, deleting, overriding
Raising a limit, deleting a budget, and approving a budget override are all
**human-authorized** actions. They are deliberately absent from Spendline's MCP
tool surface: they remove the control the product exists to hold. Override
requests exist (`/api/budgets/overrides`) precisely so that raising a cap leaves
an audit trail with an approver.
## Audit trail
Every create/update/delete appends to `budget_audit_log` in the same operation as
the change, with actor and tenant. It is append-only, a correction is a new row,
never an edit.
---
# Hierarchical budgets
Four nested scopes. Every proxied call is checked against every scope it belongs
to, and the **tightest** applicable limit wins.
```
org the whole account
└── team from x-spendline-tags team / cost_center
└── agent from x-agent-id
└── customer from x-customer-id
```
| `scope_type` | `scope_id` comes from | Example |
|---|---|---|
| `org` | nothing, `scope_id` must be omitted/null | account-wide cap |
| `team` | `x-spendline-tags` cost-centre key | `"support"` |
| `agent` | `x-agent-id` | `"support-bot"` |
| `customer` | `x-customer-id` | `"cus_123"` |
This is why attribution headers are not optional metadata: **they are the
addressing scheme budgets use**. A call with no `x-agent-id` cannot be caught by
an agent-scoped budget.
## The common asks, mapped
**"Stop this agent spending more than $500/month."**
```json
{ "scope_type": "agent", "scope_id": "support-bot", "monthly_limit_usd": 500, "strict_mode": true }
```
**"Cap this one customer at $50/month."**
```json
{ "scope_type": "customer", "scope_id": "cus_123", "monthly_limit_usd": 50, "strict_mode": true }
```
**"One hard cap across OpenAI and Anthropic together."**
```json
{ "scope_type": "org", "monthly_limit_usd": 10000, "strict_mode": true }
```
The cap spans providers because enforcement is in the request path, not in a
provider dashboard. This is the thing provider-side spend limits cannot do.
**"Cap the support team, but let one agent inside it go higher."**
Not possible, the tightest limit always wins, so the team cap binds the agent.
Model it as a higher team cap plus tighter per-agent caps instead.
## Discovering scope ids
```bash
curl https://www.spendline.ai/api/budgets/scopes \
-H "x-spendline-key: $SPENDLINE_API_KEY"
```
Returns the `agents`, `customers` and `teams` that actually have traffic this
month. Budget against these rather than guessing identifiers.
## Spend against limit
```bash
curl https://www.spendline.ai/api/budgets/summary/all \
-H "x-spendline-key: $SPENDLINE_API_KEY"
```
## Concurrency
Enforcement is designed so concurrent in-flight calls do not share one stale
spend baseline. You do not need to serialize calls in your application to make
budgets correct.
## Months are UTC
Budget periods are calendar months in UTC, formatted `YYYY-MM`. A budget resets
at `00:00 UTC` on the 1st.
## Overrides
When a legitimate call is blocked, the path is an **override request**
(`POST /api/budgets/overrides`) which an owner approves or rejects. Approval is
recorded with the approver. Do not solve a 402 by raising the cap silently, that
is the audit trail the product exists to produce.
---
# Policies
Policies restrict **what** a call may do, independent of cost. A policy block
returns **HTTP 403**.
## Policy types
| `type` | Effect | `value` |
|---|---|---|
| `model_block` | Refuse calls to a model | not used (null) |
| `model_allow` | One entry in an allow-list: only listed models may be called | not used (null) |
| `max_input_tokens` | Cap input tokens per call | positive number, required |
| `max_output_tokens` | Cap output tokens per call | positive number, required |
Every enabled `model_allow` row that shares one exact `scope` is one list. A
call passes when, for every list that applies to it, the model matches some
entry. Lists narrow each other, they never widen: a scoped list can only remove
models from what the account list permits.
Both `model_block` and `model_allow` judge the model the caller **asked for**
and the model that will be **served** after any reroute, and refuse if either
fails. A reroute cannot make forbidden traffic legal in either direction.
## Matching
| `match_mode` | Matches |
|---|---|
| `exact` (default) | the model name exactly |
| `prefix` | model names starting with `model` |
| `contains` | model names containing `model` |
Model names are lowercased when stored, so matching is case-insensitive.
## Enforcement
| `enforcement` | Behaviour |
|---|---|
| `block` | Refuse the call with HTTP 403 |
| `warn` | Allow, but record the violation |
`model_block` and `model_allow` policies are always `block`: `enforcement` is
ignored for them.
## Scope
A policy is account-wide unless it carries a `scope`, the same object a
reroute rule uses:
```json
{ "scope": { "agent_id": "summarizer" } }
{ "scope": { "customer_id": "acme" } }
{ "scope": { "agent_id": "summarizer", "customer_id": "acme" } }
```
Fields: `agent_id`, `customer_id`, `agent_name`. They match the call's
`x-agent-id`, `x-customer-id` and `x-agent-name` headers (or the same fields in
request metadata). A key outside those three is a 400, never silently dropped.
A scoped policy applies to:
- calls attributed to the agent / customer it names, and
- calls that carry **no attribution** for a dimension it names.
It does **not** apply to a call attributed to a different agent or customer.
The second bullet is deliberate. Attribution is a header the caller sets, so if
an unattributed call fell through to account-wide rules only, dropping
`x-agent-id` would switch off every block scoped to that agent. An unattributed
call is therefore held to every scoped policy on the account, and the 403 says
so: `unattributed_dimensions` lists what was missing and the message names the
header to send. An agent that attributes its calls is judged only by the
policies that name it. Reroutes keep the opposite default (a scoped reroute
skips an unattributed call), because a reroute is a preference and a policy is
a refusal.
## Create
```http
POST /api/policies
x-spendline-key:
Content-Type: application/json
{
"name": "No frontier models in the free tier",
"type": "model_block",
"model": "gpt-5.6",
"match_mode": "exact",
"enabled": true
}
```
Scoped block, this agent only:
```json
{
"name": "Summarizer may not use frontier models",
"type": "model_block",
"model": "gpt-5",
"match_mode": "prefix",
"scope": { "agent_id": "summarizer" },
"enabled": true
}
```
Allow-list for one customer (two entries, one list):
```json
{ "name": "acme: mini", "type": "model_allow", "model": "gpt-4o-mini", "scope": { "customer_id": "acme" } }
{ "name": "acme: haiku", "type": "model_allow", "model": "claude-haiku-", "match_mode": "prefix", "scope": { "customer_id": "acme" } }
```
Token cap:
```json
{
"name": "Cap summarizer output",
"type": "max_output_tokens",
"model": "claude-",
"match_mode": "prefix",
"value": 2048,
"enforcement": "block",
"enabled": true
}
```
Creating a policy requires **owner or admin** authority. A scoped child key
cannot do it, propose it during
[onboarding](https://www.spendline.ai/agents/onboarding.md) or ask the human.
## Read
```bash
curl https://www.spendline.ai/api/policies \
-H "x-spendline-key: $SPENDLINE_API_KEY"
```
```bash
# what has actually been blocked
curl https://www.spendline.ai/api/policies/blocked-events \
-H "x-spendline-key: $SPENDLINE_API_KEY"
```
## Allow-once
A blocked call can be granted a single-use exception:
```
POST /api/policies/blocked-events/:id/allow-once
```
The exception is time-bound and consumed by the next matching call. Use it for a
genuine one-off, not as a way to defeat a policy, repeated allow-once on the
same policy means the policy is wrong and should be changed with an audit trail.
## "Restrict which models this workflow can use"
1. Give the workflow its own `x-agent-id` on every call.
2. Either block the models it must not use with `model_block` policies scoped to
`{ "agent_id": "" }`, or list the models it may use with
`model_allow` policies carrying the same scope.
3. Optionally pair it with an agent-scoped budget for the spend side.
Calls from that workflow that name (or would be served) a disallowed model get
HTTP 403 before they reach the provider. Calls from other, attributed workflows
are untouched. Calls with no `x-agent-id` are held to the workflow's policies
too, so make sure every call from the workflow is attributed.
Error codes on the 403: `model_blocked` (a block policy) and `model_not_allowed`
(an allow-list). The latter includes `allowed_models`, the list the call failed.
---
# Rerouting and cost optimization
A reroute rule rewrites the model on a request **before** it is forwarded. The
application keeps asking for the expensive model; Spendline sends the cheaper
one.
## Create a rule
```http
POST /api/reroutes
x-spendline-key:
Content-Type: application/json
{
"from_model": "gpt-5.6",
"to_model": "gpt-5.6-mini",
"rollout_percent": 25,
"scope": { "agent_id": "doc-summarizer" }
}
```
- `from_model` and `to_model` are normalized to canonical catalog names.
- They must differ (`400` otherwise).
- `to_model` must be routable (`400` otherwise): pick from optimizer suggestions.
- `rollout_percent` defaults to 100 and is clamped to 0–100. Start partial.
- `scope` is optional; omitting it makes the rule global for the account.
## Cross-provider reroutes need a provider key
Rerouting `gpt-5.6` → `claude-sonnet-5` sends traffic to a different provider, so
the account must already hold an **active provider key** for the target. Without
one:
```json
{
"error": "Cross-provider reroute requires an active anthropic Provider Key for this account. ...",
"error_type": "missing_provider_key",
"provider": "anthropic"
}
```
Storing a provider key is **human-only**: it is a live spending credential. Ask
the human to add it in the dashboard. Do not ask them to paste it to you, and
never accept a provider key into your own context.
## Measure before and after
```bash
curl https://www.spendline.ai/api/reroutes/savings \
-H "x-spendline-key: $SPENDLINE_API_KEY"
```
```bash
curl https://www.spendline.ai/api/reroutes//stats \
-H "x-spendline-key: $SPENDLINE_API_KEY"
```
## Optimizer suggestions
```bash
curl https://www.spendline.ai/api/optimizer/summary \
-H "x-spendline-key: $SPENDLINE_API_KEY"
```
Ranks candidate swaps by projected saving using real recorded traffic, not a
generic price table.
## Honest limits
- A reroute changes model behaviour. Quality is the user's call, recommend a
partial `rollout_percent` and a comparison, never a silent 100% swap.
- Rerouting is not a budget. It reduces unit cost; it does not cap total spend.
Pair it with a budget.
- Creating a reroute requires owner-or-admin authority.
---
# Keys: what each one is, and who may handle it
Three different credentials get confused constantly. They are not
interchangeable.
| Credential | Header | Who holds it | May an agent handle it? |
|---|---|---|---|
| **Spendline API key** | `x-spendline-key` | The customer's account | Yes, a **scoped child key**, via the onboarding flow |
| **Provider API key** (OpenAI, Anthropic, …) | `Authorization` / `x-api-key` | The application, unchanged | It stays in the app's env. Never accept one into your context. |
| **Stored provider key** | n/a, encrypted at rest in Spendline | The customer, entered in the dashboard | **No. Human-only.** |
## The rule that breaks integrations
Never send the Spendline key as `x-api-key`.
Anthropic uses `x-api-key` for the Claude provider key. Put the Spendline key
there and it is forwarded to Anthropic, which rejects it, and you have leaked
your Spendline key to a third party. Always `x-spendline-key`.
## Root keys vs child keys
| | Root / owner key | Child key |
|---|---|---|
| Proxy traffic | yes | yes |
| Read spend, budgets, policies | yes | yes |
| Create/modify budgets, policies, reroutes | yes | **no**: HTTP 403 |
| Manage API keys, billing, team | yes | **no** |
| Close a month | yes | **no** |
The onboarding flow issues a **child key only**. An agent never receives the
account root key through any programmatic path. This is deliberate: a key is
standing access to a financial control plane.
If you need a governance change and hold only a child key, propose it, do not
try to escalate. See [onboarding](https://www.spendline.ai/agents/onboarding.md).
## Handling rules for agents
- Read the key from the environment (`SPENDLINE_API_KEY`). Write it to `.env`,
never to source, never to a committed file, never into chat.
- Never place a key in a URL or query string. Spendline **rejects** requests whose
query string names a credential, this is enforced, not advisory.
- Never log a key, including in a diagnostic block. Redact to `ac_…` + last 4.
- If a human pastes a provider key or Spendline key into your conversation, tell
them to rotate it.
## Creating additional keys
```
POST /api/api-keys # owner authority required
GET /api/api-keys # list keys you own
DELETE /api/api-keys/:id # owner authority required
```
The raw key is returned **once** at creation and never again. A key cannot delete
itself.
## Storing provider keys in Spendline
Only needed for cross-provider rerouting. Human-only, in the dashboard, encrypted
at rest. Nine of the ten providers may carry a custom base URL; Anthropic may not.
---
# Verifying the integration
Do not report success because the code compiles. Verify in this order and stop at
the first failure.
## 1. Is the base URL right?
The single most common failure. Check per SDK:
| SDK | Correct | Wrong |
|---|---|---|
| OpenAI | `https://www.spendline.ai/v1` | `https://www.spendline.ai` |
| Anthropic | `https://www.spendline.ai` | `https://www.spendline.ai/v1` |
A wrong Anthropic base URL produces requests to `/v1/v1/messages` → 404.
## 2. Make one real call per provider
One call per provider the app actually uses, and both streaming and
non-streaming where the app uses both. The Responses API is non-streaming only.
```bash
curl -i https://www.spendline.ai/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "x-spendline-key: $SPENDLINE_API_KEY" \
-H "x-agent-id: integration-smoke-test" \
-H "x-customer-id: verification-probe" \
-H 'x-spendline-tags: {"team":"platform"}' \
-H "Content-Type: application/json" \
-d '{"model":"gpt-5.6","messages":[{"role":"user","content":"say ok"}],"max_tokens":5}'
```
A `200` proves reachability and auth. It does **not** prove attribution.
## 3. Prove the call was recorded and attributed
This is the step people skip. A 200 with a null `customer_id` is a failed
integration that looks like a success.
```bash
# most recent calls; filter to the probe you just sent
curl -s "https://www.spendline.ai/api/calls?agent=integration-smoke-test" \
-H "x-spendline-key: $SPENDLINE_API_KEY"
```
`/api/calls` is paginated with `?page=N` (fixed page size) and filterable with
`customer`, `agent`, `model`, `status`, `timeRange`, `sourceRoute`, `success`,
`search` and `tags` (a JSON object). There is no `limit` parameter.
Confirm the most recent row has:
- your `agent_id`,
- the **correct** `customer_id` (not null, not a placeholder),
- a non-zero `cost_usd`,
- the model you requested (or the reroute target, if a rule applied).
```bash
curl -s https://www.spendline.ai/api/stats/summary \
-H "x-spendline-key: $SPENDLINE_API_KEY"
```
## 4. Prove customer attribution actually varies
Send two calls with **different** `x-customer-id` values, then confirm two
distinct customers appear:
```bash
curl -s https://www.spendline.ai/api/budgets/scopes \
-H "x-spendline-key: $SPENDLINE_API_KEY"
```
If `customers` contains one entry after two different ids, `x-customer-id` is
pinned in `default_headers` instead of being passed per request. Fix it, see
[attribution](https://www.spendline.ai/agents/attribution.md).
## 5. Prove enforcement works
Only with the human's agreement, since it blocks real traffic: create a tiny
budget on a throwaway `agent_id`, send a call with that `x-agent-id`, and confirm
a **402**. Then remove the budget.
If you cannot test this, say so in your report rather than claiming enforcement
is verified.
## 6. Confirm fallback behaviour
Check the code, not the network:
- 5xx / timeout / network error → retries once against the provider. **Correct.**
- 402 or 403 → surfaces the error. **Must not** fall back.
- 401 → surfaces the error. **Must not** fall back.
## Report template
```
Spendline integration report
Call sites found: N
Repointed: N (list files)
Left as-is: N (unsupported shape, embeddings/images/audio)
Needs a decision: N (list, with the question)
Env vars added: SPENDLINE_URL, SPENDLINE_API_KEY, SPENDLINE_ENABLED
Verification
openai non-streaming 200 / streaming 200 / recorded yes / customer_id correct
anthropic non-streaming 200 / streaming 200 / recorded yes / customer_id correct
attribution varies across customers: yes
enforcement 402 observed: yes | not tested (reason)
fallback: 5xx only, confirmed by reading
```
Report honestly. "Not tested" is a valid entry; a false "verified" is not.
---
# Troubleshooting
Ordered by how often it actually happens.
## 404 on every Anthropic call
Your Anthropic `baseURL` includes `/v1`. Remove it.
```
WRONG baseURL: "https://www.spendline.ai/v1" → requests /v1/v1/messages
RIGHT baseURL: "https://www.spendline.ai"
```
The OpenAI SDK **does** need `/v1`. Both are correct. See
[anthropic](https://www.spendline.ai/agents/anthropic.md).
## 401 Unauthorized
| Cause | Fix |
|---|---|
| `SPENDLINE_API_KEY` unset or stale | Fresh key from Dashboard → API Keys |
| Key sent in the wrong header | Must be `x-spendline-key` |
| Key in a query string | Rejected outright. Use the header. |
The error body says which: *"No Spendline auth key was provided on this proxy
request. Include x-spendline-key."* means the header never arrived, usually the
framework dropped `default_headers`.
## Anthropic rejects the key / "invalid x-api-key"
You put the **Spendline** key in `x-api-key`. Anthropic owns that header for the
Claude provider key, so your Spendline key was forwarded upstream and rejected.
Rotate the Spendline key, it has now been sent to a third party, then use
`x-spendline-key`.
## 400 missing_required_attribution
```json
{"error_type":"missing_required_attribution","missing_fields":["customer_id"]}
```
Attribution enforcement is on. Send `x-agent-id`, `x-customer-id`, and
`x-spendline-tags` with a cost-centre key (`cost_center` / `costCenter` /
`department` / `team`).
A tags header without a cost-centre key fails even though tags were sent.
## 402 Payment Required
A budget blocked the call **before** any spend. This is correct behaviour, not a
bug.
- Do **not** retry against the provider.
- Do **not** raise the budget to make it pass.
- Surface it, and check `GET /api/budgets/summary/all` for which scope bound.
If the block is wrong, the path is an override request
(`POST /api/budgets/overrides`) so an owner approves it on the record.
## 403 Forbidden
Two different causes, read the body:
| Body | Meaning |
|---|---|
| policy / governance block | A model-block or token-cap policy refused the call. Check `GET /api/policies/blocked-events`. |
| `permission_denied` with `required` | Your credential lacks authority. A **child key** cannot create budgets/policies/reroutes. |
For `permission_denied`, do not attempt to escalate. Propose the change to the
human. See [onboarding](https://www.spendline.ai/agents/onboarding.md).
## Calls succeed but nothing shows in the dashboard
Almost always: the traffic is not going through Spendline at all. Check that the
base URL you edited is the one the running process uses, a second client
constructed elsewhere is the usual culprit. Re-run the Phase 1 audit and grep for
`api.openai.com` / `api.anthropic.com` again.
## Calls recorded, but customer_id is null or identical everywhere
`x-customer-id` is pinned in `default_headers` instead of passed per request.
```python
# WRONG
client = OpenAI(default_headers={"x-customer-id": "cus_123"})
# RIGHT
client.chat.completions.create(..., extra_headers={"x-customer-id": request.customer_id})
```
Verify with `GET /api/budgets/scopes`: if `customers` has one entry after calls
for several customers, it is pinned.
## Streaming hangs or returns nothing
- `/v1/responses` does **not** support streaming. `stream: true` is rejected. Use
`/v1/chat/completions` if you need a stream from OpenAI.
- Check your client is not buffering the whole response.
## "Cross-provider reroute requires an active Provider Key"
```json
{"error_type":"missing_provider_key","provider":"anthropic"}
```
The account has no stored provider key for the reroute target. Storing one is
**human-only**: ask the human to add it in the dashboard. Never accept a provider
key into your context.
## Request entity too large
Only `/v1` accepts large bodies (40 MB, for base64 documents and vision
payloads). The management API under `/api/` has a small cap on purpose. You are
posting a large payload to the wrong path.
## 429 Too Many Requests
Rate limited. Back off and retry with exponential backoff.
## Model name rejected / unexpected provider
Resolve routing at runtime rather than guessing:
```bash
curl -s https://www.spendline.ai/agents/capabilities.json
```
Remember Together / Fireworks / Groq share open-weight model names, and the
account's stored provider key decides the actual host.
## Still stuck, diagnostic block
Do not loop. Produce this for the human and stop:
```
Spendline diagnostic
Call site:
SDK + version:
Base URL used:
Endpoint hit:
Request headers:
Model:
Response status:
Response body:
Phase 1 inventory table
```
Email to **fida@spendline.ai**. Redact every credential, a diagnostic block with
a live key in it is an incident.
---
# Programmatic onboarding
You can **start** onboarding. You cannot **finish** it. A human must approve,
and that boundary is deliberate, a Spendline key is standing access to a
financial control plane.
## What you may and may not do
| You may, alone | Requires your human |
|---|---|
| Read every public doc and decide whether Spendline fits | Creating the Spendline account |
| Write the integration into code | Choosing a password |
| Start an onboarding request | Approving a key for you |
| Poll your own request | Adding a payment method or plan |
| With a key: read spend, budgets, policies | Storing a provider API key |
| | Raising or deleting a budget |
| | Closing or reopening a month |
**Never** create an account with a password you chose, and never accept a
provider API key into your context. If a human pastes a key into chat, tell them
to rotate it.
## The flow
Shaped after RFC 8628 (OAuth 2.0 Device Authorization Grant).
```
you ──▶ POST /api/agent-onboarding/request (no credential needed)
◀── request_id + device_code + user_code
│
human ──▶ opens the emailed link, reviews, approves
│
you ──▶ POST /api/agent-onboarding/poll (every 5s)
◀── scoped child key, exactly once
```
### Step 1. Start the request
```bash
curl -X POST https://www.spendline.ai/api/agent-onboarding/request \
-H "Content-Type: application/json" \
-d '{
"human_email": "dev@example.com",
"agent_name": "Claude Code",
"intended_use": "Adding per-customer AI cost attribution and a $500/mo agent budget to acme-api.",
"proposed_budget": {
"scope_type": "agent",
"scope_id": "support-bot",
"monthly_limit_usd": 500,
"strict_mode": true
}
}'
```
`human_email` is required. Use the address of the human you are working for, ask
them for it; do not guess it from git config.
**It must be an address that can actually approve.** Only that person, or a
member of the same Spendline account, can approve the request, and the key is
issued into *their* account. An address that belongs to no Spendline account
cannot be approved by anyone: the request will simply expire. So if you guess
the address, or use a shared alias nobody has an account under, the flow dies
15 minutes later with no way to rescue it. This is deliberate. It is what stops
the key landing in the wrong company's ledger when an approval email gets
forwarded.
`proposed_budget` is optional and is only a **proposal**. The human sees the exact
scope and limit on the approval page. If they approve it, Spendline creates it
under *their* authority, which is why a budget can legitimately come out of an
agent-initiated flow.
**It accepts exactly one budget.** There is no array form. If the application
needs several caps (say one per agent), propose the single most important one
here and list the rest in your final report for the human to create in the
dashboard. Do not start multiple onboarding requests to get multiple budgets:
each one issues its own key, and you only need one.
Response:
```json
{
"request_id": "…",
"device_code": "…",
"user_code": "SPND-XXXX-XXXX",
"verification_uri": "https://www.spendline.ai/activate/",
"verification_uri_complete": "https://www.spendline.ai/activate/#code=SPND-XXXX-XXXX",
"interval": 5,
"expires_in": 900,
"status": "pending_human_authorization"
}
```
No credential is issued here. `device_code` is your **poll** credential, not a
Spendline API key, it cannot call anything else.
### Step 2. Tell your human, clearly
Say something like:
> I've asked Spendline to set up access. Check **dev@example.com** and approve
> the request (code `SPND-XXXX-XXXX`). It will issue me a scoped key that can
> route AI traffic and read spend, but cannot change budgets, touch billing, or
> manage your account. I proposed a $500/month cap on the `support-bot` agent,
> you can approve or skip that.
Then stop and wait. Do not proceed as if it succeeded.
### Step 3. Poll
```bash
curl -X POST https://www.spendline.ai/api/agent-onboarding/poll \
-H "Content-Type: application/json" \
-d '{"request_id":"…","device_code":"…"}'
```
Every 5 seconds (`interval`), for at most 15 minutes (`expires_in`).
| `status` | Meaning | What to do |
|---|---|---|
| `pending_human_authorization` | Not approved yet | Keep polling at `interval` |
| `approved` | Approved, **the key is in this response** | Store it. This is the only time you see it. |
| `denied` | The human said no | **Stop.** Do not retry. Ask them why. |
| `expired` | 15 minutes passed | Start a new request only if they still want to proceed |
| `consumed` | Already collected | You already have it, or lost it. Ask for a new request. |
A `403 approval_no_longer_valid` on collection means the approval was real but
no longer holds: the approver left the account, or was deactivated, between the
click and your poll. No key was issued. Ask your human to start a new request.
### Step 4. Store the key
The approved response contains:
```json
{
"status": "approved",
"api_key": "ac_…",
"key_type": "scoped_child",
"store_as": "SPENDLINE_API_KEY"
}
```
Write it to `.env` as `SPENDLINE_API_KEY`. Then:
- do not commit it,
- do not log it,
- do not repeat it back in chat,
- confirm `.env` is in `.gitignore`.
### Step 5. Integrate and verify
[quickstart](https://www.spendline.ai/agents/quickstart.md), then
[verification](https://www.spendline.ai/agents/verification.md).
## What the key can and cannot do
The issued key is a **scoped child key**.
| Can | Cannot |
|---|---|
| Proxy AI traffic | Create or change budgets, policies, reroutes |
| Read spend, calls, budgets, policies | Manage API keys or the team |
| | Change billing or payment methods |
| | Store provider API keys |
| | Close, reopen or reconcile a month |
This is enforced by the permission layer, not by convention. `POST /api/budgets`
with this key returns `403 permission_denied`. **Do not try to escalate**: if you
need a governance change, propose it to the human.
## If onboarding is unavailable
A `503` with `error_type: onboarding_unavailable` means this deployment cannot
send the approval email, so the flow cannot complete. Fall back to:
> Ask your human to create an account at https://www.spendline.ai/signup and an
> API key at Dashboard → API Keys, then set it as `SPENDLINE_API_KEY`.
Signup may also be invite-gated. That gate is not something this flow routes
around, if the human cannot sign up, tell them to email
**fida@spendline.ai**.
## Anti-patterns
- Creating the account yourself with a generated password. **Never.**
- Polling faster than `interval`, or past `expires_in`.
- Retrying automatically after `denied`.
- Continuing the integration as though a key was issued when it was not.
- Putting the key in a URL or query string. Spendline rejects those requests.