API reference
HTTP endpoints for resources, credentials, projects, and usage.
Base URL: https://extraorbital.dev/api/v1.
Authentication
Identity comes from the credential, never the request body:
| Credential | Looks like | Use |
|---|---|---|
| API key | eo_live_… | CI and long-running agents. Scoped to one team, optionally one project — a project-scoped key uses that project when a request names none, and gets 403 for any other |
| 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 floating-point values |
| 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
Returns identity, active team, plan, and budget information. freeTier is a legacy field name. Prototype credit is a one-time allowance on the person’s default team; additional Prototype teams, and every team on a paid plan, report a zero allowance: paid plans have no free tier.
{
"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 },
"billingMode": "postpaid",
"balanceInCents": null,
"windows": [
{ "interval": "lifetime", "budgetInCents": 500, "spentInCents": 460, "remainingInCents": 40 }
],
"projectCount": 3,
"resourceCount": 5,
"monthToDateInCents": 540,
"warnings": []
}windows lists spending limits across all services. remainingInCents may be negative.
| Billing mode | Spending windows |
|---|---|
| Prototype or sandbox | One lifetime allowance. |
| Paid, postpaid | Monthly budget cap; $500 if no cap is set. |
| Paid, prepaid | Lifetime credit balance, plus a monthly window if capped. |
A first top-up switches a paid team to prepaid billing. Previously invoiced usage is not deducted from the new credit. Exhausted windows block new metered resources with 402. Top up depleted credit or adjust the monthly cap as indicated by the response.
For machine tokens, machine identifies the device and user identifies its linked owner. Unlinked machines use a Prototype sandbox (sbx_{hostId}), with a synthetic sandbox:{hostId} user. Sandboxes cannot be signed into, paid for, invited to, or deleted as ordinary accounts. Their 402 links request account linking.
"machine": {
"hostId": "6a9a9248ccdac03c7cb454bb",
"label": "keen-vole-0a8c",
"linked": true,
"owner": { "userId": "6a73…", "email": "severin@example.com" },
"sandboxTeamId": "sbx_6a9a9248ccdac03c7cb454bb",
"transferredTo": "team_6a73…_default",
"movedProjects": [{ "from": "default", "to": "default-keen-vole-0a8c" }]
}Linking transfers projects, resources, and ledger rows to the owner’s team without changing credentials. Transferred usage counts in its original time window and may make the balance negative. No duplicate debit is created.
Conflicting project IDs become {id}-{label}. movedProjects records renames; transferredTo identifies the destination while linked. The CLI uses both to update .extraorbital.json.
warnings contains budget and inactivity alerts for teams without email delivery, including all sandboxes. whoami, add, and provision print them. Teams receiving email alerts return an empty list.
GET /services
Returns available services, accepted aliases, recognized environment-variable names in credentials, and whether usage is measured in metered.
supply: "provisioned" means a project-specific resource. "shared" means shared credentials, currently Stripe test keys. Display the returned notice. Shared services without configured credentials are omitted.
{
"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
Creates the project, service identity, and resource if needed, then returns credentials. Reusing (team, projectId, service, slug) returns the existing resource.
| Body field | Type | Description |
|---|---|---|
service | string | A service ID or alias from GET /services. Availability depends on the deployment. |
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? | Alternative to separate 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
Lists stored resource records without returning credential values.
| 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 | List the credential records, no secrets |
POST /resources/:service/:slug/credentials/:id/reveal | Return a credential's values (primary for the primary). Audit-logged credential reveal |
POST /resources/:service/:slug/verify | Liveness probe for a provisioned resource. 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": []
}Cost fields:
providerCostInCents: supplier charge.listPriceInCents: price before allowances.chargedInCents: amount billed, as the team's plan bills it. A paid plan is charged the list price from the first unit. On the Free plan it is zero while the allowance covers it; usage a linked machine brought from its sandbox counts as zero there until the team moves to a paid plan, whose next invoice bills it.
Provider cost and list price currently match.
syncedAt records the last sync. Data is not live; partial identifies failed service syncs.
GET /ledger/summary
Returns aggregated ledger data for charts and summaries.
| 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 imports usage for one team and reports imported and corrected counts per service. Corrections preserve the row’s team and project. With the cron secret, it processes all teams across time-limited runs, resuming from the previous position.
Billing
| Route | Purpose |
|---|---|
POST /link | Mint the short-lived URL a human opens. An optional projectId lets the page move that project into a team they already pay for, instead of only selling this team a plan. Returns { url, token, expiresAt } |
GET /link/:token | Public. What the machine asked for, the project it was refused 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 |
POST /billing/topup | Buy prepaid credit with { amountInCents }, from $5.00 to $10,000.00. For an admin of a team on a paid plan. Returns { url }, the hosted checkout; the credit is added when the payment arrives, and the first top-up makes the team prepaid |
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 |
GET /billing/reconcile | Cron only, hourly. Pushes the AI Gateway policy of every team holding a model key again — at the version the gateway already holds where nothing changed, which repairs a ceiling that drifted and refreshes the small models |
GET /billing/reconcile/sweep | Cron only, every minute. The same for the teams whose ceilings cannot wait an hour: keys in two namespaces or in two workspaces, a paid period that has just ended, or a push the gateway did not take |
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 /machines/link · GET /machines/link/:code | A machine asks for the URL that links it to whoever opens it, then polls for the answer |
GET /machines · DELETE /machines/:hostId | The machines linked to the signed-in person, and unlinking one |
POST /device/code · POST /device/token | The device flow behind extraorbital login --human |
GET /snippets?resource=&platform=&action= | Public. Code for a resource on a platform (nextjs, node, python, cloudflare-workers): without action, the setup — install command, variable names, client; with one, that task. { resource, platform, action, install, env, snippets: [{ language, file, code }], actions, notes, docs }. Names variables, never values |
GET /snippets/actions?resource=&platform= · GET /snippets/platforms?resource= | Public. The tasks, and the platforms, there is code for |
POST /feedback | { message, email?, cliVersion?, team? }, signed in or not: behind extraorbital feedback. Stored, then emailed to the ExtraOrbital team. Returns { id, stored, notification }, 201 (200 for the same message resent the same day); 429 past 10 an hour per sender |
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. Have a human open the link below to move this project into a team they already pay for, or to move this team to a paid plan — 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, a key scoped to another team or project, 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 |