API reference
Provisioning, resources, ledger, teams and billing — one bearer token, one error shape.
AI/LLM: this page is available in plain markdown at /docs/api.md
Base URL: https://extraorbital.dev/api/v1. Everything the CLI does is one of these
endpoints: extraorbital add is a single POST /provision, and
extraorbital provision is a GET /services to learn the variable names followed by
one POST /provision per resource it decided on.
Authentication
Send a bearer token. Three kinds are accepted, and the caller's identity always comes from the credential, never from the request body:
| Credential | Looks like | Use |
|---|---|---|
| API key | eo_live_… | CI and long-running agents. Scoped to one team, optionally one project |
| Agent token | a short-lived Ed25519 JWT | A machine that registered through the Agent Auth Protocol |
| Session | a cookie, or its token as a bearer | The dashboard, and the CLI after extraorbital login |
curl https://extraorbital.dev/api/v1/me \
-H "Authorization: Bearer $EXTRAORBITAL_TOKEN"Missing or invalid credentials return 401. A team past its Prototype allowance with no human
attached returns 402 with the link URL in both the body and the Location header —
see When a human is needed.
Conventions
| Topic | Rule |
|---|---|
| Team | ?team=<slug or id> on GET/DELETE, team or teamId in the body on POST. Defaults to your default team |
| Resource address | <service>/<slug>, e.g. s3/default. Send service + slug, or the combined resource string. An alias like mongodb is canonicalised to mongo before anything is stored |
| Identifiers | slug and team slugs match ^[a-z0-9][a-z0-9-]{0,62}$; projectId also allows dots, so skills.dev is valid. Both default to default |
| Money | Always integer cents, in fields ending InCents. No floats on the wire |
| Time | ISO 8601 UTC. Ranges are from and to, both inclusive |
| Pagination | Keyset: ?limit= (1–200, default 50) and ?cursor=; responses carry hasMore and nextCursor |
| Errors | Always { "error": { "code", "message", "status" } }, with linkUrl on 402 and retryAfter on 409 |
GET /me
Who the token belongs to, which team it is acting in, and how much of the
Prototype allowance is gone. The freeTier field keeps its name for
compatibility; on a free team usedInCents counts the account's whole history,
because the allowance is granted once rather than every month.
{
"user": { "id": "6a73…", "label": "severin", "email": "severin@example.com" },
"credential": "apiKey",
"machine": "brave-fox-a3f2",
"team": { "teamId": "team_6a73…_default", "slug": "default", "role": "owner", "isDefault": true },
"namespace": "swift-heron-9c1f",
"plan": "prototype",
"humanLinked": false,
"freeTier": { "limitInCents": 500, "usedInCents": 460 },
"projectCount": 3,
"resourceCount": 5,
"monthToDateInCents": 540
}GET /services
The resource catalog, generated from the registry, so a new service becomes available
without a CLI release. credentials is also the vocabulary autopilot scans a directory
for, aliases lists other accepted names, and metered says whether the owning
service measures usage at all.
supply is "provisioned" for everything created for your project alone, and
"shared" for a resource that is one account handed to every team that asks — today
only stripe. A shared entry carries a notice you should
surface rather than swallow, and it is absent from the catalog entirely on a deployment
that holds no account for it.
{
"data": [
{
"service": "s3",
"aliases": [],
"title": "S3 bucket",
"status": "available",
"credentials": ["S3_BUCKET", "S3_ENDPOINT", "S3_REGION", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"],
"options": { "region": { "type": "string", "default": "us-east-1" }, "isPublic": { "type": "boolean", "default": false } },
"freeTier": "1 GB, 10K simple + 2K advanced ops/mo",
"metered": false,
"stateful": true,
"supply": "provisioned"
},
{
"service": "stripe",
"aliases": [],
"title": "Stripe test keys",
"status": "available",
"credentials": ["STRIPE_SECRET_KEY", "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY", "STRIPE_WEBHOOK_SECRET"],
"options": {},
"freeTier": "Shared test account, free on every plan",
"metered": false,
"stateful": false,
"supply": "shared",
"notice": "Shared test account: every ExtraOrbital project provisioning stripe gets these same keys. …"
}
]
}POST /provision
The one endpoint that matters. Creates the project if needed, mints the team's
downstream identity if needed, provisions the resource if needed, and returns
credentials. Idempotent on (team, projectId, service, slug).
| Body field | Type | Description |
|---|---|---|
service | string | s3, mongo (or mongodb), redis, vector, qstash, authlocker (or auth), openai, anthropic, git, stripe |
slug | string? | Instance name inside the project. Default default |
projectId | string? | Default default. Created on first use. May be a domain, e.g. skills.dev |
resource | string? | Sugar for service + slug, e.g. "s3/default" |
team / teamId | string? | Defaults to your default team |
options | object? | Service-specific tuning. Applied at creation, ignored on reuse |
machine | string? | Caller label recorded for attribution |
curl -X POST https://extraorbital.dev/api/v1/provision \
-H "Authorization: Bearer $EXTRAORBITAL_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "projectId": "cosmic-otter", "service": "s3", "slug": "default" }'201 when it created something, 200 when the resource already existed. Both
return the same shape:
{
"teamId": "team_6a73…_default",
"projectId": "cosmic-otter",
"service": "s3",
"slug": "default",
"resource": "s3/default",
"status": "active",
"resourceId": "prv_9c1f77a2b4e8",
"createdBy": "brave-fox-a3f2",
"createdAt": "2026-08-01T09:14:00.000Z",
"metered": false,
"meta": { "bucketName": "cosmic-otter-default", "region": "us-east-1" },
"credentials": [{ "name": "S3_BUCKET", "value": "cosmic-otter-default" }]
}409 PROVISION_IN_PROGRESS means a concurrent call holds the lease — retry with
backoff. 402 PAYMENT_REQUIRED carries linkUrl.
Resources
GET /resources lists from the broker's own mirror, so it is one fast query
rather than a fan-out — and it never returns credentials.
| Route | Purpose |
|---|---|
GET /resources | List. Filters: projectId, service, status |
GET /resources/:service/:slug | One resource, with metadata and month-to-date spend |
GET /resources/:service/:slug/credentials | Decrypt and return credentials. Audit-logged |
POST /resources/:service/:slug/verify | Liveness probe behind extraorbital check. Always 200; branch on ok |
DELETE /resources/:service/:slug | Deprovision. ?force=true skips the grace period |
All take ?projectId=, defaulting to default.
Projects and teams
| Route | Purpose |
|---|---|
GET POST /projects | List with rollups, or create one |
GET PATCH DELETE /projects/:projectId | Detail with resources inlined; rename; delete (?force=true) |
GET POST /teams | List teams with rollups, or create one from a slug and a name |
GET PATCH DELETE /teams/:teamId | Detail, budget cap and alert settings, deletion |
GET POST /teams/:teamId/members | Membership. POST takes { email, role }; an address with no account yet becomes a pending invitation, honoured on its first sign-in |
DELETE /teams/:teamId/members/:userId | Remove a member |
GET /ledger
Line items across every service, served from the broker's synced mirror.
| Query | Default | Description |
|---|---|---|
month | current | yyyy-mm. Shorthand for a from/to pair |
from, to | — | ISO dates, inclusive |
projectId, service, slug, machine | all | Filters |
kind | all | provision or usage |
limit, cursor | 50 | Keyset pagination |
{
"data": [
{
"id": "lg_4b21c9f7e8a3",
"projectId": "cosmic-otter",
"resource": "s3/default",
"machine": "brave-fox-a3f2",
"kind": "usage",
"metric": "storage-gb-month",
"quantity": 105.2,
"providerCostInCents": 184,
"listPriceInCents": 184,
"chargedInCents": 0,
"createdAt": "2026-08-04T00:00:00.000Z"
}
],
"hasMore": false,
"nextCursor": null,
"syncedAt": "2026-08-04T16:31:00.000Z",
"partial": []
}Three cost fields, deliberately: providerCostInCents is what the upstream
provider charges, listPriceInCents is what we would bill, and
chargedInCents is what was actually billed — 0 for everything covered by the
Prototype allowance. The first two match, because usage is passed on at cost;
they are kept separate so a future discount is visible rather than silent.
syncedAt is on every response. The mirror is synced, not live; partial names
any service whose sync failed, so a gap is visible rather than silent.
GET /ledger/summary
The same data aggregated. This single endpoint backs every chart in the dashboard and every graph in the CLI.
| Query | Default | Description |
|---|---|---|
groupBy | project | project, resource, service, machine, day, metric |
interval | — | day or hour. Adds a series array per group for sparklines |
{
"groupBy": "machine",
"from": "2026-08-01T00:00:00.000Z",
"to": "2026-08-31T23:59:59.999Z",
"data": [
{
"group": "brave-fox-a3f2",
"count": 18,
"resources": ["s3/default", "mongo/analytics"],
"metered": true,
"providerCostInCents": 362,
"listPriceInCents": 362,
"chargedInCents": 0,
"series": [{ "t": "2026-08-01", "listPriceInCents": 98 }]
}
],
"total": { "count": 24, "listPriceInCents": 540, "chargedInCents": 40 },
"projection": { "endOfMonthInCents": 589 },
"syncedAt": "2026-08-04T16:31:00.000Z",
"partial": []
}POST /ledger/sync pulls each service's ledger for one team. Called with the
deployment's cron secret it syncs every team, which is the scheduled job.
Billing
| Route | Purpose |
|---|---|
POST /link | Mint the short-lived URL a human opens. Returns { url, token, expiresAt } |
GET /link/:token | Public. What the machine asked for and what the team has spent |
POST /link/:token | Start payment capture. Records the cap, returns a hosted checkout URL |
POST /link/:token/complete | Finish it. Idempotent with the webhook |
GET /invoices | Closed months, plus the current one as a draft |
GET /invoices/:id | One statement with its line items |
POST /invoices/close | Close a period. With the cron secret, every team's previous month |
POST /payments/webhook | The processor's webhook. Signature is the only authentication |
Agents, keys and activity
| Route | Purpose |
|---|---|
GET /agents | Machines that acted in this team, with what they provisioned and spent |
GET /activity | The audit trail: provisioning, credential reads, team changes, alerts |
GET POST /keys · DELETE /keys/:keyId | Manage eo_live_ keys. A key is shown once |
POST /device/code · POST /device/token | The device flow behind extraorbital login |
Agents also have a protocol surface of their own under /api/auth, implementing the
Agent Auth Protocol.
Errors
{
"error": {
"code": "PAYMENT_REQUIRED",
"message": "Your team has spent its Prototype allowance and cannot add vector. Move to a paid plan by having a human open the link below — then you are billed only for what you use.",
"status": 402,
"linkUrl": "https://extraorbital.dev/link/fl_8a2c91d4e7b3"
}
}| Status | Code | When |
|---|---|---|
400 | VALIDATION_ERROR | Malformed projectId, slug, options, or an unknown service |
401 | UNAUTHORIZED | Missing, invalid, or expired credential |
402 | PAYMENT_REQUIRED | Past the Prototype allowance or resource count with no human linked, or a hard budget cap reached. Carries linkUrl |
403 | FORBIDDEN | Not a member of that team, or an agent missing a capability |
404 | NOT_FOUND | Unknown team, project or resource |
409 | PROVISION_IN_PROGRESS | A concurrent provision holds the lease. Retry with backoff |
422 | RESOURCE_IN_USE | Delete attempted while resources remain |
429 | RATE_LIMITED | Too many calls. Respect Retry-After |
502 | UPSTREAM_ERROR | The owning service failed. The resource is left retryable |
503 | ENCRYPTION_UNAVAILABLE | The credential key ring is missing or invalid |