# Logmoth control plane API

Public API reference, snapshot 2026-10-20. API origin: https://control.logmoth.com.
Website and dashboard: https://logmoth.com and https://logmoth.com/dashboard/.

The control plane manages accounts, organizations, instances, keys, and billing.
It does not ingest or query logs. Use the [service API](https://logmoth.com/docs/agent-api.md)
at your instance origin for those operations. Instance API keys do not authenticate
control-plane requests. This is a browser-session API, not a headless provisioning API.

Sign in through the URL returned by `/api/session`; authentication is hosted by WorkOS.
The browser keeps an HttpOnly session cookie. Do not extract cookies or store session
or WorkOS tokens in localStorage. Use returned navigation URLs without rebuilding them.
CORS is limited to configured frontend origins, including https://logmoth.com and the
explicit development origin http://localhost:4321. Arbitrary origins are not supported.

Keep secrets out of URLs, source code, analytics, and logs. Obtain user authorization
before paid provisioning, granting access, or deletion. Do not automatically retry a
create request after a network timeout: it may already have succeeded. Refresh the
instance list first. API error messages should be rendered as text, never HTML.

Prices in examples are snapshots, not a substitute for the live billing response.
See [pricing](https://logmoth.com/docs/pricing.md) for billing units and the difference
between managed charges and the homepage storage estimate.

## Making requests

```js
const API_URL = 'https://control.logmoth.com';

const response = await fetch(`${API_URL}/api/session`, { credentials: 'include' });
const session = await response.json();
```

- **Always set `credentials: 'include'`.** Without it the browser doesn't send the session cookie.
- **Every signed-in `POST` and `DELETE` needs `X-CSRF-Token`**, set to the `csrf_token` value from `GET /api/session`. The token stays the same for the whole session.
- **Request bodies are JSON:** send `Content-Type: application/json`.
- **The browser handles CORS.** It sends `Origin`, and before a `POST` or `DELETE` it sends an `OPTIONS` preflight (cached for 10 minutes). The API approves that preflight only for your frontend's origin; from anywhere else the browser blocks the call, and a `POST` that gets through anyway is answered with `403 forbidden`. CORS covers only `/api/*`; `/auth/*` and `open_url` are browser navigations (see below).
- Responses are JSON and are never cached (`Cache-Control: no-store`).

## Errors

Every error has the same shape:

```json
{ "error": "not_signed_in", "message": "Sign in to continue." }
```

`message` is written for end users and safe to show as text. Branch on `error`, not on `message`.

| Status | `error` | Meaning | Suggested handling |
|---|---|---|---|
| 400 | `invalid_request` | The body is missing, isn't JSON, or has a bad value | Show `message`; it names the field |
| 401 | `not_signed_in` | No valid session | Go to `/` |
| 403 | `forbidden` | Wrong or missing `Origin` or `X-CSRF-Token`, or the user isn't an owner | For CSRF: reload `/api/session` and retry once. If it keeps happening, the API's frontend URL doesn't match your origin. For owner-only actions: show `message` |
| 404 | `not_found` | Route or resource doesn't exist, or the user isn't a member | Show `message` |
| 409 | `no_payment_method` | Creating an instance: the organization has no card on file. Opening the billing portal: billing was never set up (no Stripe customer yet) | Offer "Add payment method" |
| 409 | `instance_limit_reached` | The organization already has 5 instances that aren't deleted | Show `message` |
| 409 | `instance_not_running` | The action needs a running instance | Show `message` |
| 502 | `instance_unavailable` | The instance didn't answer | Show `message`; retry later |
| 503 | `billing_unavailable` | Billing isn't configured on this server | Hide billing actions; `billing_available` is `false` too |
| 503 | `provisioning_unavailable` | This server can't create or delete instances or manage their API keys | Hide those actions; `provisioning_available` is `false` too |
| 405 | `method_not_allowed` | Wrong HTTP method for the route (e.g. `GET /api/sign-out`) | A frontend bug |
| 500 | `internal` | Server-side failure; details go to the server log | Show `message`; retrying later is fine |

`fetch` rejects (throws) on network failures and on responses the browser blocks for CORS reasons. Those have no body to read, so show a generic message.

## Endpoints

### `GET /api/session`

Call this on every page load. It returns `200` (or `500 internal` if the server itself fails).

Signed out:
```json
{
  "signed_in": false,
  "user": null,
  "csrf_token": null,
  "sign_in_url": "https://control.logmoth.com/auth/start",
  "sign_up_url": "https://control.logmoth.com/auth/start?screen_hint=sign-up"
}
```

Signed in:
```json
{
  "signed_in": true,
  "user": {
    "user_id": "usr_4f1c2a9e0b7d36a85c21",
    "email_address": "you@example.com",
    "display_name": "Your Name"
  },
  "csrf_token": "9b0e…64 hex characters…",
  "sign_in_url": "https://control.logmoth.com/auth/start",
  "sign_up_url": "https://control.logmoth.com/auth/start?screen_hint=sign-up"
}
```

- Use `sign_in_url` and `sign_up_url` as the hrefs of your Sign in and Sign up links. They're the same page; `sign_up_url` only opens AuthKit on its sign-up screen.
- `display_name` is the first and last name from WorkOS, or the email address when there's no name.

### `POST /api/sign-out`

Headers: `X-CSRF-Token: <csrf_token>`. No body needed.

`200`:
```json
{ "signed_in": false, "logout_url": "https://auth-api.logmoth.com/user_management/sessions/logout?session_id=…&return_to=https%3A%2F%2Flogmoth.com%2F" }
```

Treat `logout_url` as opaque: when there is no WorkOS session to end, it is simply your `/`.

The Logmoth session is already over. **Then set `window.location.href = logout_url`.** That ends the WorkOS session too, then lands on your `/`. Skipping it would leave the person signed in at WorkOS, and the next Sign in would go straight through without asking which account to use.

Errors: `not_signed_in`, `forbidden`.

### `GET /api/organizations`

The signed-in user's organizations and their instances. Every user has at least one organization, created when they first sign in, with `member_role` `"owner"`.

`200`:
```json
{
  "organizations": [
    {
      "organization_id": "org_8d02f6c1e94b37a05d18",
      "organization_name": "Your Name's organization",
      "member_role": "owner",
      "instances": [
        {
          "instance_id": "ins_fefd4b0b714f546a1328",
          "instance_hostname": "quiet-amber-falcon.logmoth.sh",
          "plan_name": "small",
          "region_name": "us-east-1",
          "instance_state": "running",
          "created_at": "2026-10-20T19:30:32Z",
          "open_url": "https://control.logmoth.com/instances/quiet-amber-falcon.logmoth.sh/open"
        },
        {
          "instance_id": "ins_503b0f886bce736455d9",
          "instance_hostname": "brave-copper-heron.logmoth.sh",
          "plan_name": "medium",
          "region_name": "us-west-1",
          "instance_state": "creating",
          "created_at": "2026-10-20T19:31:02Z",
          "open_url": null
        }
      ]
    }
  ]
}
```

- Organizations come oldest first, and so do the instances within each.
- `plan_name`: `small`, `medium` or `large`. `region_name`: an entry of `regions` from the billing endpoint.
- `instance_state`:
  - `creating`: being set up, usually 3–6 minutes. Poll this endpoint every 10–15 seconds until it changes.
  - `running`: ready; `open_url` is set. Billing starts at this moment.
  - `failed`: setup didn't succeed (it gives up after 45 minutes or 8 failed attempts) and it was never billed. The owner can delete it and create another; until then it still counts toward the 5-instance limit.
  - `deleting`: being torn down. Billing stopped when the delete was requested.

  Deleted instances aren't listed. Show unknown states as they are.
- `open_url` is a complete URL, set only when the instance can be opened. Use it as the href of an "Open" link. Don't build the URL yourself.
- `created_at` is UTC, RFC 3339.
- Errors: `not_signed_in`.

### `GET /api/organizations/{organization_id}/billing`

Billing status for an organization the user belongs to; any member may read it. Plans and prices come from here; don't hard-code them.

`200`:
```json
{
  "organization_id": "org_8d02f6c1e94b37a05d18",
  "billing_available": true,
  "can_manage_billing": true,
  "payment_method_added": false,
  "plans": [
    { "plan_name": "small",  "display_name": "Small",  "instance_type": "m7g.large",   "vcpu_count": 2,  "memory_gib": 8,  "monthly_price_cents": 14000,  "monthly_price": "$140.00" },
    { "plan_name": "medium", "display_name": "Medium", "instance_type": "m7g.2xlarge", "vcpu_count": 8,  "memory_gib": 32, "monthly_price_cents": 55600,  "monthly_price": "$556.00" },
    { "plan_name": "large",  "display_name": "Large",  "instance_type": "m7g.4xlarge", "vcpu_count": 16, "memory_gib": 64, "monthly_price_cents": 111200, "monthly_price": "$1112.00" }
  ],
  "storage_price_per_gb_month": "$0.027",
  "provisioning_available": true,
  "regions": [
    { "region_name": "us-east-1", "display_name": "US East (N. Virginia)" },
    { "region_name": "us-west-1", "display_name": "US West (N. California)" }
  ],
  "default_region_name": "us-east-1",
  "invoices": [
    { "billing_month": "2026-09", "invoice_state": "paid", "total_cents": 42407, "total": "$424.07", "hosted_invoice_url": "https://invoice.stripe.com/i/…" }
  ]
}
```

- **`billing_available: false`** means the server has no Stripe key. Hide the billing actions.
- **`can_manage_billing`** is `true` for organization owners. Only they may add a card, open the portal, or create and delete instances.
- **`provisioning_available: false`** means this server can't create or delete instances. Hide those actions.
- **`regions`** are where instances can run, with the same prices everywhere. Preselect `default_region_name`.
- **How billing works:**
  - Instance time is prorated **to the second**: monthly price × hours running ÷ 730, rounded to the cent per instance.
  - Storage is billed at `storage_price_per_gb_month` from hourly measurements of what a running instance keeps in S3. Data kept after an instance is deleted isn't charged.
  - Both are charged **once a month for the previous month’s usage**, to the saved card. Each invoice covers the previous calendar month (UTC).
- **`invoices`:** newest first, up to 36.
  - `invoice_state` is Stripe's status: `paid`, `open` (payment pending or failed; Stripe retries), `uncollectible` or `void`. It can also be `creating` (being issued) or `no_payment_method` (usage with no card on file).
  - Link `hosted_invoice_url`, when present, as "View invoice".
- **Errors:** `not_signed_in`, `not_found` (not a member).

### `GET /api/organizations/{organization_id}/billing/usage`

Charges so far this month, worked out at the moment of the request, plus a projection to the end of the month. Any member may read it. Poll it as often as a page is open; every minute is plenty.

`200`:
```json
{
  "organization_id": "org_8d02f6c1e94b37a05d18",
  "billing_month": "2026-10",
  "period_starts_at": "2026-10-01T00:00:00Z",
  "period_ends_at": "2026-11-01T00:00:00Z",
  "as_of": "2026-10-20T21:26:13Z",
  "month_to_date_cents": 10989,
  "month_to_date": "$109.89",
  "projected_month_total_cents": 16151,
  "projected_month_total": "$161.51",
  "instances": [
    {
      "instance_hostname": "calm-bright-owl.logmoth.sh",
      "instance_state": "running",
      "plan_name": "small",
      "billing_running": true,
      "billable_hours": 381.44,
      "instance_charge_cents": 7315,
      "latest_stored_bytes": 50000000000,
      "latest_sampled_at": "2026-10-19T03:00:00Z",
      "storage_gb_months": 6.8493,
      "storage_charge_cents": 18,
      "month_to_date_cents": 7333,
      "month_to_date": "$73.33",
      "projected_month_total_cents": 12495,
      "projected_month_total": "$124.95"
    },
    {
      "instance_hostname": "early-lunar-kite.logmoth.sh",
      "instance_state": "deleted",
      "plan_name": "medium",
      "billing_running": false,
      "billable_hours": 48.0,
      "instance_charge_cents": 3656,
      "latest_stored_bytes": null,
      "latest_sampled_at": null,
      "storage_gb_months": 0.0,
      "storage_charge_cents": 0,
      "month_to_date_cents": 3656,
      "month_to_date": "$36.56",
      "projected_month_total_cents": 3656,
      "projected_month_total": "$36.56"
    }
  ]
}
```

- **`plan_name` can be `null`** for a deleted instance that only has storage charges this month.
- **The month is the current calendar month in UTC.** It's what the invoice issued at the start of next month will charge, computed by the same code.
- **`month_to_date`** counts instance time up to `as_of`, to the second, and storage up to the latest hourly sample.
- **`projected_month_total`** assumes everything with `billing_running: true` keeps running on its plan until `period_ends_at`, with storage staying at `latest_stored_bytes`. Present it as an estimate. Anything already stopped contributes only its month-to-date charge.
- **`instances`** lists every instance that was billed this month or still exists, including deleted ones (`instance_state: "deleted"`), sorted by hostname. A new instance can show `0` until it's running.
- **`latest_stored_bytes`** is the most recent hourly measurement of what the instance keeps in S3; `null` if it hasn't been measured yet.
- `billable_hours` has two decimals and `storage_gb_months` four; the `_cents` fields are exact. The organization totals are the sums of the instance totals.
- **Errors:** `not_signed_in`, `not_found` (not a member).

### `POST /api/organizations/{organization_id}/instances`

Creates an instance. Owners only, with a card on file. Headers: `X-CSRF-Token`, `Content-Type: application/json`.

```json
{ "plan_name": "small", "region_name": "us-east-1" }
```

`region_name` is optional and defaults to `default_region_name`.

`201`: the new instance, shaped like an entry of `instances` in `GET /api/organizations`, with `instance_state: "creating"`:
```json
{ "instance_id": "ins_7b443e99b96d42aba111", "instance_hostname": "gilded-proud-satyr.logmoth.sh", "plan_name": "large", "region_name": "us-west-1", "instance_state": "creating", "created_at": "2026-10-20T21:26:13Z", "open_url": null }
```

The hostname is chosen by the server and is never reused. Then poll `GET /api/organizations` until the state is `running`.

Errors:
- `invalid_request`: unknown `plan_name` or `region_name`, or a body that isn't JSON.
- `no_payment_method`: add a card first.
- `instance_limit_reached`: 5 instances per organization.
- `forbidden`: not an owner, or CSRF.
- `not_found`: not a member.
- `billing_unavailable`, `provisioning_unavailable`.

### `DELETE /api/organizations/{organization_id}/instances/{instance_id}`

Deletes an instance. Owners only. Headers: `X-CSRF-Token`. No body.

`202`: the instance with `instance_state: "deleting"`. Billing stops at this moment. The machine, its address and its hostname are removed within a few minutes, after which it disappears from `GET /api/organizations`.

**The instance's stored data is kept for 30 days, then deleted for good.** Say so in the confirmation dialog.

Deleting an instance that's already being deleted, or already deleted, returns `202` with its current state. Errors: `not_signed_in`, `forbidden` (not an owner, or CSRF), `not_found`, `provisioning_unavailable`.

### `POST /api/organizations/{organization_id}/instances/{instance_id}/api-keys`

Creates an API key on a running instance, for sending logs or querying from code. Any member may call it. Headers: `X-CSRF-Token`, `Content-Type: application/json`.

```json
{ "name": "production ingest", "scope": "ingest" }
```

- `name`: 1–100 characters after trimming, no control characters, for the user's own reference.
- `scope`:
  - `ingest`: can send events and read a namespace's attribute names and types (`GET /v1/ns/{namespace}/attributes`), nothing else.
  - `read`: can query, but can't send events.
  - `admin`: can do everything, including deleting data and managing keys.

`201`:
```json
{ "api_key": "lm_…", "key_id": "9f3c2a…", "name": "production ingest", "scope": "ingest", "created_at": "2026-10-20T21:30:00.123456Z" }
```

**`api_key` is shown only this once;** neither the control plane nor the instance can show it again. Display it with a copy button and say so. Use it as `Authorization: Bearer <api_key>` against `https://<instance_hostname>/v1/…`. Keys can be listed and revoked with the endpoints below, or directly on the instance with an `admin`-scope key (`GET /v1/keys`, `DELETE /v1/keys/{key_id}`). The instance UI has no key management.

Key timestamps come from the instance: RFC 3339 with fractional seconds (control-plane timestamps are whole seconds).

Errors:
- `invalid_request`: bad name or scope, or the instance refused it, for example its 1,000-key limit.
- `instance_not_running`.
- `instance_unavailable`: the instance didn't answer or returned an unexpected error.
- `not_signed_in`.
- `forbidden`: CSRF.
- `not_found`.
- `provisioning_unavailable`.

### `GET /api/organizations/{organization_id}/instances/{instance_id}/api-keys`

List key metadata for a running instance. Any organization member may call it using
an authenticated session. No CSRF token is required for this read. Keys are listed oldest first.

`200`:
```json
{
  "keys": [
    {
      "key_id": "0123456789abcdef",
      "name": "production ingest",
      "scope": "ingest",
      "created_at": "2026-10-20T21:30:00Z",
      "last_used_at": null
    }
  ]
}
```

Only metadata is returned; existing key secrets cannot be recovered. `last_used_at`
is an RFC 3339 timestamp or `null` if the key has not been used. The operator key is
not a managed key and is never returned. An instance with no managed keys returns
`{"keys": []}`.

Errors: `not_signed_in`, `not_found` (not a member or no such instance),
`instance_not_running`, `instance_unavailable`, `provisioning_unavailable`.

### `DELETE /api/organizations/{organization_id}/instances/{instance_id}/api-keys/{key_id}`

Revoke a managed API key on a running instance. Any organization member may call it.
Headers: `X-CSRF-Token`. No body. The key ID is the 16-character lowercase hexadecimal
identifier from the list response, not the secret token.

`200`: `{"revoked": true, "key_id": "0123456789abcdef"}`.

Require user confirmation: revocation permanently disables this key and clients using
it will no longer authenticate. Create and deploy a replacement before revoking if
clients still need access. Refresh the key list after success. The key cannot be restored.
If the response is lost, refresh the list before retrying; an absent key is already revoked.

Errors: `not_signed_in`, `forbidden` (Origin or CSRF), `not_found` (including a key that
was already revoked), `instance_not_running`, `instance_unavailable`,
`provisioning_unavailable`.

### `POST /api/organizations/{organization_id}/billing/payment-method`

Owners only. Headers: `X-CSRF-Token`. No body.

`200`: `{ "checkout_url": "https://checkout.stripe.com/c/pay/cs_…" }`. Navigate to it (`window.location.href = checkout_url`).

Stripe's hosted page saves a card **without charging it**. On success it returns the browser to `{API_URL}/billing/payment-method/complete`, which records the card and redirects to your `/dashboard/`; on cancel Stripe returns straight to `/dashboard/`. The dashboard URL carries one of these (if the session ended meanwhile, the browser lands on `/` with no `billing` parameter):
- `?billing=payment_method_added`: the card is now the default for invoices. Reload billing status.
- `?billing=payment_method_cancelled`: the user backed out.
- `?billing=payment_method_failed`: the setup didn't complete. Let them try again.

Adding a card again replaces the default. Errors: `not_signed_in`, `forbidden` (not an owner, or CSRF), `not_found`, `billing_unavailable`.

### `POST /api/organizations/{organization_id}/billing/portal`

Owners only. Headers: `X-CSRF-Token`. No body.

`200`: `{ "portal_url": "https://billing.stripe.com/p/session/…" }`. Navigate to it. It's Stripe's hosted portal for changing the card and seeing every invoice, and it returns to your `/dashboard/`.

Errors: `not_signed_in`, `no_payment_method` (billing was never set up: add a card first), `forbidden`, `not_found`, `billing_unavailable`.

## Browser navigation routes (on the API's origin)

These are not `fetch` calls. They answer with redirects, so use plain links (`<a href>`) or set `window.location`.

| Route | Result |
|---|---|
| `sign_in_url` / `sign_up_url` (`{API_URL}/auth/start`, optionally `?screen_hint=sign-up`) | Goes to WorkOS AuthKit. After sign-in, WorkOS returns the browser to `{API_URL}/auth/callback`, which redirects to the frontend's `/dashboard/` on success or `/?sign_in_error=<code>` on failure. |
| `{API_URL}/billing/payment-method/complete` | Stripe Checkout's success return (see `POST …/billing/payment-method`). Redirects to `/dashboard/?billing=payment_method_added` or `…failed`; signed out: `/`. |
| `open_url` (`{API_URL}/instances/<hostname>/open`) | Redirects to the instance's own UI, already signed in. Signed out: the frontend's `/`. Not a member of that instance's organization, or no such instance: `/dashboard/?error=instance_not_found`. Not running: `/dashboard/?error=instance_not_running`. |

Each instance also links back here: when someone opens an instance's UI while signed out of it, the instance sends them to its `open_url`, which brings them back signed in.

**`sign_in_error` codes** (on `/`):

| Code | Show |
|---|---|
| `cancelled` | Sign-in was cancelled. Try again. |
| `expired` | That sign-in attempt expired. Try again. (The 10-minute sign-in window passed, or the browser lost the cookie.) |
| `unverified_email` | Verify your email address, then sign in again. |
| `failed` | Something went wrong. Try again in a moment. |

**`error` codes** (on `/dashboard/`): `instance_not_found`, `instance_not_running`.

**`billing` codes** (on `/dashboard/`, after Stripe Checkout): `payment_method_added`, `payment_method_cancelled`, `payment_method_failed`.

After showing either message, remove it from the address bar (`history.replaceState(null, '', location.pathname)`), so a reload doesn't show it again.

## Flows

**Page load (every page).** `GET /api/session`.
- `/` when signed in: go to `/dashboard/`.
- `/dashboard/` when signed out: go to `/`.

**Sign in / sign up.**
1. Link to `sign_in_url` / `sign_up_url`.
2. The person signs in on AuthKit.
3. They return signed in on `/dashboard/`, or on `/?sign_in_error=<code>`.

The first sign-in creates their Logmoth account and personal organization.

**Open an instance.** Render an "Open" link with `href = open_url` when it isn't `null`.

**Billing** (per organization, from `/api/organizations`):
1. `GET …/billing` for plans, prices, card status and invoices.
2. If `can_manage_billing` and not `payment_method_added`: show "Add payment method", which calls `POST …/billing/payment-method` and navigates to `checkout_url`.
3. On return, read `?billing=…`, show the message, and strip it from the URL.
4. "Manage billing" calls `POST …/billing/portal` and navigates to `portal_url`.

Creating instances requires `billing_available`, `provisioning_available` and `payment_method_added`.

**Sign out.**
1. `POST /api/sign-out` with `X-CSRF-Token`.
2. `window.location.href = logout_url`.
3. They land on your `/`.


## Current boundaries

There are no control-plane endpoints here for changing instance plans or managing team
membership. Keys can be listed and revoked using the endpoints above. Do not invent endpoints.
