langwatch CLI and LangWatch dashboard use, exposed for scripts, CI pipelines, SDKs, and terraform providers. It runs on the LangWatch control plane (https://app.langwatch.ai), separately from the data-plane gateway (https://gateway.langwatch.ai).
Authentication uses existing LangWatch API tokens (Authorization: Bearer lwp_... or X-Auth-Token: lwp_...). No new token format.
The CLI (
langwatch virtual-keys ...) is built on this API. If you’re scripting from Node or Python, consider using the CLI rather than calling the REST directly, it handles pagination, JSON formatting, and error messages for you.Base
Paging
Every list on this API is cursor-paged.limit defaults to 50 and is capped at 200, and cursor is opaque: pass back the next_cursor you were given, verbatim, and keep going until it comes back null. A full page does not mean there is more, and a short page does not mean there is not: next_cursor is the only end-of-walk signal. A cursor this API did not issue answers 400 with error.code = "invalid_cursor" rather than silently restarting the walk.
Retrying a create safely
The four creates (/virtual-keys, /budgets, /cache-rules, and /api/webhooks/v1/endpoints) accept an Idempotency-Key header, so a client that never learned the outcome can retry without creating a second resource:
- Send the same key and the same body again within 24 hours and you get the original response back, with
X-Idempotent-Replay: true. Nothing is created twice. - Send the same key with a different body and the request is refused:
409witherror.code = "idempotency_error". The key already stands for a different request. - Retry while the first attempt is still running and you also get
409 idempotency_error, so two racing retries cannot both create. - Only successful outcomes are stored. If the create failed, a retry runs it again for real.
- The key is yours to choose, 8 to 255 characters. Scope it to the thing you are creating, not the attempt.
secret.
Keys are scoped to your project on the gateway routes and to your organization on the webhook routes, so two projects can use the same key text without colliding. Stored responses are encrypted at rest and expire after 24 hours.
From the SDKs
Both LangWatch SDKs wrap this API, so you get the paging walk, the error types, and the idempotency plumbing without writing them.virtualKeys / virtual_keys and gatewayBudgets / gateway_budgets cover this page; spendEvents / spend_events and webhooks cover the billing and webhook surfaces.
- Paging has three shapes on purpose.
listPage/list_pagereturns one page and its cursor when you want to drive the walk yourself.listreturns the whole set.iteratestreams rows and fetches pages behind you, which is what you want over a large window. idempotencyKey/idempotency_keyis a per-call option, not a body field, and the replay callback fires when the server answered from a stored receipt. That is how a provisioning job tells “I created it” from “it already existed”.- Retirement is named after what it does. Budgets and webhook endpoints
archive, which keeps their history readable. Virtual keysdisable(reversible) orrevoke(terminal).
Your own ids and metadata
Virtual keys and budgets both take two optional fields on create, so you do not have to keep a mapping table between your system and LangWatch:external_id: your identifier for whatever this resource belongs to, for example your customer id. Unique per organization and resource type. Reusing one answers409witherror.code = "external_id_conflict", which is what makes provisioning safe to retry.metadata: a flat object of string keys and values, echoed back on every read.
Virtual keys
List
limit while the walk still has more to give.
Response:
purpose: "langy" marks a product-managed key (auto-provisioned by LangWatch). Those rows appear in listings but refuse every mutation; filter on purpose when scripting bulk operations.
last_used_at advances when the gateway resolves the key, which it does periodically rather than per request, because it serves traffic from a cached configuration in between. Read it as “this key has been in use recently”, not as the timestamp of the last request. A revoked or disabled key never advances it, since the refusal happens before the bump, so the field is a reliable way to find keys with no recent use.
expires_at is the date the key stops serving, or null for a key that never expires. status stays active past that date, so compare expires_at with the current time to tell an expired key apart.
Create
name is required):
scopesdefaults to the calling project. Team- and org-scoped keys need a scoped API key holdingvirtualKeys:manageat each requested scope; legacy project keys can only mint keys for their own project.- An org- or team-scoped key also needs a place for its traces and spend to land, and has to say where: pass
trace_project_id(requiresvirtualKeys:manageon that project; it is a destination, not a scope, and grants no access to the key). Without it, and without exactly one project scope naming a live project to take it from, creation refuses withgateway_trace_project_ambiguous. The same refusal covers a key scoped to several projects at once. An organization with no other live projects is exempt and the key gets its oldest live governance project, because there is nothing else to name; one with no governance project either refuses withtrace_project_required. Atrace_project_idthat names a project this organization does not have refuses withgateway_trace_project_unknownrather than quietly resolving to somewhere else. trace_project_idis decided once, when the key is written, and stored on it. Changing what the key is scoped to therefore never moves where its traces and spend land; sendtrace_project_idon the update to move it, validated the same way create validates it. Sending it as explicitnulldoes not clear it: it asks for the destination to be worked out again from what the key is now, under the same rules create uses, so it lands on the key’s single project scope when exactly one names a live project, falls back to the organization’s oldest live governance project when there are no other live projects to choose from, and is refused withgateway_trace_project_ambiguouswhen nothing selects a destination and the organization has live projects that could have been named. A key written before this was stored, in an organization that had no governance project to give it, can carrynull: its traces are dropped and no budget counts its spend, so give it atrace_project_id.trace_project_archivedsays the project the key traces into has been deleted. The key goes on sending its traces there, so the data stays whole and reappears if the project is restored, and traffic is never refused for it. Point the key somewhere else if that is not what you want.budgetcreates a budget atomically with the key and manages exactly that row on later updates: the key can never exist without the cap you asked for. Windows:day,week,month.routing_modeis one ofnone(default: no silent failover),fallback_all, orpolicy(requiresrouting_policy_id).expires_atis when the key stops serving. Omit it and the key never expires. A date already in the past is refused withvirtual_key_expiry_in_past. After the date, requests are refused withvirtual_key_expiredwhilestatusstaysactive, so readexpires_atrather than the status to tell an expired key apart.
201:
Get
{ "virtual_key": {...} }. No secret.
Spend
gatewayUsage:view. from and to are epoch milliseconds, both optional, defaulting to the current UTC calendar month. Answers { "virtual_key_id", "spent_usd", "requests", "window" }, with window echoed in the same unit so a response feeds straight back as the next request. It reads the cost path the dashboard reads, so this number and the UI agree by construction.
On deployments without a ClickHouse spend source the endpoint answers 412 with error.code = "spend_source_unavailable" rather than a $0.00 that cannot be told apart from a zero-spend key.
Update
scopes replaces the whole visibility set and requires virtualKeys:manage at every NEW scope, and does not move where the key’s traces and spend land. trace_project_id does: omitting it leaves the destination alone, a value moves it, and explicit null re-resolves it under the create-time rules rather than clearing it. budget: null archives the key’s own cap (spend history is retained); omitting budget leaves it alone. expires_at follows the same three-way rule: omitted leaves the date alone, null clears it, and a date moves it. An expired key accepts this patch like any other, which is how it goes back into service without a new secret.
Rotate, disable, enable, revoke
{ "virtual_key": {...}, "secret": "vk-lw-NEW..." }. The previous secret keeps authenticating for a 24 hour grace window, so in-flight clients roll over without a coordinated deploy.
Disable is the reversible stop (virtualKeys:update): the key’s requests are rejected with the distinct virtual_key_disabled error until it is enabled again. Budgets, scopes, key material, and any running rotation grace stay intact, and enable restores the key exactly as it was.
Revoke is one-way. It also archives the key’s own budgets and discards any rotation grace.
All four are idempotent, audit-logged, propagate to the gateway in seconds, and emit a webhook event. See Virtual Keys.
Budgets
List
organization, team, project, virtual_key, principal, group, attributed_user), newest first, with live spent_usd from the spend ledger. scope_type is an optional comma-separated filter, applied in the query, so limit counts the rows you get back.
Response:
- Money is an integer.
limit_nano_usdandspent_nano_usdare int64 billionths of a USD, and they are the source of truth;limit_usdandspent_usdare decimal strings rendered from them for display. Compare and sum the integers, and round exactly once at the end. Reading the strings back into a float is how a reconciliation drifts by a cent. spend_available: falsemeans spend could not be totalled server-side; do not readspent_usdorspent_nano_usdas real spend in that case.grouprows are per-member allowances:limit_usdis what EACH member may spend,spent_usdis the group’s summed spend, andmember_countsays how many members the allowance currently covers.attributed_userrows are per-person templates:limit_usdis what EACH end user may spend,end_users_seencounts the end users with at least one successful request this period, andend_users_overhow many of those are at or over the cap. Read the pair rather thanspent_usd, which has no single meaning when one row fans out into one bucket per person.provider_keynames the ModelProvider the budget is pinned to;nullcounts every provider.current_period_started_atandresets_atare computed as you read them, so they always describe the period the budget is actually in.cycle_anchor_atis the instant the cycle rolls from, ornullfor calendar alignment.
Create
group budgets track spend per member, which requires the ClickHouse spend ledger; deployments without it answer 400 with error.code = "group_budget_requires_clickhouse". attributed_user budgets are per-end-user templates on an anchor key or project (each distinct end user: this limit per window) and are service-guarded the same way. manual windows accrue until an explicit reset.
cycle_anchor_at moves the period boundary off the calendar and onto your own date, which is how a budget lines up with a billing anniversary. It is rejected on total and manual with error.code = "gateway_budget_cycle_anchor_invalid", because those windows do not cycle, and it is fixed at creation. See Budgets for the clamping rules.
Get
{ "budget": {...} }, the same row shape the list returns, including live spent_usd and the current period pair.
Update
name, description, limit_usd, on_breach, timezone. Scope, window, and cycle_anchor_at are fixed at creation; to move a period boundary, reset it.
Reset period
gatewayBudgets:update; optional JSON body {"reason": "..."} for the audit log). Recorded spend is never mutated: the ledger and every emitted billing event are immutable, so reconciliation is unaffected. On calendar windows this truncates the running period and the next boundary stays calendar; on manual windows the new period stays open until the next reset. For attributed-user templates, end_user_id resets one end-user bucket’s boundary and leaves the template period untouched. Returns the updated budget row.
End-user spend
Per-end-user usage and the caps that apply to it are served on the billing surface with an organization API key rather than here:GET /api/gateway/v1/end-users/:id/spend, permission gatewaySpend:view. It returns a rolling-window rollup plus every applicable attributed-user template at its current-period spend.
Archive
archived_at set.
Provider bindings
Provider credentials, rate limits, and fallback priority live on the platform-wide ModelProvider: use/api/gateway-platform/v1/model-providers or the Advanced (Gateway) tab in the dashboard. Budgets reference providers by ModelProvider id in provider_key. The /providers routes on this API answer 410.
Cache rules
Organization-scoped overrides that modulate cache behaviour for gateway requests. Evaluated first-match-wins by priority descending; a matched rule beats the per-key default but loses to a per-requestX-LangWatch-Cache header.
- A create needs at least one matcher. A rule that matches every request has to be declared explicitly, which v1 does not support.
matchersandactionreplace the stored value when provided, rather than merging field by field. Omit them and the stored value is untouched.mode_enumis echoed as a top-level field alongsideaction.mode, so dashboards can filter on it without parsing the action.GET /cache-rules/:idanswers404for archived rules; use the audit log to inspect removed ones.DELETEis a soft archive and returns the archived row with200, not204, so a script can confirm thearchivedAttimestamp.
Webhooks and billing surfaces
Two sibling REST families complete the platform and are documented on their own pages, because they authenticate with an organization API key and organization-exclusive permissions rather than the credential used above:/api/webhooks/v1/*: endpoint CRUD, secret roll, test fire, delivery log, health, and the emitted-events log. See Webhooks./api/gateway/v1/spend-events,/spend-summaries,/spend-events/replay: the billing reconciliation pull surface. See Billing & spend events.
Errors
All responses follow the OpenAI-compatible envelope, withtype as the status class and code as the stable machine name to branch on:
Audit
Every write emits a row in the platform-wideAuditLog (gateway shape, targetKind, targetId, before, after). Visible under /settings/audit-log with the Source = “Gateway” badge; filter by Target (virtual_key, budget, cache_rule) to scope. Writes via scoped API keys are attributed to the key’s owning user; legacy project keys carry no user, so their writes record the machine principal svc_<projectId>. See Audit log for the full schema, REST export path, and migration note from the v3.0 gateway-only table.
Rate limits
Management endpoints are rate-limited to 100 req/min/token. For bulk operations, use the--format json CLI with xargs -P4 (the CLI sleeps 250 ms between retries on 429).
Shared service layer
This API and the dashboard call the same service layer on the server, so a rule enforced in one is enforced in the other. The only differences are the field naming (snake_case here, camelCase in the app) and how the caller is identified (API credential versus browser session). The refusals are pinned by an integration suite, so the two surfaces cannot drift apart.
OpenAPI
Every route on this API is described in LangWatch’s OpenAPI 3.1 document, served unauthenticated at:See also
- langwatch CLI: the same operations from a shell.
- RBAC: which scopes your token needs.
- Security: how your API tokens and resulting writes are protected.