# Spendline
> Spendline is a financial control layer for production AI spend. It routes supported AI requests, records attributed costs and applies configured controls, and covers only traffic actually routed through it. Your application supplies the customer, agent, workflow and team labels; configured budgets and policies are checked before a supported request is forwarded.
Spendline is a financial control layer, not an engineering-observability gateway. For supported requests routed through it, strict budgets refuse a request whose estimated cost would exceed a limit (HTTP 402) before it reaches the provider, cost is recorded against the customer / agent / workflow / team labels the application supplies, and the append-only ledger can be closed month by month. Requests sent directly to a provider are outside its controls and records. 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://staging.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://staging.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://staging.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
book a 30-minute call at https://staging.spendline.ai/book/ to start a guided pilot, or accept an invite to their team's existing Spendline account; once they can sign in at https://staging.spendline.ai/ (the "Sign in" button on the homepage), they create an API key at Dashboard → API Keys instead.
**Step 3. Integrate.** Change the provider base URL and add attribution headers.
Both are in the next section. Exact per-SDK code:
https://staging.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://staging.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://staging.spendline.ai/v1`. Set the OpenAI SDK baseURL/base_url to https://staging.spendline.ai/v1. WITH /v1. The SDK appends /chat/completions itself.
- **Anthropic messages**: set the SDK base URL to `https://staging.spendline.ai`. Set the Anthropic SDK baseURL to https://staging.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://staging.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://staging.spendline.ai/agents/index.md): Every agent-facing document.
- [When to use Spendline](https://staging.spendline.ai/agents/when-to-use.md): Intent → capability, and when to pick something else.
- [Quickstart](https://staging.spendline.ai/agents/quickstart.md): Fastest correct integration.
- [For agents](https://staging.spendline.ai/for-agents/): Human-readable version of the same material.
- [Integration instructions](https://staging.spendline.ai/integrate.md): Phase-by-phase migration for a multi-provider codebase.
- [Programmatic onboarding](https://staging.spendline.ai/agents/onboarding.md): How to get a key with human approval, and where a human with no account starts.
- [Connect flow](https://staging.spendline.ai/agents/connect.md): Sent from the dashboard's Connect page? Analyse locally, propose attribution, wait for the human, install, verify.
## Documentation
- [When to use Spendline](https://staging.spendline.ai/agents/when-to-use.md): Intent → capability mapping, and when to choose something else.
- [Quickstart](https://staging.spendline.ai/agents/quickstart.md): Fastest correct integration: base URL, key, attribution, verify.
- [OpenAI integration](https://staging.spendline.ai/agents/openai.md): OpenAI SDK, chat-completions and Responses API.
- [Anthropic integration](https://staging.spendline.ai/agents/anthropic.md): Anthropic SDK and the /v1 base-URL asymmetry.
- [All supported providers](https://staging.spendline.ai/agents/providers.md): Every provider, its request shape and model routing.
- [Attribution headers](https://staging.spendline.ai/agents/attribution.md): Customer, agent, workflow and cost-centre attribution.
- [Budgets](https://staging.spendline.ai/agents/budgets.md): Flat account budgets and pre-spend enforcement.
- [Hierarchical budgets](https://staging.spendline.ai/agents/hierarchical-budgets.md): org → team → agent → customer budget nesting.
- [Policies](https://staging.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://staging.spendline.ai/agents/rerouting.md): Cheaper-model and cross-provider rerouting.
- [Spendline keys and provider keys](https://staging.spendline.ai/agents/keys.md): What each key is, and which are human-only.
- [Verifying the integration](https://staging.spendline.ai/agents/verification.md): Deterministic checks that traffic is flowing and attributed.
- [Troubleshooting](https://staging.spendline.ai/agents/troubleshooting.md): Every error an integrating agent will hit, and the fix.
- [Programmatic onboarding](https://staging.spendline.ai/agents/onboarding.md): How an agent requests a scoped key that a human approves, and where a human with no account starts.
- [Connect flow](https://staging.spendline.ai/agents/connect.md): Analyse the repo locally, propose attribution, wait for the human to confirm in the dashboard, install, verify.
## Machine-readable
- [OpenAPI 3.1 spec](https://staging.spendline.ai/openapi.json): Canonical API contract.
- [Capabilities](https://staging.spendline.ai/agents/capabilities.json): Providers, request shapes, headers, response contract.
- [Intent map](https://staging.spendline.ai/agents/when-to-use.json): Intent → capability selection data.
- [MCP server descriptor](https://staging.spendline.ai/.well-known/mcp.json): Remote MCP endpoint and its tools.
- [Full corpus](https://staging.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.
## How a team starts
- A guided 30-day free pilot, scoped on a 30-minute call. Public self-serve signup is not the way in for new teams; people invited to an existing account keep the invite flow. Book a call: https://staging.spendline.ai/book/
- No public Spendline price. Paid pricing combines a platform fee with a capped charge based on managed AI spend; scope, included features and paid terms are agreed before the pilot starts. There is no Spendline subscription fee during the pilot; model-provider charges still apply.
- https://staging.spendline.ai/pricing/ lists model-provider token rates as reference data. They are not Spendline fees.
## 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 seals a financial period and writes to the audit trail.
## Optional
- [Guides](https://staging.spendline.ai/guides/): Long-form FinOps and unit-economics writing.
- [What is Spendline](https://staging.spendline.ai/what-is-spendline/): Category definition.
- [Compare](https://staging.spendline.ai/compare/): Spendline vs named alternatives (Portkey, Helicone, Langfuse, OpenAI's own usage limits, building in-house).
- [Use cases](https://staging.spendline.ai/use-cases/): One page per intent in "When to use Spendline" above, titled as the question.
- [Model pricing](https://staging.spendline.ai/pricing/): Model-provider token rates. Reference data, not Spendline fees.
- [FAQ](https://staging.spendline.ai/faq/)
- [Security](https://staging.spendline.ai/security/), [Privacy](https://staging.spendline.ai/privacy/), [Terms](https://staging.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 a financial control layer for production AI spend. It routes
supported AI requests, records attributed costs and applies configured controls,
and covers only traffic actually routed through it. The application supplies the
customer, agent, workflow and team labels; configured budgets and policies are
checked before a supported request is forwarded.
New teams start with a guided 30-day free pilot scoped on a call
(). There is no public Spendline price; paid
terms are agreed before the pilot starts.
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) | You need a key, or the user has no Spendline account yet. |
| [connect](https://www.spendline.ai/agents/connect.md) | A human sent you from the dashboard's Connect page: propose attribution, wait for their click, install, verify. |
## 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) | Model block lists, allow-lists and per-model token caps, account-wide or scoped to an agent or customer. |
| [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 a **financial control layer** for production AI spend. It covers
supported requests routed through it; requests sent directly to a provider are
outside its controls and records. 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 each request, set on your server from authenticated request context. |
| "What's my margin after AI cost, per customer?" | margin | Attribute with `x-customer-id`, then enter customer revenue for the same period, manually or as a CSV upload. Where you share revenue data, Spendline reports revenue minus attributed AI cost; other delivery costs are not included. |
| "Prevent this agent exceeding its budget." | hierarchical budgets | Agent-scoped budget in strict mode; a call whose estimated cost would exceed the cap gets HTTP 402. Final cost can exceed the estimate. |
| "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 the providers routed through it. |
| "Restrict which models this workflow can use." | policies | A `model_block` or `model_allow` policy scoped to the workflow's `x-agent-id` (or a `customer_id`), account-wide when scope is omitted; violations get HTTP 403. See [policies](https://www.spendline.ai/agents/policies.md). |
| "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 plus month close (open → closed → reconciled), run by a human owner in the dashboard. A closed month's sealed total never changes. |
| "Reconcile our AI spend against the provider invoice." | month close | An owner uploads the invoice CSV; its total is compared with the month's corrected total and any difference is filed as one `ledger_adjustments` row. Totals only, no line-item matching. Corrections are new rows; originals are never mutated. Not an agent call. |
| "Alert me when an agent burns money unexpectedly." | alerting | Hourly runaway-spend anomaly checks and budget burn-rate forecasts, delivered to a Slack or Discord webhook. No email delivery. |
| "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 also enforce spend: LiteLLM supports cost attribution and pre-call budget enforcement, Cloudflare ships a dollar cap per scope. If the existing gateway already answers the team's cost and budget questions, Spendline may not be necessary. |
| 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 already
supports cost attribution and budget enforcement, including pre-call
reservations. Choose Spendline when the team needs customer cost compared with
revenue, where it shares revenue data, and budget decisions finance can review,
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:
customer cost and margin after AI cost where revenue is shared, and a month
close, not infra metrics.
**Observability tools**: they observe. Spendline *controls*: a strict budget
refuses a supported request whose estimated cost would exceed it (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
import uuid
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
"Idempotency-Key": str(uuid.uuid4()), # per logical 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
"Idempotency-Key": crypto.randomUUID(), // per logical 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 "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-5.6","messages":[{"role":"user","content":"hello"}]}'
```
**`Idempotency-Key` is required** on every proxied request to the hosted
service (a request without one is refused with `400 idempotency_key_required`
before anything is sent to the provider). Generate one value (a UUID) per
logical request **before the first attempt** and reuse it on every retry of
that request. The SDK's own automatic retries reuse the headers of the call, so
generating it in the call, as below, is correct. If you write your own retry
loop, create the key outside the loop: a fresh key per attempt makes each retry
a separate purchase.
## 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. |
| 400 `idempotency_key_required` | No `Idempotency-Key` was sent | Add one per logical request, reused on retries. |
| 409 `duplicate_request` | That `Idempotency-Key` already completed | Do not resend under a new key; the work was already done and recorded. |
| 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. A request sent
directly to the provider is outside Spendline's budget checks and cost records,
so a fallback call is neither controlled nor attributed.
## 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
import uuid
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
"Idempotency-Key": str(uuid.uuid4()), # per logical 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, "Idempotency-Key": crypto.randomUUID() } },
);
```
**`Idempotency-Key` is required** on every proxied request to the hosted
service (a request without one is refused with `400 idempotency_key_required`
before anything is sent to the provider). Generate one value (a UUID) per
logical request **before the first attempt** and reuse it on every retry of
that request. The SDK's own automatic retries reuse the headers of the call, so
generating it in the call, as below, is correct. If you write your own retry
loop, create the key outside the loop: a fresh key per attempt makes each retry
a separate purchase.
## 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, "Idempotency-Key": str(uuid.uuid4())},
)
```
## 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. |
The Anthropic SDK sets the provider key for you from `apiKey` / `api_key`:
version 0.x sends it in `x-api-key`, version 1.x sends it as
`Authorization: Bearer`. Spendline accepts either on `/v1/messages` and forwards
it to Anthropic in `x-api-key`. If both are sent, `x-api-key` is used.
Because Anthropic already owns `x-api-key`, the Spendline key **must** travel in
`x-spendline-key`. A Spendline key placed in `x-api-key` on `/v1/messages` is
refused with 401 and is never forwarded to Anthropic.
## Python
```python
import os
import uuid
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, # per request
"Idempotency-Key": str(uuid.uuid4()), # per logical request
},
)
```
## 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, "Idempotency-Key": crypto.randomUUID() } },
);
```
**`Idempotency-Key` is required** on every proxied request to the hosted
service (a request without one is refused with `400 idempotency_key_required`
before anything is sent to the provider). Generate one value (a UUID) per
logical request **before the first attempt** and reuse it on every retry of
that request. The SDK's own automatic retries reuse the headers of the call, so
generating it in the call, as below, is correct. If you write your own retry
loop, create the key outside the loop: a fresh key per attempt makes each retry
a separate purchase.
## 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 "Idempotency-Key: $(uuidgen)" \
-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
each proxied request. Requests sent directly to a provider are outside them.
| 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. It compares the
limit with recorded spend, in-flight reservations and an estimate of this
request's cost (from `max_tokens`, or a default output allowance when it is
unset). A blocked call:
- returns **HTTP 402**,
- never reaches the provider,
- costs nothing.
The estimate is not the final cost. A response longer than estimated can take
spend past the limit; set `max_tokens` to keep the estimate close. If Spendline
cannot confirm current spend it refuses with **HTTP 503** rather than guessing.
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. Each proxied request is checked against every scope it
belongs to, and the **tightest** applicable limit wins. The check uses an
estimate of the request's cost; final cost can exceed it.
```
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 | Getting a Spendline account (a guided pilot booked on a call, or an invite) |
| 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 your human has no Spendline account
The approval step needs someone who can already sign in to a Spendline account
(see Step 1). New teams start with a guided 30-day free pilot, scoped on a
30-minute call. Tell your human:
> Book a 30-minute call at https://www.spendline.ai/book/ to start a guided
> pilot, or, if your team already uses Spendline, ask a teammate to invite you.
> Once you can sign in at https://www.spendline.ai/ (the "Sign in" button on
> the homepage), create an API key at Dashboard → API Keys, or approve my
> onboarding request.
There is no public Spendline price and nothing for an agent to buy. Paid terms
are agreed before a pilot starts. If the human prefers email:
**fida@spendline.ai**.
## 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 book a 30-minute call at https://www.spendline.ai/book/ to
> start a guided pilot, or accept an invite to their team's existing Spendline
> account; once they can sign in at https://www.spendline.ai/ (the "Sign in"
> button on the homepage), they create an API key at Dashboard → API Keys,
> then set it as `SPENDLINE_API_KEY`.
That is not something this flow routes around: if the human cannot get access,
tell them to email **fida@spendline.ai**.
## Anti-patterns
- Creating the account yourself with a generated password. **Never.**
- Treating a sign-up form as the way in for a new team. It is a booked call.
- 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.
---
# Connect flow: analyse, propose, wait for the human, install, verify
You were sent here from the **Connect Spendline** page in a human's dashboard.
They chose you (Cursor, Claude Code, or another coding agent) to set Spendline
up for them. The promise that page makes is that they will **not** read API
documentation, edit code by hand, learn Spendline's headers, or decide up front
how spend should be attributed. You do the technical work in their existing
development environment; Spendline shows them your recommendation; they only
confirm.
Work through the steps **in order**. Step 3 blocks on a human click. Do not
skip it, and do not change a line of application code before it clears.
Base URL: `https://www.spendline.ai` unless the human gave you a pilot URL.
Below, `` means that URL.
## Step 0. Confirm this is the hosted proxy
Read Phase 0 of first. If the human has
said "in-network", "on-premise", "prompts must not leave our infrastructure", or
handed you a `.tgz` or a `SPENDLINE_KEY` (not `SPENDLINE_API_KEY`), this is the
wrong flow: **stop and tell them**. The in-network deployment is not installed
from here, and in it Spendline never sees the prompts. Do not improvise a hybrid.
## Step 1. Credential, without seeing it
You need `SPENDLINE_API_KEY` in the application's `.env`. Check whether it is
already there. If it is, move on: you never need to read its value.
If it is not:
- Tell the human the Connect page has a **Create installation key** button that
copies a ready `.env` line to their clipboard. Ask them to paste it into
`.env` and tell you when done.
- Or start the device flow in :
you request access, they approve in their browser, and a scoped key is issued
to you exactly once. Write it straight to `.env`.
Never ask the human to paste a key into chat. If they do anyway, tell them to
rotate it in Dashboard → API Keys. Confirm `.env` is in `.gitignore`.
Everything below authenticates with the header `x-spendline-key: $SPENDLINE_API_KEY`.
## Step 2. Analyse the repository, then propose
The analysis happens **in the human's development environment**, on their
machine or their existing coding-agent sandbox. Spendline never receives or
stores source code, prompts, or responses as part of this flow. What you send
in Step 2 is a handful of identifier names.
Find, and keep notes on:
- every place an AI provider is called: SDK constructors (`new OpenAI(`,
`Anthropic(`, `AsyncOpenAI`, …), raw HTTP to a provider host, and wrappers
such as LangChain, LlamaIndex, LiteLLM and the Vercel AI SDK. The full list
of hosts and shapes is in Phase 1.
- which providers and SDKs those are. Spendline proxies the ten providers in
; anything else stays as-is.
- the value that identifies the **customer** at each call site: a tenant,
workspace, organisation, account or user id that is available on the request
or job context. This is the one that must vary **per request**.
- the **agent**: a stable name for the bot, feature or service making the call
(`support-agent`, `doc-summarizer`). Often the module or service name.
- the **workflow**: a route name, job type, ticket type or pipeline stage that
groups a multi-step run.
- the **team / cost centre**: a team, department or product-area value if the
codebase has one (config, ownership file, service metadata).
- the **environment**: usually `process.env.NODE_ENV`, `ENV`, `APP_ENV` or a
deployment stage variable.
Then propose. One request, identifier names only:
```bash
curl -X POST /api/connect/plan \
-H "x-spendline-key: $SPENDLINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"agent_tool": "claude-code",
"repository_label": "acme-api",
"providers": ["openai", "anthropic"],
"sdks": ["openai-node", "@anthropic-ai/sdk", "langchain"],
"call_sites": 7,
"mappings": {
"customer": { "source": "workspace.id", "kind": "expression", "confidence": "high", "why": "Every request handler has req.workspace; billing already keys on it." },
"agent": { "source": "ticket-triage", "kind": "literal", "confidence": "high", "why": "One service, one name. Two features would be two agents." },
"workflow": { "source": "route.name", "kind": "expression", "confidence": "medium", "why": "Routes are the unit your team already discusses cost in." },
"team": { "confidence": "unknown", "question": "Which team should AI spend from this service be charged to?" },
"environment": { "source": "process.env.NODE_ENV", "kind": "expression", "confidence": "high" }
}
}'
```
Rules the server enforces, so read them before you send:
| Rule | Why |
|---|---|
| `agent_tool` is `cursor`, `claude-code` or `other`. | Shown to the human as "Cursor found…". |
| `providers` are keys from `/agents/providers.md`. An unknown one is refused with the valid list. | Never advertise a provider Spendline cannot proxy. |
| A mapping `source` is **one identifier-like expression** (`workspace.id`), or a fixed word value when it genuinely never changes (`support`). Max 120 characters, no newlines, braces or semicolons. | This channel carries names, not code. Anything else is refused. |
| Every confident mapping states `kind`: **`expression`** (read from the app at runtime, every call) or **`literal`** (a fixed value that never changes). Required — the server will not guess it, and refuses a `source` that does not match the `kind` you declared. | `"tenant"` is a valid word AND a valid identifier, so the string cannot say which it is. Installing an expression as a literal hardcodes one customer's id onto every call, which looks fine in the diff and is wrong in the ledger. |
| A mapping you cannot determine **confidently** has `"confidence": "unknown"`, **no** `source`, and a one-line business-language `question`. | **Never invent a mapping.** The page asks the human your question in plain words. |
| `customer`, `agent` and `team` are required keys (as confident or unknown). `workflow` and `environment` are optional. | These are what attribution enforcement requires on the wire. |
A `201` means the page just updated live. A `400` tells you exactly which field
to fix; fix it and resend. Sending again replaces your earlier proposal.
Tell the human, in one sentence, that your recommendation is waiting in the
Spendline tab. Do **not** paste the raw plan into chat as a wall of JSON.
## Step 3. Wait for the human's decision
```bash
curl /api/connect/plan -H "x-spendline-key: $SPENDLINE_API_KEY"
```
Every 5 seconds, for up to 15 minutes.
| `plan.status` | Meaning | What to do |
|---|---|---|
| `proposed` | Not decided yet | Keep polling. **Change no code.** |
| `confirmed` | The human clicked **Use this setup** or edited and confirmed | Install using `plan.decision.mappings`, which may differ from what you proposed. Respect their edits exactly. |
| `discarded` | The human started over | Stop. Ask how they want to proceed. |
| `plan: null` | Expired (24h) or never sent | 200, not an error. Re-run Step 2 if they still want to proceed. |
If the human answers one of your questions in the dashboard, the answer arrives
here as a confident mapping with the value they typed. Use it.
## Step 4. Install
Report progress first, then work:
```bash
curl -X POST /api/connect/plan/stage \
-H "x-spendline-key: $SPENDLINE_API_KEY" -H "Content-Type: application/json" \
-d '{"stage":"installing"}'
```
Then follow Phases 1 to 5 with these
constraints, which are the promises the Connect page made:
1. Audit every relevant provider call before editing anything.
2. Route only the supported shapes through Spendline (chat-completions,
Anthropic messages, OpenAI Responses). Leave embeddings, image and audio
generation and Google's native `generateContent` as they are.
3. Handle wrappers where they expose base URL and headers (LangChain, Vercel
AI SDK, …). If a wrapper cannot carry per-request headers, say so in your
report rather than forcing it.
4. Make the **smallest safe change**: prefer one shared client factory only if
that refactor is small in this codebase.
5. Authenticate with `x-spendline-key` read from `SPENDLINE_API_KEY`. Put
`SPENDLINE_URL` and a `SPENDLINE_ENABLED` flag in env config too.
6. Attribution values come from the **confirmed mappings**, and each mapping's
`kind` says how to install it: `expression` is **evaluated at runtime on
every request** (`x-customer-id` from `workspace.id` means reading
`workspace.id` per call, never the value you saw once); `literal` is the
fixed string itself. Send `x-customer-id`, `x-agent-id`, `x-workflow-id`
where present, and `x-spendline-tags` as JSON carrying `team` and `env`.
**Never hardcode a customer id** or any other `expression` mapping. The
human may have edited both the source and its kind — respect what
`decision.mappings` says, not what you proposed.
7. Preserve existing provider credentials and behaviour. Provider keys stay
where they are; Spendline forwards them.
8. No secrets in source code. Env only.
9. Send an `Idempotency-Key` per logical request, created before the first
attempt and reused on retries, as integrate.md Phase 4 describes.
10. Run the project's tests, type checks and build. Fix what you broke.
## Step 5. Verify, and confirm Spendline saw it
```bash
curl -X POST /api/connect/plan/stage \
-H "x-spendline-key: $SPENDLINE_API_KEY" -H "Content-Type: application/json" \
-d '{"stage":"verifying"}'
```
The page now reads **Waiting for your first AI call…**. Make one safe, cheap
real request through the integrated code path per provider (a short prompt,
a small `max_tokens`), with real attribution values, not placeholders like
`test-user`. Then check it landed:
```bash
curl "/api/calls?page=1" -H "x-spendline-key: $SPENDLINE_API_KEY"
```
The newest row should carry your model, `customer_id`, `agent_id`, and tags
with `team` and `env`. The page turns to **You're live ✓** on its own and shows
what Spendline detected; the human does not refresh anything.
If the call failed or attribution is incomplete, diagnose from
and fix it yourself:
a `401` is the key, a `400 missing_required_attribution` names the missing
field, a `402` is a deliberate budget block and must not be retried around.
Ask the human only when something genuinely cannot be determined safely, and
ask it as a business question, not a header name.
When the row is right:
```bash
curl -X POST /api/connect/plan/stage \
-H "x-spendline-key: $SPENDLINE_API_KEY" -H "Content-Type: application/json" \
-d '{"stage":"done"}'
```
## Step 6. Report
Finish with a short summary for the human: the call sites you routed, the ones
you left alone and why, the env vars you added, and the verification result per
provider. Keep the tone of the page: they chose you so they would not have to
learn this.