# API reference

> Provisioning, resources, ledger, teams and billing — one bearer token, one error shape.

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](https://agent-auth-protocol.com) |
| Session | a cookie, or its token as a bearer | The dashboard, and the CLI after `extraorbital login` |

```bash
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](/docs/quickstart#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.

```json
{
  "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`](/docs/resources/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.

```json
{
  "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 |

```bash
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:

```json
{
  "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 |

```json
{
  "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 |

```json
{
  "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](https://agent-auth-protocol.com).

## Errors

```json
{
  "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 |

---

More for agents: [Docs index](https://extraorbital.dev/sitemap.md) · [llms.txt](https://extraorbital.dev/llms.txt) · [agents.md](https://extraorbital.dev/agents.md)
