Skip to main content
The management REST API is the same surface the 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

Self-hosted: replace the base with your control plane’s DNS.

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: 409 with error.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.
Replay is what makes a lost create recoverable on the two routes that return a secret exactly once: replaying the original request returns the original secret.
The body has to be identical on the retry, because the key is matched against a fingerprint of it. A field computed fresh each attempt makes every retry a 409: "cycle_anchor_at": new Date().toISOString() is a different body every time. Derive such values from something the first call already produced, for example the created_at of the virtual key you just minted, rather than from the clock at retry time.
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.
Three things are worth knowing before you write against them:
  • Paging has three shapes on purpose. listPage / list_page returns one page and its cursor when you want to drive the walk yourself. list returns the whole set. iterate streams rows and fetches pages behind you, which is what you want over a large window.
  • idempotencyKey / idempotency_key is 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 keys disable (reversible) or revoke (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 answers 409 with error.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.
Filter by your own id instead of storing ours:

Virtual keys

List

Returns the keys visible to your credential: keys scoped to your project, its team, or the whole organization, newest first. Visibility is applied to each page after it is read, so a page of virtual keys can hold fewer rows than 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

Body (only name is required):
  • scopes defaults to the calling project. Team- and org-scoped keys need a scoped API key holding virtualKeys:manage at 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 (requires virtualKeys:manage on 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 with gateway_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 with trace_project_required. A trace_project_id that names a project this organization does not have refuses with gateway_trace_project_unknown rather than quietly resolving to somewhere else.
  • trace_project_id is 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; send trace_project_id on the update to move it, validated the same way create validates it. Sending it as explicit null does 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 with gateway_trace_project_ambiguous when 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 carry null: its traces are dropped and no budget counts its spend, so give it a trace_project_id.
  • trace_project_archived says 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.
  • budget creates 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_mode is one of none (default: no silent failover), fallback_all, or policy (requires routing_policy_id).
  • expires_at is when the key stops serving. Omit it and the key never expires. A date already in the past is refused with virtual_key_expiry_in_past. After the date, requests are refused with virtual_key_expired while status stays active, so read expires_at rather than the status to tell an expired key apart.
Response 201:
The secret field is returned only this once. Persist it immediately.

Get

Response: { "virtual_key": {...} }. No secret.

Spend

Requires 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

Body (all fields optional):
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

Rotate answers { "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

Returns the non-archived budgets in your organization across all seven scope types (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_usd and spent_nano_usd are int64 billionths of a USD, and they are the source of truth; limit_usd and spent_usd are 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: false means spend could not be totalled server-side; do not read spent_usd or spent_nano_usd as real spend in that case.
  • group rows are per-member allowances: limit_usd is what EACH member may spend, spent_usd is the group’s summed spend, and member_count says how many members the allowance currently covers.
  • attributed_user rows are per-person templates: limit_usd is what EACH end user may spend, end_users_seen counts the end users with at least one successful request this period, and end_users_over how many of those are at or over the cap. Read the pair rather than spent_usd, which has no single meaning when one row fans out into one bucket per person.
  • provider_key names the ModelProvider the budget is pinned to; null counts every provider.
  • current_period_started_at and resets_at are computed as you read them, so they always describe the period the budget is actually in. cycle_anchor_at is the instant the cycle rolls from, or null for calendar alignment.

Create

Body:
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

Response: { "budget": {...} }, the same row shape the list returns, including live spent_usd and the current period pair.

Update

Updatable fields: 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

Moves the budget’s period boundary to now and recomputes the next reset (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

Soft archive: preserves ledger history, stops enforcement on new requests. Returns the archived row with 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-request X-LangWatch-Cache header.
The rule shape, the full matcher and action tables, and the evaluation order live on Cache control. What is specific to this API:
  • A create needs at least one matcher. A rule that matches every request has to be declared explicitly, which v1 does not support.
  • matchers and action replace the stored value when provided, rather than merging field by field. Omit them and the stored value is untouched.
  • mode_enum is echoed as a top-level field alongside action.mode, so dashboards can filter on it without parsing the action.
  • GET /cache-rules/:id answers 404 for archived rules; use the audit log to inspect removed ones.
  • DELETE is a soft archive and returns the archived row with 200, not 204, so a script can confirm the archivedAt timestamp.

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, with type as the status class and code as the stable machine name to branch on:
Every code this API answers with, and what to do about each, is on API: Errors.

Audit

Every write emits a row in the platform-wide AuditLog (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:
It carries the whole public REST surface, not only the gateway routes, so one fetch feeds a client generator, a Postman import, or an agent that needs the schemas. Self-hosted: same path on your control plane’s DNS.

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.
Last modified on August 17, 2026