Plane docs
API reference

Overview

The envelope, the two credentials, the tenancy headers, and what /v1 promises.

Plane's public interface is an OpenAPI 3.1 document. Every server serves its own at GET /openapi.json, and it is generated from the contract the server implements — so it cannot describe an endpoint that is not there, or omit one that is. The reference pages on this site are generated from that same document at build time.

One shape

POST /v1/<group>/<method>

agents.create is POST /v1/agents/create. connections.grants.list is POST /v1/connections/grants/list — the group is the first dotted segment and everything after it becomes a path segment. The operationId of every operation is the dotted name, so the document, a generated client and your own logs all join on one key.

Every endpoint is a POST with a JSON body, including the reads. The payloads are nested documents — sessions.list takes a metadata filter, agents.upsert takes a whole agent definition — and a query string cannot carry one without inventing an encoding the schema does not describe. This is RPC over HTTP and says so.

Ten procedures take no payload at all (auth.me, connections.list, providers.list, …). Those endpoints declare no request body, so {}, an empty request and a leftover body from a retry all mean the same thing.

curl https://plane.example.com/v1/sessions/list \
  -H "Authorization: Bearer $PLANE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"limit": 20, "filter": {"metadata": {"customerId": "cus_42"}}}'

The two credentials

Both are advertised in the document and accepted on every endpoint.

An API key, Authorization: Bearer pl_<env>_…. The key is the environment. Everything it reads and writes is scoped to the environment it was minted in, there is no way to point it at another, and the two tenancy headers below are ignored for a key rather than obeyed or refused.

Mint one in the control plane under API keys, with plane keys create, or through POST /v1/apiKeys/create. A fresh database prints a control key at boot.

The control plane's session cookie. A browser that signed in at /auth/login. This is what the control plane itself uses; you would only be here if you are building something that runs in a signed-in browser.

Tenancy headers

A person belongs to organizations rather than to one environment, so a cookie-authenticated request says which tenant it is about.

HeaderMeaning
x-plane-organizationAn organization slug or id. One you are not an active member of is 403 Forbidden{reason:"not_a_member"}, not a 404 — a slug a human typed is not a secret.
x-plane-environmentAn environment slug, resolved inside that organization. One that does not exist or is archived is refused rather than silently resolved to the default.

Both are ignored for an API key.

Scopes

read / data / control, with no implication between them: a control key cannot read unless it also holds read. Every operation in the reference names what it costs, straight from the table the server checks, and it is on the document as x-plane-scope.

ScopeRoughly
readList and get. Agents, sessions, events, traces, usage.
dataRun things. sessions.create, sessions.send, sessions.decide, blobs.upload, previews.mint.
controlChange the configuration. Agents, providers, connections, secrets, environments, API keys.

auth.me costs any: it is how a client discovers what it holds, including a client that holds nothing useful.

Two more extensions ride on every operation: x-plane-rpc (the procedure name) and x-plane-org-level (whether x-plane-environment changes the answer).

A key may additionally be restricted in what identity it may assert — see Identity.

Streaming

sessions.stream is the one procedure with no POST endpoint, because a stream a browser can resume is Server-Sent Events and not a long JSON body.

GET /sessions/{id}/stream

The credential is a stream token, minted by POST /v1/sessions/streamToken. An API key is deliberately not accepted: the URL is meant to be handed to a browser, and the token grants read of exactly one session and nothing else. EventSource cannot set a header, so ?token= works; a fetch reader should use Authorization: Bearer and keep the URL clean.

Reconnect with Last-Event-ID. The whole design is Streaming to a browser.

Not in /v1

RouteWhy
GET /sessions/{id}/streamThe SSE stream. It carries a stream token, not an API key. It is in the document, at that path.
/auth/login, /auth/callback, /auth/logout, /oauth/callbackBrowser redirects. A client that called them with a credential would get HTML.
/.well-known/plane/jwks.json, /.well-known/plane/oauth-client.json, /agent-definition.schema.jsonPublic documents, fetched by verifiers and editors.
/sandbox/connect, /webhooks/workosInbound: a sandbox dialling out to Plane, and a delivery from WorkOS.
/healthz, /openapi.jsonA probe and the contract itself.

What /v1 promises

  • Additive changes land in /v1: a new endpoint, a new optional request field, a new response field, a new error tag. A client that ignores what it does not know keeps working.
  • A breaking change mints /v2: removing an endpoint or a field, making an optional field required, changing a field's type, or changing what an error tag means. The two would be served side by side.
  • info.version is the document's version and moves with any change, additive included. It is not the path prefix and gates nothing.

Generating your own client

The document is the whole input.

# TypeScript — types only, plus ~2 kB of transport
npx openapi-typescript https://plane.example.com/openapi.json -o openapi.ts
npm i openapi-fetch

# Python
uvx --from openapi-python-client openapi-python-client generate \
  --url https://plane.example.com/openapi.json

@plane/sdk's own transport is generated exactly this way — see the SDK. Any generator that reads OpenAPI 3.1 works the same.

On this page