# API reference

Base URL https://api.lakehousebox.com. JSON in, JSON out. Every failure is {"error": {"code", "message", …}} with, where it helps, remedy, field, limit, current, available, retry_after_seconds or attempts_left, and always a request_id to quote when writing to us; unexpected failures are 500 with the same id. When the caller is an lhbox older than the one published (it says so in User-Agent: lhbox/<version> …), a refusal (a 4xx: typed errors, and the router's own 404/405 beside its detail) also carries cli_outdated {version, published, remedy}: an option or route the docs describe may simply be newer than that CLI. A deployment may refuse versions below a minimum: 426 cli_too_old with the same remedy. Request bodies are limited to 1 MB.

## Authentication

Send Authorization: Bearer al_live_… with the API key itself, or a token from POST /v1/tokens (RS256, 900 s), or a token from an identity provider the deployment trusts. There are three kinds of principal: humans (created by signup), agents (created by an organisation admin, optionally confined to warehouses) and the operator. Keys look like al_live_<key id>_<secret>; only the hash is stored; ten wrong secrets for one key id from one address within ten minutes answer 429 with Retry-After.

A person whose address an organisation's identity provider covers, while that organisation enforces single sign-on, reaches every route other than the sign-in ones (/v1/auth/…) only with a session the provider made or the provider's own token: any other browser session, or another issuer's token, answers 403 sso_required (naming the provider) everywhere else, and the organisation's break-glass admin's password-and-code session answers 403 break_glass_restricted outside the routes that repair sign-in (its identity provider, its domains, its member list). On a deployment with the ENS security profile a covered address whose organisation does not enforce gets 403 password_login_disabled the same way (an enforcing one still answers sso_required, and the break-glass is no exemption there). API keys and tokens are not sign-ins and are never refused this way.

Words: a catalog in the account page, the CLI and these docs is a warehouse in the API (/v1/warehouses, warehouse_id, my_level). Catalog aliases (since 2026-09-25, additive; the warehouse names keep working): every /v1/warehouses… path also answers as /v1/catalogs…; the query parameters catalog_id, catalog_ids and catalog stand for warehouse_id, warehouse_ids and warehouse; a JSON body (Content-Type: application/json with a Content-Length, at most 1 MB) may send catalog_id / catalog_ids at its top level and in the items of a top-level grants list; JSON answers of at most 8 MB that carry warehouse_id also carry catalog_id with the same value. Where an answer's warehouse is a storage location (a catalog, GET /v1/connection) it also carries catalog_location; where it is the catalog's name (the read routes GET /v1/namespaces, /v1/tables, /v1/table), catalog. An explicit warehouse_* always wins. Not aliased: the routes that carry your data (POST /v1/query, GET /v1/object) and every sink route except creating one (POST /v1/sinks, top level only), whose bodies and answers pass exactly as sent: filter the sink list with GET /v1/sinks?warehouse=, not ?catalog= (sink answers still carry catalog_id).

The Iceberg catalog at https://catalog.lakehousebox.com and the object store at https://s3.lakehousebox.com are separate surfaces with their own credential, the catalog's catalog_credential from GET /v1/connection. They never accept LakehouseBox API keys.

## Idempotency

POST /v1/projects, /v1/warehouses and /v1/tables take an Idempotency-Key header (any string). A replay returns the original response; the same key on a different endpoint is 409 idempotency_key_reused.

## Identifiers

Wherever a project_id or warehouse_id is expected, the name you chose is accepted too; an unknown value is a 404 that names both accepted forms and the command that lists them. Two warehouses with one name in different projects make the name ambiguous (409 ambiguous_name with candidates).

## Health and identity

### GET /healthz → 200

Liveness: the state database answers and the catalog answers /v1/config.

  Auth:  none

  Response:  ok, catalog_ok, issuer, kid

### GET /jwks.json → 200

The RS256 public key that verifies tokens from POST /v1/tokens.

  Auth:  none

  Response:  keys[{kty, use, alg, kid, n, e}]

### POST /v1/tokens → 200

Exchange an API key for a short-lived bearer token for THIS API. Optional: every authenticated route also accepts the API key itself as the bearer. The Iceberg catalog never accepts either; it takes the warehouse's catalog_credential.

  Auth:  none

  Request:  {"api_key": "al_live_…"}

  Response:  access_token (JWT, dies with the key: once the key is revoked the token is 401 key_revoked on every route), token_type: Bearer, expires_in: 900, principal_id

  Errors:  403 mfa_enrolment_required | owner_mfa_enrolment_required (a deployment that requires two-factor: the key's person has no second factor yet), 401 invalid_api_key, 401 api_key_revoked, 401 account_deleted (the account was deleted; WWW-Authenticate: Bearer error="invalid_token"), 403 principal_suspended, 429 rate_limited after 10 wrong secrets for one key id within 600 s (Retry-After header)

## Signup and recovery

### POST /v1/signup/start → 202

Step 1 of 2 of the email-code signup: a six-digit code is mailed to the address; nothing is created yet. The signup FORM sends full_name, organization_name and handle too: the organisation is created pending (its handle reserved) and verify activates it. An address with a pending invitation joins the inviting organisation instead (will_join). An email domain never places anyone: an organisation that verified and opened the address's domain is only offered afterwards (domain_offers; POST /v1/orgs/{org_id}/join). While new accounts are admitted in batches (an hourly or total cap the operator sets, or the store short of room), a NEW address is put on the waitlist instead: 202 {email, waitlisted: true, message, next}, no code and no mail; the address gets the set-password mail when its turn comes. Existing accounts and invitations never wait.

  Auth:  none

  Request:  {"email": "you@example.com", "workspace"?: "acme", "full_name"?: "Ana Lopez", "organization_name"?: "Acme Labs", "handle"?: "acme-labs"}

  Response:  email, workspace, delivery (email|log), organization{org_id, name, handle, status: pending} (form), will_join{org_id, name, handle, role, via: invitation} (an open invitation), terms{version, url, privacy_url, note}, next, note, expires_in (600 s), code_length (6)

  Errors:  403 registration_closed (self-hosted, registration closed: the deployment creates no account by self-registration; an address holding an open invitation goes on; remedy names the ways in), 400 invalid_value (email; handle: reason invalid|starts_with_digit|too_long|double_dash|reserved|sql_word, suggestion), 400 disposable_email (an address at a throwaway mailbox service such as mailinator or yopmail; an invited address is not refused), 409 handle_taken (suggestion), 409 already_exists (the address has an account: connect the machine with `lhbox login`, or reset the password at /app/forgot), 429 rate_limited (5 starts per hour per address; or a code less than 60 s old, with retry_after_seconds), 502 mail_error

  CLI:  lhbox signup --email …

### POST /v1/agent/identity → 201

An agent with no account and no person (a deployment setting turns it on): an UNCLAIMED organisation, its catalog `lakehouse` and a key with write on it, answered once. Sending the request accepts the Terms of Service and the Acceptable Use Policy. Nobody owns it: it is deleted with its data at expires_at (72 h) unless a person claims it; until then 1 GB, one catalog, nothing public, and its tokens change data only (any other write is 403 claim_required). Its fields include auth.md's anonymous registration (registration_id, pre_claim_scopes, post_claim_scopes).

  Auth:  none

  Request:  (no body) or {"type": "anonymous", "client"?, "hostname"?, "client_name"?, "campaign"?: {source?, medium?, campaign?, content?}} (campaign: the tags of the page the command came from, each 1-64 of letters, digits, ".", "_", "-")

  Response:  api_key (once), key_id, kind: token, type: anonymous, registration_id, organization{org_id, name, handle: null, status: unclaimed}, expires_at, connection{token_id, name, org_id, warehouses[{warehouse_id, name, level: write}]}, catalog{warehouse_id, name, bucket}, limits, pre_claim_scopes, post_claim_scopes, terms{version, accepted: true, url, privacy_url}, how_to_use, next

  Errors:  403 registration_closed (self-hosted, registration closed: the deployment creates no account by self-registration; it also closes agent signup; remedy names the ways in), 400 service_auth_not_enabled|issuer_not_enabled|invalid_value (type; campaign or campaign.<key>), 403 anonymous_not_enabled, 429 rate_limited (5 an hour and 20 a day per address, 200 a day for the service, or the store short of room; Retry-After)

  CLI:  lhbox new

### DELETE /v1/agent/identity → 200

The agent ends what `lhbox new` started, before the expiry: the unclaimed organisation, its catalog with every table and file, its tokens and keys, deleted now, as the expiry would at expires_at. For a key that leaked, or a catalog nobody needs. Only while nobody has claimed it; a claim that lands first keeps it.

  Auth:  Authorization: Bearer <API key or token> — a token of an unclaimed organisation

  Request:  {"confirm": "<org_id>"} (GET /v1/agent/status names it)

  Response:  discarded: true, org_id, catalogs[{warehouse_id, name}], note

  Errors:  400 confirmation_required (org_id: the value to send), 409 not_unclaimed (a person owns it: an admin deletes it), 409 delete_in_progress (retry_after_seconds)

  CLI:  lhbox discard

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/agent/identity/claim → 200

A person keeps what an agent made without an account: a device code bound to the unclaimed organisation. The person opens verification_uri_complete, signs up or logs in, types the code and keeps the catalog in a new organisation (with a handle) or one they administer; the agent polls POST /v1/auth/device/token with device_code and receives a NEW key at the level the person chose. Every pre-claim key stops working at the claim, and the catalog keys the agent fetched are rotated. A new claim cancels the earlier pending one; the code lives 24 h, never past the organisation's expiry. Called with an MCP access token (an account a client set up) the answer has no device_code and no interval: no key comes from it; the connection ends at the claim and the person reconnects it, signed in.

  Auth:  Authorization: Bearer <API key or token> — a token of an unclaimed organisation

  Request:  (no body)

  Response:  device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval, claim{org_id, expires_at, catalogs[]}, note

  Errors:  401 unclaimed_expired, 409 not_unclaimed (the key belongs to an account), 409 claim_gone, 429 rate_limited

  CLI:  lhbox claim

  Scope:  catalogs:write (an MCP access token; an API key has no scopes)

  MCP tool:  keep_account

### GET /v1/agent/status → 200

Whether the caller's organisation is still unclaimed (made by an agent without an account) and the time it has left before it is deleted with its data.

  Auth:  Authorization: Bearer <API key or token>

  Response:  unclaimed (false for every other account), org_id; when unclaimed: status, expires_at, seconds_left, limits, deleting (an earlier discard stopped half-way: `lhbox discard --yes` retries it), note

  CLI:  lhbox status

  Scope:  account:read (an MCP access token; an API key has no scopes)

### GET /v1/handles/{handle} → 200

Is this organisation handle free? For the signup form as you type (per-address rate limit). A handle being chosen: one character, or 3-27 lowercase letters, digits and dashes, starting with a letter and ending with a letter or digit, because the organisation's default catalog is named after it (dashes as underscores: acme-corp -> acme_corp) and must read well in SQL; never two dashes in a row (`--` separates the handle from the catalog name in the organisation's storage bucket names, <handle>--<catalog>); app, api, docs, legal, admin, lhbox, www, s3, catalog and auth are reserved, and a word SQL reserves (DuckDB's reserved keywords such as select, end or union, and main, memory, system, temp) is refused. Handles chosen before 2026-10-10 keep working.

  Auth:  none

  Response:  handle, valid, available, reason (invalid|starts_with_digit|too_long|double_dash|reserved|sql_word|taken|retired|null; retired: a deleted organisation's, reserved until its catalogs are purged everywhere and 30 more days have passed), suggestion (a free handle near it), pattern, catalog (the default catalog an organisation with this handle gets; null when the handle may not be chosen), note (with reason double_dash: why)

  Errors:  429 rate_limited

### POST /v1/signup/verify → 201

Step 2 of 2: the right code creates the account (no API key of its own, and no password yet: /app/forgot sets the first one), an organisation (owned by you unless an invitation places you elsewhere), and the organisation's ONE default catalog: the one it has when you join it by invitation, else one created now and named after its handle, dashes as underscores (`acme-corp` -> `acme_corp`); and a CONNECTION for the machine that signed up: a token of the organisation named after it (member role, on that catalog only: read and write, or read for a member who joined), the same kind `lhbox login` makes. The key returned is that connection's; it lists under Settings › Access keys and is revoked there. An email domain never places anyone: an organisation that verified and opened the address's domain is only offered afterwards (domain_offers; POST /v1/orgs/{org_id}/join).

  Auth:  none

  Request:  {"email": …, "code": "123456", "accept_terms": true, "engine": "duckdb", "hostname"?: "laptop", "client_name"?: "laptop", "client"?: "cli"}  (engine: duckdb|pyiceberg|spark, default duckdb; client_name names the connection, default the hostname)

  Response:  api_key (the connection's, shown once), key_id, kind (token), token_id, api_key_note, principal_id (yours), project_id, warehouse_id, organization{org_id, role, joined_via}, token{token_id, name, org_id, role, warehouses[{warehouse_id, name, level}], created_by, grants}, connection{as GET /v1/connection}, domain_offers[], account_page{url, via (password_reset|identity_provider), way_in, note}, next[], terms{version, accepted, url}. A self-hosted deployment with neither password login nor an identity provider is the one exception: there the key is the person's own (kind human, no token), since nothing else would let them in

  Errors:  403 registration_closed (self-hosted, registration closed: the deployment creates no account by self-registration; the invitation must still be open; remedy names the ways in), 404 no_pending_signup, 410 code_expired, 401 invalid_code (attempts_left; five attempts, then a new code is needed), 400 disposable_email (the domain was listed after the code was sent), 400 terms_required (accept_terms must be exactly true), 409 already_exists

  CLI:  lhbox signup --email … --code … --accept-terms --save

### POST /v1/signup → 201

The one-call, UNVERIFIED signup for local development stacks. The managed service answers 403 verification_required: use /v1/signup/start + /verify.

  Auth:  none

  Request:  {"email", "workspace", "engine", "accept_terms"}

  Response:  as /v1/signup/verify

  Errors:  403 registration_closed (self-hosted, registration closed: the deployment creates no account by self-registration, invited or not; remedy names the ways in), 403 verification_required on the managed service

### POST /v1/recover/start → 410

Retired on 2026-10-01: a code by email no longer mints a key with a person's full access (nor does signup since then). Always 410, nothing sent, nothing created. A person resets the password at /app/forgot and logs in; a machine runs `lhbox login` again and the person approves it.

  Auth:  none

  Request:  any body

  Response:  none: always the 410 error

  Errors:  410 recovery_retired (login_command, remedy; reset_url where password login is on, login_url where an identity provider is the way in)

  CLI:  lhbox recover

### POST /v1/recover/verify → 410

Retired on 2026-10-01 with /v1/recover/start: always 410, no key minted.

  Auth:  none

  Request:  any body

  Response:  none: always the 410 error

  Errors:  410 recovery_retired (as /v1/recover/start)

  CLI:  lhbox recover

## Login and sessions

### POST /v1/auth/signup → 202

The signup form: a pending account and a pending organisation (handle reserved), and a set-password mail with a link to /app/welcome?token=… valid 3 days. Nothing is usable until the link sets a password. An address with a pending invitation joins the inviting organisation instead (the organisation fields are ignored, nothing is reserved; will_join says so with via: invitation). An email domain never places anyone: an organisation that verified and opened the address's domain is only offered afterwards (domain_offers; POST /v1/orgs/{org_id}/join). The code path for agents and the CLI stays at POST /v1/signup/start. While new accounts are admitted in batches, a NEW address is answered 202 {email, waitlisted: true, message, next} instead: nothing is created and nothing is mailed until its turn comes (then the same set-password mail).

  Auth:  none

  Request:  {"email": …, "full_name": …, "organization_name": …, "handle": …, "return_to": "/app/…"}  (return_to optional: a path under /app/ on this site the welcome page continues to after the password is set, e.g. the device consent screen)

  Response:  202: email, delivery (email | log), expires_in (259200), organization{org_id, name, handle, status: pending} or will_join{org_id, name, handle, role, via: invitation} + note_join, terms, next, note (when the link went to the log: why), return_to

  Errors:  403 registration_closed (self-hosted, registration closed: the deployment creates no account by self-registration; an address holding an open invitation goes on; remedy names the ways in), 400 invalid_value (handle grammar or a reserved word; suggestion carries a free one), 400 disposable_email (an address at a throwaway mailbox service such as mailinator or yopmail; an invited address is not refused), 409 handle_taken, 409 already_exists (the address has finished signing up: log in), 429 rate_limited (5 per hour per address; one re-send per minute), 502 mail_error

### POST /v1/auth/password/set → 200

The set-password link: verifies the address, stores the argon2id hash, activates the pending organisation with the person as admin (or joins the inviting organisation with the invited role -- a joiner provisions nothing: the organisation's default catalog is theirs), records the Terms version the form showed, and returns a session with warehouse{warehouse_id, name, project_id, created} and next. With an unanswered domain offer (domain_offers) the personal default catalog waits: warehouse is null, warehouse_deferred is true, and next names both ways on (join the offering organisation, or decline with POST /v1/onboard, which creates the catalog then).

  Auth:  none

  Request:  {"token": …, "password": …}  (12 to 256 characters, not containing the address or the organisation name)

  Response:  access_token (RS256, 15 min, claims sub/email/kind/sid), token_type, expires_in, refresh_token (30 days sliding), refresh_expires_in, session_id, principal_id, principal, organization{…, role, joined_via}, terms, next, domain_offers[], warehouse_deferred (only when true)

  Errors:  403 registration_closed (self-hosted, registration closed: the deployment creates no account by self-registration: the link finishes only for an invited address or a member; remedy names the ways in), 400 password_too_short | password_too_long | password_contains_identity (the link is not spent), 401 invalid_login_token (unknown or already used), 410 token_expired

### POST /v1/auth/login → 200

Email and password to a session. The same 401 for an unknown address, a wrong password and an account without a password (a constant-time compare against a dummy hash in every case). `session_cookie: true` (the account page) puts the refresh token in an HttpOnly cookie for this account instead of the body; asked from another site's page it is 403 origin_not_allowed.

  Auth:  none

  Request:  {"email": …, "password": …, "code"?: …, "session_cookie"?: true}

  Response:  the session shape of POST /v1/auth/password/set

  Errors:  401 mfa_enrolment_required (a deployment that requires two-factor: a password account without a second factor; the error carries enrol_token for POST /v1/auth/mfa/enrol/start and /confirm, which answer the session), 401 invalid_login, 401 mfa_required (the account has two-factor on and no code was sent: the error carries mfa_token for POST /v1/auth/login/mfa), 401 invalid_mfa_code, 403 principal_suspended, 429 rate_limited (10 failures -- wrong passwords and wrong codes alike -- per caller and address per hour; 10 wrong passwords per account in 15 minutes from any caller, the same for an address without an account, the right password refused too until the window passes or a passkey login or the reset link clears it; Retry-After and retry_after_seconds; 120 attempts per caller address per hour)

### GET /v1/auth/google/start → 200

"Continue with Google" (only where the deployment has its own Google client: GET /v1/auth/config names `google`, otherwise 404). Google's authorize URL; a fresh state, nonce and PKCE verifier (S256) are bound to this browser in a sealed HttpOnly cookie on /v1/auth/google for 10 minutes. `accept_terms=true` only from the signup view's button, which says that continuing accepts the Terms: a new account needs it.

  Auth:  none

  Request:  ?accept_terms=true&hd=<domain>&prompt=none&login_hint=<email> (hd: pre-selects a Google Workspace account of that domain at Google, what GET /v1/auth/realm names; the token's hd claim is still checked. prompt=none + login_hint: the silent renewal of a short Workspace session)

  Response:  authorize_url, expires_in (600), redirect_uri

  Errors:  400 invalid_value (hd, prompt, login_hint), 403 origin_not_allowed, 404 not_found (no Google client here), 429 rate_limited

### POST /v1/auth/google/callback → 200

The second half of "Continue with Google": the code and state Google sent back to /app/auth/google. State checked against the cookie (spent here), the code exchanged server-side, the ID token verified, the address verified by Google. A known Google account signs in. Otherwise Google's word counts only for gmail.com/googlemail.com or a Workspace account of the address's domain: then an account with that address is linked, or a new one made as by the signup form; for any other address nothing happens before our email code (401 email_code_required, POST /v1/auth/google/link). An organisation's own identity provider wins for the addresses it covers (403 sso_required). The link is written only after every check, the second factor included.

  Auth:  none

  Request:  {"code": …, "state": …, "session_cookie"?: true}

  Response:  the session shape of POST /v1/auth/login, plus google (login | link | signup | waitlisted); a signup also organization, terms, warehouse (or warehouse_error / warehouse_deferred), domain_offers[]

  Errors:  400 invalid_state, 400 terms_required (signup_url), 400 disposable_email, 401 invalid_google_code, 401 invalid_id_token (reason), 401 email_code_required (google_token), 401 mfa_required (as for POST /v1/auth/login), 403 email_not_verified | sso_required | workspace_required (an address of a bound Google Workspace domain, and the token's hd is not that domain) | registration_closed | principal_suspended | origin_not_allowed, 404 not_found, 409 identity_conflict (reason), 429 rate_limited, 502 google_unavailable | mail_error, 503 storage_error (a signup whose placement failed keeps nothing)

### POST /v1/auth/google/link → 200

The email-code step of a Google sign-in at an address Google does not run: the google_token of 401 email_code_required and the six digits we emailed; then whichever of login, link or signup applies, with the rest of the sign-in (organisation provider, second factor, account state).

  Auth:  none

  Request:  {"google_token": …, "code": "123456", "session_cookie"?: true}

  Response:  as POST /v1/auth/google/callback

  Errors:  401 invalid_google_token (reason: expired | unreadable), 401 invalid_code (attempts_left; counted toward the address's login lockout), 410 code_expired, 404 no_pending_code, 429 rate_limited, then the callback's

### POST /v1/auth/google/reauth → 200

A browser session of an account with NO password that signs in with Google confirms it is still the person, the counterpart of POST /v1/auth/reauth (issue #1554). An account with a password always confirms with the password (and its second factor), never here. With two-factor on, the authenticator's code (or a backup code, spent) is checked first. Answers Google's authorize URL with max_age=0 (Google signs the person in again) and login_hint, bound to this browser and this session; the callback (POST /v1/auth/google/callback) then answers reauth instead of a session once the ID token is the Google account linked to the person and its auth_time is no earlier than 5 s before the start, and the session counts as recently signed in for 10 minutes. The callback refuses what a Google sign-in to the account would be refused for (its state, sso_required, workspace_required) and an account that set a password meanwhile (409 password_reauth_required). The routes that need a recent sign-in (device approval, MCP consent, the credential routes; not the static key pair) answer such a session 401 reauth_required with reauth: google, start naming this route and needs: [code] with two-factor on.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"code"?: …}  or  {"passkey": <assertion for POST /v1/auth/passkeys/confirm/start {"purpose": "google_reauth"}>} (instead of the code)

  Response:  authorize_url, expires_in (600), redirect_uri; the callback then: reauth{reauthenticated: true, how: google, valid_for_seconds}

  Errors:  401 mfa_code_required, 401 invalid_mfa_code, 403 not_a_person (a token), 403 session_required (an API key or an identity provider's token), 403 origin_not_allowed, 409 password_reauth_required (the account has a password), 409 google_not_linked, 404 not_found (no Google client here), 429 rate_limited; at the callback 401 reauth_not_fresh (no auth_time, or a sign-in older than the start: Google answered from an existing sign-in), 403 google_account_mismatch (another Google account), 401 session_ended

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/auth/login/mfa → 200

The second step of a login with two-factor on: the challenge from 401 mfa_required plus a 6-digit authenticator code or a single-use backup code, for the session. The challenge lives 5 minutes and takes 5 codes; a code is never accepted twice.

  Auth:  none

  Request:  {"mfa_token": …, "code": …} or {"mfa_token": …, "passkey": <the assertion for POST /v1/auth/login/passkey/start {mfa_token}>}

  Response:  the session shape of POST /v1/auth/login

  Errors:  401 invalid_mfa_token (unknown, used, expired, out of attempts: log in again), 401 invalid_mfa_code, 401 invalid_passkey, 503 mfa_unavailable (the server key does not open the stored secret: operator reset), 429 rate_limited

### POST /v1/auth/mfa/enrol/start → 200

The enrolment a deployment requires (self-hosted): with the enrol_token of 401 mfa_enrolment_required from POST /v1/auth/login, a new authenticator secret, pending until a code confirms it. The token lives 10 minutes and dies with a password change; starting again replaces the pending secret.

  Auth:  none

  Request:  {"enrol_token": …}

  Response:  otpauth_uri, secret (base32), issuer, account, algorithm (SHA1), digits (6), period (30), expires_in, next

  Errors:  401 invalid_enrol_token (malformed, expired, or the password changed: log in again), 403 principal_suspended, 409 mfa_already_enabled, 429 rate_limited

### POST /v1/auth/mfa/enrol/confirm → 200

The current code of the pending secret turns two-factor on and finishes the login: the session POST /v1/auth/login answers (marked two-factor), with the backup codes, shown once. `session_cookie: true` as for POST /v1/auth/login.

  Auth:  none

  Request:  {"enrol_token": …, "code": "123456", "session_cookie"?: true}

  Response:  the session shape of POST /v1/auth/login, plus backup_codes[8] (XXXXX-XXXXX, shown once), mfa{enabled, method, backup_codes, backup_codes_remaining}, note

  Errors:  400 invalid_mfa_code, 401 invalid_enrol_token, 404 mfa_not_started, 409 mfa_already_enabled, 429 rate_limited (wrong codes count against the account's two-factor cap)

### POST /v1/auth/login/passkey/start → 200

The options for navigator.credentials.get(): with no body, for a sign-in with a passkey alone (the browser offers the passkeys it holds for the site); with the mfa_token of 401 mfa_required, for the second step of a password login, naming that account's passkeys. The challenge lives 5 minutes and is used once.

  Auth:  none

  Request:  {"mfa_token"?: …}

  Response:  options (PublicKeyCredentialRequestOptions as JSON: challenge, rpId, allowCredentials, userVerification: required, timeout), expires_in

  Errors:  401 invalid_mfa_token, 403 passkeys_disabled, 429 rate_limited

### POST /v1/auth/login/passkey → 200

A passkey to a session, without an address or a password: the passkey names its account and, because the device's unlock (fingerprint, face or PIN) is required, the session counts as two-factor. `session_cookie: true` as for POST /v1/auth/login.

  Auth:  none

  Request:  {"credential": <PublicKeyCredential.toJSON() of the assertion>, "session_cookie"?: true}

  Response:  the session shape of POST /v1/auth/login

  Errors:  401 invalid_passkey (unknown passkey, another site, no user verification, a spent or expired challenge, a signature counter behind the stored one), 403 principal_suspended, 403 origin_not_allowed, 429 rate_limited

### POST /v1/auth/reauth → 200

A person's browser session confirms it is still the person: the current password, and a code when two-factor is on (a backup code is spent). The routes that hand out a durable credential (API keys, tokens, uploaders, catalog key rotation, sinks, device tokens, device approvals) accept a session whose login, or whose last confirmation here, is within the last 10 minutes; otherwise they answer 401 reauth_required and the account page asks for the password. An API key is never asked. An account with no password that signs in with Google renews with POST /v1/auth/google/reauth instead (the 401 says reauth: google), and an identity provider's sign-in at the provider (reauth: identity_provider). A provider that sends no auth_time cannot show a recent sign-in: the 401 then carries id_token_without_auth_time and a remedy naming the claim an admin adds. One of the account's passkeys confirms on its own, as it signs in on its own; the refusals of an account with a passkey name it in passkey{start, purpose, alone}.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"current_password": …, "code"?: …}  or  {"passkey": <PublicKeyCredential.toJSON() of the assertion for POST /v1/auth/passkeys/confirm/start {"purpose": "reauth"}>}

  Response:  reauthenticated: true, how (password, password+totp, password+backup_code, passkey, password+passkey), valid_for_seconds, note

  Errors:  401 reauth_required, 401 invalid_credentials, 401 mfa_code_required, 401 invalid_mfa_code, 401 invalid_passkey, 403 session_required (an API key or an identity provider's token), 403 not_a_person (a token), 409 password_required

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/auth/passkeys/confirm/start → 200

The options for navigator.credentials.get() naming the caller's passkeys, to confirm one act with a passkey instead of the authenticator's code: purpose reauth (POST /v1/auth/reauth), google_reauth (POST /v1/auth/google/reauth), account_delete (DELETE /v1/me), password_change, passkey_add (register/finish), totp_disable, backup_codes. The challenge is bound to this session and to that act, lives 5 minutes and is used once; the assertion goes to the act's route as {"passkey": …}.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"purpose": "reauth"}

  Response:  options (PublicKeyCredentialRequestOptions as JSON: challenge, rpId, allowCredentials, userVerification: required, timeout), expires_in, purpose

  Errors:  400 invalid_value (another purpose), 403 session_required (an API key), 403 not_a_person (a token), 403 passkeys_disabled, 409 no_passkey, 429 rate_limited

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/auth/passkeys → 200

The caller's passkeys (a person's browser session).

  Auth:  Authorization: Bearer <API key or token>

  Response:  passkeys[{passkey_id, name, created_at, last_used_at, backed_up, transports}], count, max, rp_id

  Errors:  403 session_required (an API key), 403 not_a_person (a token)

  Scope:  account:read (an MCP access token; an API key has no scopes)

### POST /v1/auth/passkeys/register/start → 200

The options for navigator.credentials.create(), bound to this session for 5 minutes. Needs the current password; when two-factor is on, a code must come too (send it again with register/finish, where it is checked, so a cancelled prompt spends no backup code).

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"password": …, "code"?: …}  or  {"password": …, "second_factor": "passkey"} (an existing passkey answers at finish)

  Response:  options (PublicKeyCredentialCreationOptions as JSON: residentKey and userVerification required, attestation none, excludeCredentials), expires_in, next

  Errors:  401 invalid_credentials (counted toward the login lockout), 401 mfa_code_required, 403 session_required, 409 password_required (the account has no password), 409 passkey_limit (20 per account), 429 rate_limited

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/auth/passkeys/register/finish → 201

The new passkey checked and stored; a notice is mailed to the account's address. With two-factor on, the code is checked here, after the ceremony.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"credential": <PublicKeyCredential.toJSON() of the attestation>, "name"?: "MacBook", "code"?: …, "passkey"?: <an existing passkey's assertion for confirm/start {"purpose": "passkey_add"}>}

  Response:  passkey{passkey_id, name, created_at, last_used_at, backed_up, transports}

  Errors:  400 invalid_passkey (a challenge this session was not given, another site, no user verification), 401 mfa_code_required, 401 invalid_mfa_code, 401 invalid_passkey (the existing passkey), 409 passkey_exists, 409 passkey_limit

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### DELETE /v1/auth/passkeys/{passkey_id} → 200

Remove one of the caller's passkeys: it no longer signs in, every session it opened is revoked (not the one making the call), and a notice is mailed. Needs a recent sign-in (the session's login, or POST /v1/auth/reauth, within the last 10 minutes).

  Auth:  Authorization: Bearer <API key or token>

  Response:  deleted: true, passkey_id, sessions_revoked

  Errors:  401 reauth_required, 409 mfa_required_by_policy (a deployment that requires two-factor: the account's last second factor), 404 passkey_not_found

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/auth/mfa → 200

The caller's two-factor state.

  Auth:  Authorization: Bearer <API key or token>

  Response:  enabled, method (totp|null), enabled_at, pending, backup_codes_remaining, session_mfa (this session presented a second factor)

  Scope:  account:read (an MCP access token; an API key has no scopes)

### POST /v1/auth/mfa/totp/start → 200

Begin two-factor enrolment (a person's browser session, with the current password): a new secret, pending until a code confirms it. The QR code on the account page encodes otpauth_uri; secret is for typing it by hand.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"password": …}

  Response:  otpauth_uri, secret (base32), issuer, account, algorithm (SHA1), digits (6), period (30), next

  Errors:  401 invalid_credentials (counted toward the login lockout), 403 session_required (an API key), 403 not_a_person (a token), 409 mfa_already_enabled, 429 rate_limited

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/auth/mfa/totp/confirm → 200

The current code of the pending secret turns two-factor on. The backup codes are in this answer only; every other session of the account is revoked.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"code": "123456"}

  Response:  enabled: true, method, backup_codes[8] (XXXXX-XXXXX, shown once), backup_codes_remaining, other_sessions_revoked, note

  Errors:  400 invalid_mfa_code, 404 mfa_not_started, 409 mfa_already_enabled, 403 session_required

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/auth/mfa/totp/disable → 200

Two-factor off: needs the password and a current code (or a backup code); the secret and the backup codes are deleted.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"password": …, "code": …}  or  {"password": …, "passkey": <assertion for confirm/start {"purpose": "totp_disable"}>}

  Response:  enabled: false

  Errors:  409 mfa_required_by_policy (a deployment that requires two-factor: the account's last second factor), 401 invalid_credentials (password), 401 invalid_mfa_code, 401 invalid_passkey, 409 mfa_not_enabled, 429 rate_limited

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/auth/mfa/backup-codes → 200

New backup codes for old: needs a current code (or a backup code); the earlier codes stop working.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"code": …}  or  {"passkey": <assertion for confirm/start {"purpose": "backup_codes"}>}

  Response:  backup_codes[8] (shown once), backup_codes_remaining, note

  Errors:  401 invalid_mfa_code, 401 invalid_passkey, 409 mfa_not_enabled, 429 rate_limited

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/auth/refresh → 200

A new access token and a rotated refresh token; the session's expiry slides 30 days. Presenting the refresh token that was just rotated out revokes the whole session (theft signal). The account page sends no token: its HttpOnly cookie of the account named is read, rotated and set again (a cookie rotated out under 30 s ago is rotated again, not taken for theft, and every token such a re-rotation superseded stays usable until the next rotation, so two or more tabs refreshing at once keep the session).

  Auth:  none

  Request:  {"refresh_token": …}  or, from the account page, {"account"?: "<principal_id>"} with the cookies (none named: the most recently used)

  Response:  the session shape, with the new refresh_token (in the cookie instead, for a cookie refresh)

  Errors:  401 invalid_refresh_token | refresh_reused | session_revoked | session_expired (a cookie refresh also clears that cookie; a named account without a cookie here is session_revoked), 401 refresh_raced (another request rotated the same token at the same moment: use the newest token you hold; a cookie refresh retries once and never clears the cookie for it), 403 origin_not_allowed

### POST /v1/auth/logout → 200

Revokes the session, by its refresh token, by the account page's cookie (and clears it), or by the access token in Authorization (sid claim). Its access tokens are refused at once (401 session_revoked), not only when they expire.

  Auth:  none

  Request:  {"refresh_token": …}  or  {"account": "<principal_id>"} / {"all": true} with the cookies  or  Authorization: Bearer <access token>

  Response:  revoked, session_id

### GET /v1/auth/accounts → 200

The accounts this browser holds a live session cookie for (the account page's account picker; its scripts cannot read the HttpOnly cookies). Reads only; suspended accounts are left out.

  Auth:  none

  Response:  accounts[{principal_id, email, display_name, last_used_at}] most recent first, count

  Errors:  403 origin_not_allowed (a cookie sent from another site's page)

### GET /v1/auth/sessions → 200

The caller's live sessions: where and when they logged in; `current` marks the one making the call.

  Auth:  Authorization: Bearer <API key or token>

  Response:  sessions[{session_id, created_at, last_used_at, expires_at, user_agent, ip, current}], count

  Scope:  account:read (an MCP access token; an API key has no scopes)

### DELETE /v1/auth/sessions/{session_id} → 200

Revokes one of the caller's sessions.

  Auth:  Authorization: Bearer <API key or token>

  Response:  session_id, revoked: true

  Errors:  404 session_not_found (also for another account's session)

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/auth/password/reset/start → 202

A 30-minute reset link to /app/reset?token=… by mail. 202 with the same body whether or not the address has an account. An account created through an identity provider that has no password yet gets the same link, which sets its first password. The newest 3 links stay valid together (a later request does not kill the one you are about to use); using one, or changing the password, spends them all. A new mail goes out only while fewer than 3 links are live, at most one a minute and 10 an hour per address (past a rule the same 202, nothing sent).

  Auth:  none

  Request:  {"email": …}

  Response:  email, delivery, expires_in (1800), next

  Errors:  429 rate_limited (5 per hour per caller address, shared with signup)

### POST /v1/auth/password/reset → 200

The reset link: a new password, every session revoked, every passkey removed, every API key of the person revoked, every MCP connection the person approved disconnected (the client connects again from its app; the link that sets a first password too; machines connected with lhbox login hold tokens and keep working), a notice mailed; returns a fresh session -- except for an account with two-factor on, which gets no session: it logs in with the new password AND a code.

  Auth:  none

  Request:  {"token": …, "password": …}

  Response:  the session shape plus sessions_revoked, api_keys_revoked, mcp_connections_revoked, first_password; with two-factor on: password_reset, sessions_revoked, api_keys_revoked, mcp_connections_revoked, first_password, mfa_required: true, next

  Errors:  400 password_* (the link is not spent), 401 invalid_login_token, 410 token_expired

### POST /v1/auth/password/change → 200

A logged-in human changes their password; every OTHER session is revoked; unless drop_keys is false, every API key of the person is revoked and every MCP connection the person approved disconnected; a notice is mailed.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"current_password": …, "new_password": …, "drop_keys"?: true, "code"?: … | "passkey"?: <assertion for POST /v1/auth/passkeys/confirm/start {"purpose": "password_change"}> (two-factor on)}

  Response:  changed: true, other_sessions_revoked, api_keys_revoked, mcp_connections_revoked, password_changed_at

  Errors:  401 invalid_login (wrong current password), 400 password_*, 409 no_password_set (identity-provider account: use the reset link), 403 forbidden (a token has no password), 401 mfa_code_required, 401 invalid_mfa_code, 401 invalid_passkey

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/auth/device → 200

RFC 8628 device authorisation for the CLI, asking for a CONNECTION (docs/agent-setup.md): a device code to poll with and an 8-character user code (no ambiguous glyphs) for the human to approve at /app/device. client_name names the machine (default the hostname); requested_level, warehouses and warehouse_ids are accepted from older CLIs and choose nothing since 2026-11-02 (a login acts as the person). Formerly: requested_level what it asked for (default read), warehouses which catalogs at which level ([{name | warehouse_id, level}], from `login --catalog NAME[:LEVEL]`; resolved within the approver's organisation when the code is read; default the organisation's default catalog at requested_level). warehouse_ids (ids or names, all at requested_level) is the older spelling. Unauthenticated (the machine has no credential yet): every field is the requester's claim, and the approval page says so; the code lives 15 minutes.

  Auth:  none

  Request:  {"client": "cli", "hostname": …, "client_name": …, "requested_level": "read|write", "warehouses": [{"name": …, "level": …}]}  (all optional)

  Response:  device_code, user_code (XXXX-XXXX), verification_uri, verification_uri_complete, expires_in (900), interval (5), client_name, acts_as ("you")

  Errors:  429 rate_limited (60 per hour per caller address), 400 invalid_value

  CLI:  lhbox login [--no-wait]

### GET /v1/auth/device/pending → 200

The consent screen's content for a pending code (people only; nothing is decided by reading it): what the program calls itself and its machine (its claim; nothing verifies it), that approving lets it act as the caller (acts_as, key_days), whether an organisation's policy refuses it (allowed, refusal), `request` (where and when the login started) and `confirm_required` (the approval carries the code typed from the terminal). A claim code answers its own content instead.

  Auth:  Authorization: Bearer <API key or token>

  Request:  ?user_code=XXXX-XXXX

  Response:  user_code, client, hostname, client_name, label, acts_as, key_days, expires_in, allowed, refusal?, request{ip, started_at, age_seconds, same_network}, confirm_required

  Errors:  404 device_code_not_found (mistyped, expired or already decided), 403 forbidden (a token), 429 rate_limited (120 reads of a code per hour per caller address and per account, shared with the status read)

  Scope:  account:read (an MCP access token; an API key has no scopes)

### POST /v1/auth/device/approve → 200

A person signed in on the account page (a password, Google, or their organisation's identity provider; within the last 12 hours) decides a code login (`lhbox login --no-browser`): yes or no. Approved, the machine gets, when it polls, the person's own key: it acts as them, as the browser login's does, for 30 days, mailed to them as a new key. A key limited to some catalogs is made on the website, never here; the older picker's fields (warehouses, level, warehouse_ids, agents) are ignored. The approval carries confirm_code, the code the person typed from their own terminal (a code that came with a link is not enough); deny: true refuses it and needs none. Only people: a token approving would hand out a key. A CLAIM code (`lhbox claim`, POST /v1/agent/identity/claim) takes claim: {into: <org_id the approver administers> | "new" (with handle)} and level instead, with a sign-in of the last 10 minutes: the catalog moves there, every pre-claim key ends, and the agent's poll gets a new key at that level.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"user_code": …, "confirm_code": …, "deny": false}

  Response:  approved, claimed: false, user_code, client, hostname, client_name, label, acts_as ("you"), expires_in, note

  Errors:  401 reauth_required, 403 session_required (an API key), 403 forbidden (a token), 403 org_policy_forbids (an organisation of the person lets only admins connect machines: connect_agents), 400 code_not_confirmed, 404 device_code_not_found (mistyped, expired or already decided), 403 idp_org_mismatch (a claim into a new organisation, or one other than the identity provider's, through an organisation's own provider), 409 quota_exceeded (a claim into an organisation without room for what the catalog holds, measured at the claim), 503 storage_unmeasured (its size could not be read just now; Retry-After)

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/auth/cli/authorize → 200

`lhbox login` in the browser (RFC 8252: a loopback redirect with PKCE). The person, signed in on the account page within the last 12 hours (AL_CLI_LOGIN_FRESH_SECONDS; never an API key or a token), lets the CLI on this computer act as them: the answer is where the page sends the browser, the CLI's own 127.0.0.1 listener with a one-time code (5 minutes) and the state. deny: true sends it back with error=access_denied. Refused while an organisation of the person lets only its admins connect machines (connect_agents).

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"redirect_uri": "http://127.0.0.1:<port>/callback", "state": …, "code_challenge": …, "code_challenge_method": "S256", "client_name": …, "hostname": …, "deny": false}

  Response:  redirect, expires_in

  Errors:  400 invalid_redirect_uri (not this computer's loopback over http with a port), 400 invalid_value, 401 reauth_required, 403 session_required (an API key), 403 forbidden (a token), 403 org_policy_forbids, 429 rate_limited

  CLI:  lhbox login

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/auth/cli/token → 200

The CLI trades the code its listener received, with the PKCE verifier only it holds, for the person's own key: it acts as them (kind human), as on the website, until expires_at (30 days); shown once, and mailed to the person as a new key. The code works once.

  Auth:  none

  Request:  {"code": …, "code_verifier": …, "redirect_uri": …}

  Response:  api_key, key_id, principal_id, kind (human), label, expires_at, note

  Errors:  400 invalid_grant (unknown, used or expired code, another redirect_uri, or a verifier that does not match), 429 rate_limited

  CLI:  lhbox login

### GET /v1/auth/device/status → 200

Where a CLI login stands, for the page that approved it: pending | approved | denied | used | expired, and `claimed` (the machine collected its key). Approving is not connecting -- the token is created when the machine polls -- so the page says "waiting for <name> to pick up its key" until this says claimed. A pending code is visible to any signed-in human (as the consent read is); a decided one only to the human who decided it. `expired` is an approval nobody collected within the code's 15 minutes.

  Auth:  Authorization: Bearer <API key or token>

  Request:  ?user_code=XXXX-XXXX

  Response:  user_code, status, claimed, client, hostname, client_name, expires_in, connection{name, org_id, token_id (once claimed), warehouses[{warehouse_id, name, level}]} | null

  Errors:  404 device_code_not_found, 403 forbidden (a token), 400 invalid_value, 429 rate_limited (reads of a code other than the caller's own decided one share the consent read's budget: 120 per hour per caller address and per account; following a login you decided is not counted)

  Scope:  account:read (an MCP access token; an API key has no scopes)

### POST /v1/auth/device/token → 200

The CLI polls until the person decides. The RFC's error names travel as error.code in the usual envelope: authorization_pending (keep polling), slow_down (interval grows by 5 s; never the first poll after the human decides), expired_token, access_denied. Once approved: the person's own key, labelled "cli <hostname>", shown once: it acts as them until expires_at (30 days), the same key as the browser login's. A key that cannot be made after the approval (an organisation's policy changed) is refused and the code denied.

  Auth:  none

  Request:  {"device_code": …}

  Response:  api_key, key_id, label, principal_id (the person's), kind: human, token_type: api_key, expires_at, note

  Errors:  400 authorization_pending | slow_down | expired_token | access_denied | invalid_grant, 403 org_policy_forbids (the organisation stopped members connecting machines after the approval)

  CLI:  lhbox login --no-browser  |  lhbox login --resume [--wait]

### GET /v1/setup → 200

A self-hosted install's first-run setup: whether it is open (no install administrator yet and a live setup token from the host), and how the first administrator will sign in. 404 on the managed service.

  Auth:  none

  Response:  deployment, open, has_admin, expires_at, password_login, mfa_required, identity_providers[{issuer, second_factor}], next

### POST /v1/setup → 201

The install administrator and the first organisation of a self-hosted install, once, with the setup token `./al setup-link` printed on the host. With a password, the answer's enrol_token starts the authenticator enrolment (POST /v1/auth/mfa/enrol/start, /confirm), whose answer is the session; without one, the install's identity provider signs the person in. Audited install.setup.

  Auth:  none

  Request:  {token, email, display_name, password?, organization: {name, handle?}}

  Response:  principal_id, email, platform_admin, organization{org_id, name, handle, role}, sign_in (password | identity_provider), enrol_token?, next

  Errors:  401 invalid_setup_token, 409 setup_done | email_taken | handle_taken, 400 password_required | password_login_off | password_too_short | invalid_value, 404 (managed service)

  CLI:  lhbox admin setup --token T --email E --name N --org O [--handle H] [--password-stdin | --no-password]

## Remote MCP: OAuth 2.1 authorization server

### GET /.well-known/oauth-protected-resource → 200

RFC 9728 metadata of the REST API itself: every 401 of /v1/* names it in `WWW-Authenticate: Bearer resource_metadata=…`. Its authorization server, the scopes, bearer tokens in the header, the documentation; where agents may start without an account, agent_registration names POST /v1/agent/identity.

  Auth:  none

  Response:  resource, authorization_servers, scopes_supported, bearer_methods_supported [header], resource_name, resource_documentation, agent_registration{endpoint, types_supported [anonymous], documentation}

### GET /.well-known/oauth-authorization-server → 200

RFC 8414 metadata an MCP host reads to authorise against the remote MCP server: issuer, authorization/token/revocation endpoints, jwks_uri, scopes_supported, code + PKCE S256 only, public clients and registered confidential ones (token_endpoint_auth_methods_supported: none, client_secret_basic, client_secret_post), Client ID Metadata Documents supported, iss on authorization responses, and the deprecated registration_endpoint while dynamic registration is on.

  Auth:  none

  Response:  issuer, authorization_endpoint, token_endpoint, revocation_endpoint, jwks_uri, registration_endpoint, scopes_supported, response_types_supported [code], grant_types_supported [authorization_code, refresh_token], code_challenge_methods_supported [S256], token_endpoint_auth_methods_supported [none, client_secret_basic, client_secret_post], client_id_metadata_document_supported, authorization_response_iss_parameter_supported

### GET /.well-known/jwks.json → 200

The RS256 public key(s) that verify every token this API signs (kid on every key and every token; two keys during a rotation). The same document as GET /jwks.json, at the path the MCP server's token verifier uses.

  Auth:  none

  Response:  keys[{kty, use, alg, kid, n, e}]

### GET /oauth/authorize → 200

The authorization endpoint. The MCP host opens the person's browser here; the client is identified by a Client ID Metadata Document (client_id = its https URL, fetched server-side behind an SSRF guard and cached) or a registered client_id. redirect_uri must be one the client lists (exact; loopback on any port), response_type=code, code_challenge + code_challenge_method=S256 and resource (the MCP server's URL) are required. A bad client or redirect_uri is a 400 (never a redirect); every other error is a 302 to the redirect_uri with error, error_description, state and iss. On success: 302 to the consent page (/app/authorize?request=<id>), where the logged-in person decides.

  Auth:  none

  Request:  ?client_id=<https URL or dcr_…>&redirect_uri=…&response_type=code&code_challenge=…&code_challenge_method=S256&resource=<MCP server URL>&scope=<space-separated>&state=…

  Response:  302 to the consent page, or 302 to redirect_uri with error (unsupported_response_type, invalid_request, invalid_target, invalid_scope), state, iss

  Errors:  400 invalid_client (unknown client, or a metadata document that is not https, names a private or local address, redirects to another host, exceeds 64 KiB, is not JSON or does not match), 400 invalid_request (redirect_uri not registered), 429 rate_limited (120 per hour per address). Flat RFC 6749 shape: {error, error_description}

### GET /v1/oauth/authorize/pending → 200

The consent page's content for a pending authorization (a signed-in human: a login session, or a sign-in through the organisation's own identity provider, which then sees that organisation only): who is asking (client_name, client_uri, logo_uri, the host of its metadata URL, how it registered), the scopes as sentences, the MCP server the token is for, the organisation and the catalogs the person may grant with the level proposed (capped by their own), and the token record already bound to this client if any (the picker's initial state), and decision_needs_reauth: how the sign-in must be renewed before an approval (identity_provider, or POST /v1/auth/reauth for a session; the refusal's code when no new sign-in helps), or null, with decision_refusal (that refusal's code, message, remedy). An identity provider's sign-in sends its ID token in X-Id-Token here too: Entra puts auth_time in it only. Nothing is decided by reading it.

  Auth:  Authorization: Bearer <API key or token>

  Request:  ?request_id=<from the consent page URL>

  Response:  request_id, expires_at, expires_in, client{client_id, client_name, client_uri, logo_uri, host, registration (cimd|dynamic)}, scopes[], scope, scope_descriptions{}, resource, redirect_host, person{}, organization{org_id, name, handle, role}, warehouses[{warehouse_id, name, my_level, my_via, level, granted, default}], existing{token_id, name, role, grants} | null, organizations[], default_org_id, decision_needs_reauth, decision_refusal

  Errors:  404 oauth_request_not_found, 410 oauth_request_expired (15 minutes), 410 oauth_request_decided, 403 session_required (an API key instead of a sign-in), 403 forbidden (a token), 403 idp_org_not_member (signed in by an organisation's provider, no longer its member), 403 idp_org_only (an organisation's provider signing in a person who also belongs to another organisation: refused on every route), 409 org_required

  Scope:  account:read (an MCP access token; an API key has no scopes)

### POST /v1/oauth/authorize/decision → 200

The signed-in human allows or denies. Allowing needs a recent sign-in (a session's login or its POST /v1/auth/reauth, an identity provider's auth_time, within the re-authentication window; denying needs none), and a sign-in through an organisation's own identity provider decides for that organisation only. Allowing creates a TOKEN RECORD of kind mcp in the organisation -- or reuses the one bound to (you, client_id) and replaces its grants -- named after the client and you, holding exactly the grants chosen (each capped by your own level; a catalog you hold nothing on is refused), member role unless you are an admin and ask otherwise; then mints a single-use authorization code (10 minutes). The token appears in the organisation's tokens list and is revoked there, which ends its refresh and access tokens at once.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"request_id": …, "allow": true, "grants": [{"warehouse_id": …, "level": "read|write"}], "org_id"?: …, "role"?: "member|admin"}  or  {"request_id": …, "allow": false}

  Response:  allowed, redirect_to (the client's redirect_uri with code, state and iss; on deny error=access_denied, state, iss), client{}, scope[], expires_in, token{token_id, name, org_id, role, kind: mcp, grants[], capped[], reused, connection{}}, note

  Errors:  404 oauth_request_not_found, 410 oauth_request_expired | oauth_request_decided, 403 session_required, 401 reauth_required (an approval whose sign-in is not recent; reauth names how to renew it, and auth_time_missing: true when the provider's young ID token has no auth_time), 403 idp_org_only (as on every route), 403 idp_org_not_member, 403 idp_org_mismatch (another organisation than the provider's), 403 forbidden (a token; or a catalog you hold nothing on), 403 insufficient_role (a member asking for an admin token), 403 org_policy_forbids (the organisation lets only admins connect agents; the pending read says agents_allowed per organisation), 404 warehouse_not_found | org_not_found, 409 org_required, 429 rate_limited

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/oauth/authorize/set-up → 200

What the consent page shows a person who is not signed in: who is asking, and whether the deployment offers to let that client set up an account for them now, with that account's limits.

  Auth:  none

  Request:  ?request_id=…

  Response:  request_id, expires_in, client{client_name, client_uri, logo_uri, host}, set_up{offered, storage_bytes, catalogs, lifetime_seconds, public, refused_until_claimed[], terms_url, privacy_url}

  Errors:  404 oauth_request_not_found, 410 oauth_request_expired | oauth_request_decided, 429 rate_limited

### POST /v1/oauth/authorize/set-up → 200

"Let <client> set it up": for a person with no account. The client gets an account an agent made (the unclaimed organisation of POST /v1/agent/identity: one catalog with write, 1 GB, nothing public, deleted with its data after 72 hours unless a person keeps it), counted against the same limits from this browser's address; the pending authorization is approved with write on that catalog and the browser goes back to the client with its code; the token's scope includes catalogs:write whatever the client asked. Offered only where the write service runs (so the client can write and keep the account). The click accepts the Terms of Service and the Acceptable Use Policy. The person keeps the account later through the client (POST /v1/agent/identity/claim); keeping it ends this connection, and the client asks once to reconnect.

  Auth:  none

  Request:  {"request_id": …, "accept_terms": true}

  Response:  allowed, redirect_to (the client's redirect_uri with code, state and iss), client{}, scope[], expires_in, organization{org_id, status: unclaimed, expires_at}, catalog{warehouse_id, name, level: write}, note

  Errors:  403 registration_closed (self-hosted, registration closed: the deployment creates no account by self-registration; remedy names the ways in), 400 terms_required, 403 anonymous_not_enabled, 404 oauth_request_not_found, 410 oauth_request_expired | oauth_request_decided, 429 rate_limited

### POST /oauth/token → 200

The token endpoint (form-encoded or JSON; public clients send client_id, never a secret). grant_type=authorization_code with code, code_verifier (PKCE S256), client_id, redirect_uri and resource -> an RS256 access token for the MCP server (aud = resource, sub = the mcp token record, act.sub = the person, scope, client_id, kind mcp, 15 minutes) and a refresh token (30 days sliding). A code is single use; presenting it twice revokes the tokens it produced. grant_type=refresh_token with refresh_token and client_id -> a new pair, the refresh token rotated; presenting the rotated-out one again revokes the whole family. A narrower scope may be asked for on refresh.

  Auth:  none

  Request:  grant_type=authorization_code&code=…&code_verifier=…&client_id=…&redirect_uri=…&resource=…  or  grant_type=refresh_token&refresh_token=…&client_id=…[&scope=…]

  Response:  access_token (JWT), token_type: Bearer, expires_in (900), refresh_token, refresh_expires_in, scope

  Errors:  RFC 6749 §5.2 flat shape {error, error_description}: 400 invalid_request | invalid_grant (unknown, used, expired or foreign code; wrong verifier; unknown, rotated, revoked or expired refresh token; the token record revoked) | invalid_scope | invalid_target | unsupported_grant_type, 401 invalid_client, 429 rate_limited

### POST /oauth/revoke → 200

RFC 7009: a client that logs out revokes its refresh token (or an access token, whose session is revoked: every access token of that session is then refused by the API and its refresh token is dead). 200 with {} whatever the token, as the RFC says. The person's own revocation is the tokens list on the account page.

  Auth:  none

  Request:  token=…[&token_type_hint=refresh_token|access_token]

  Response:  {}

### POST /oauth/register → 201

RFC 7591 dynamic registration, the DEPRECATED fallback for hosts that cannot publish a Client ID Metadata Document. Redirect URIs: https, loopback http on any port, or a native app's private-use scheme. A client that asks for client_secret_basic or client_secret_post gets a client_secret, shown once, and sends it to the token endpoint (PKCE still required); any other client is public. Registrations unused for 90 days are pruned. A self-hosted deployment can turn it off.

  Auth:  none

  Request:  {"client_name": …, "redirect_uris": […], "grant_types"?: [authorization_code, refresh_token], "response_types"?: [code], "token_endpoint_auth_method"?: "none|client_secret_basic|client_secret_post", "client_uri"?, "logo_uri"?, "application_type"?: "web|native", "software_id"?}

  Response:  201: client_id (dcr_…), client_id_issued_at, client_name, redirect_uris, grant_types, response_types, token_endpoint_auth_method, client_secret + client_secret_expires_at: 0 (confidential clients only), application_type, client_uri, logo_uri, software_id, note

  Errors:  400 invalid_client_metadata | invalid_redirect_uri, 403 access_denied (registration disabled), 429 rate_limited (20 per hour per address)

## Projects and catalogs (warehouses in the API)

### POST /v1/projects → 201

A project is the placement boundary: provider and region are fixed at creation and never change. Today there is one placement, the deployment's own; it names a location, never an infrastructure provider.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"name": …, "provider"?, "region"?, "org_id"?}   Header Idempotency-Key: any string

  Response:  project_id, name, provider, region, placement_immutable: true, org_id

  Errors:  403 idp_org_not_member (a person in no organisation, signed in through an organisation's own identity provider: it places them in that organisation only), 409 already_exists (name in use by you; project_id in the body), 400 unsupported_placement (available[]), 400 org_required (you belong to several organisations), 404 org_not_found, 409 idempotency_key_reused

  CLI:  lhbox project create --name …

  Scope:  catalogs:write (an MCP access token; an API key has no scopes)

### GET /v1/projects → 200

Projects you own or reach through an organisation you belong to.

  Auth:  Authorization: Bearer <API key or token>

  Response:  projects[{project_id, name, provider, region, org_id, created_at}]

  CLI:  lhbox project list

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

### GET /v1/projects/{project_id} → 200

One project you reach, as the list shows it.

  Auth:  Authorization: Bearer <API key or token>

  Response:  project_id, name, provider, region, org_id, created_at

  Errors:  404 project_not_found

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

### PATCH /v1/projects/{project_id} → 200

Rename a project. Its placement and organisation are fixed at creation.

  Auth:  Authorization: Bearer <API key or token> — an organisation admin

  Request:  {"name": …}

  Response:  project_id, name, provider, region, org_id, created_at

  Errors:  400 immutable_field (provider, region, org_id), 400 unknown_field, 403 forbidden, 404 project_not_found, 409 already_exists (project_id: the project with that name)

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### DELETE /v1/projects/{project_id} → 200

Delete an empty project. A deleted catalog still in its grace period keeps its project, since a restore puts it back there: purge it first.

  Auth:  Authorization: Bearer <API key or token> — an organisation admin

  Response:  project_id, deleted: true

  Errors:  403 forbidden, 404 project_not_found, 409 project_not_empty (catalogs[{warehouse_id, name, state}])

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/warehouses → 201

A warehouse is one Iceberg catalog: one table bucket named <organisation handle>--<catalog name, _ mapped to -> (overture--data; u-<8 hex of the principal id>--<catalog> outside an organisation) plus one storage identity confined to it by the catalog. Also a blob bucket (<handle>--<catalog>--blobs) for non-Iceberg files. Catalogs created before 2026-09-20 keep w-<uuid> / b-<uuid>; the response and every row name the buckets as they are (bucket, blob_bucket). Bucket names are DNS names of at most 63 characters, so the catalog name is bounded per handle (400 name_too_long_for_bucket says the maximum) and may not end in _; catalog names are unique per organisation (docs/permissions-and-access.md, "Names").

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"project_id": <id or name>, "name": …, "default_format_version": 2}   (2 default, or 3: the Iceberg format version LakehouseBox's own create paths -- POST /v1/tables without format_version, the MCP create_table tool, table import -- give new tables here; an engine's own CREATE TABLE is honoured as sent)   Header Idempotency-Key

  Response:  warehouse_id, name, project_id, project, provider, region, catalog_uri, warehouse (s3://<bucket>/), warehouse_name (the table bucket), bucket, blob_bucket, default_format_version, maintenance{compaction: true, compaction_format_versions: [2, 3], compaction_note, target_file_size_mb: 128, snapshot_retention_days: 7, max_snapshots: 20, disableable: false}, storage_measurement{status: scheduled, in_seconds: 120, then} (the control plane measures the new catalog itself ~2 minutes after creation; until then its storage rows say measuring: true), status: ready | provisioning, storage_probe{table, blob, seconds} (provision = allocate: creation ends with a tenant-signed PUT + DELETE of a 0-byte object in each bucket -- 'ok', or the store's status and words, e.g. '500 InternalError ...'; on any refusal the catalog IS created and answers status: provisioning with retry_after_seconds: 30 and a remedy; the control plane retries every 30 s for 10 min and flips it to ready; GET /v1/connection answers 503 provisioning until then)

  Errors:  400 invalid_name (not a SQL-friendly name, or a name ending in _ whose bucket would end in -; suggestion), 400 name_too_long_for_bucket (the mapped bucket would exceed 63 characters; max_name_length for this owner, bucket_would_be), 400 invalid_value (default_format_version not 2 or 3; versions{} says who writes each), 409 already_exists (the name in this project, or in another project of the organisation: bucket, warehouse_id), 409 name_reserved_until_purge (a deleted catalog of that name is in its grace period: deleted_warehouse_id, purge_after, restore; restore it, purge it with DELETE … {"confirm", "purge": true}, or choose another name), 409 name_reserved_until_teardown (a deleted catalog of that name is still tearing down; poll, Retry-After 5), 409 bucket_leftover_from_deletion (a finished teardown left the bucket at the store; an operator removes it), 409 name_reserved_until_cdn_purge (a deleted public catalog's bucket, not yet purged from the public-data edge; retried automatically, Retry-After 300), 409 creation_in_progress (the same bucket is being provisioned right now; Retry-After 5), 409 limit_exceeded (warehouses per project; limit_value, current), 409 quota_exceeded (the organisation is over its storage limit; current, limit_value, scope), 404 project_not_found, 502 storage_error (safe to retry with the same idempotency key)

  CLI:  lhbox catalog create <name> [--project …] [--default-format-version 2|3]

  Scope:  catalogs:write (an MCP access token; an API key has no scopes)

### GET /v1/warehouses → 200

The catalogs you can see, ids and names side by side: every catalog of your organisation(s) and of the projects you own (a member token: exactly the catalogs it was granted), or those of one project with ?project=. Each row carries its project_id.

  Auth:  Authorization: Bearer <API key or token>

  Request:  ?project=<id or name>  (optional)

  Response:  project, project_id (null without ?project), catalog_uri, warehouses[{name, warehouse_id, warehouse (s3://<bucket>/), bucket, blob_bucket (the two storage bucket names as they are: <handle>--<catalog> and …--blobs, or w-/b-<uuid> on older catalogs), project_id, project, created_at, my_level (read|write|none), my_via, status (ready | provisioning: its storage has not accepted a write yet), default_format_version (2|3), stored{bytes, table_bytes, blob_bytes, objects, blob_objects, complete, measured_at, stale_after (when the next reading will have replaced the figure), source: store|creation|on_demand|sweep, unmetered, measuring (no sample yet and the first measurement is on its way)}, public, public_since, public_url}], count (the rows you see), limit (per project), headroom (the project's own count, as the limit counts it: catalogs you cannot see included, managed ones not; null without ?project)

  Errors:  404 project_not_found

  CLI:  lhbox catalog list [--project …]

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

  MCP tool:  list_tables

### GET /v1/warehouses/{warehouse_id} → 200

One catalog by id or by name: the same row as the list, plus the bucket name and placement.

  Auth:  Authorization: Bearer <API key or token>

  Request:  the path holds the warehouse_id or the catalog's name

  Response:  name, warehouse_id, warehouse (s3://<bucket>/), warehouse_name (the table bucket), bucket, blob_bucket, project_id, project, created_at, my_level, my_via, status (ready | provisioning), default_format_version (2|3), snapshot_retention_days (null = the default), snapshot_retention{source: default|catalog, days, max_snapshots | max_snapshots_in_window, note}, stored{bytes, table_bytes, blob_bytes, objects, blob_objects, complete, measured_at, stale_after, source, unmetered, measuring}, public, public_since, public_url (public_note while public), catalog_uri, provider, region

  Errors:  404 warehouse_not_found (not yours; a member token: no grant on it), 404 warehouse_deleting / warehouse_deleted (a catalog you could see, by id: state, requested_at, completed_at, tables_dropped, bytes_at_deletion, grants_removed, usage_samples_removed, storage{step: ok|HTTP n}, storage_complete, tables_refused -- the counts once the background teardown has run), 409 ambiguous_name (two catalogs of that name: candidates[] lists their ids)

  CLI:  lhbox catalog get <name or id>

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

  MCP tool:  list_tables

### PATCH /v1/warehouses/{warehouse_id} → 200

Change a catalog's settings. default_format_version (2|3): the Iceberg format version LakehouseBox's OWN create paths give a new table -- POST /v1/tables without format_version, the MCP create_table tool, table import. An engine's own CREATE TABLE is honoured as sent, never upgraded; existing tables are unchanged. tier_policy (hot|cold, admin only). snapshot_retention_days (1-90, or null for the default; admin only): the catalog's time-travel window -- from the next maintenance run (every 6 hours) every snapshot younger than N days is kept (at most 500 per table, the newest) and older ones are expired, so a table can be read at, or rolled back to, any snapshot of the last N days; null is the default policy, at most 20 snapshots and none older than 7 days, whichever is tighter. A longer window cannot bring back snapshots already expired, and retained snapshots keep their data files, which count as storage. virtual_host (true|false, admin only): serve https://<bucket>.s3.<domain> for the catalog's two buckets (Snowflake needs that form, and an admin's request for its recipe turns it on); the TLS certificate it takes lists the bucket names in the public Certificate Transparency logs, which is why it is off by default, public catalogs included (they are read path-style). false refuses new and renewed certificates; the one already issued keeps working until it is due for renewal (within about 60 days). edge_reads (true|false; an admin of the organisation or the catalog's project manager): edge caching for a private catalog (/docs/edge-caching/): its readers' lhbox duckdb sessions then read through our European CDN; false clears its cache within the hour. Sending the value already set changes and audits nothing. Audited as warehouse.update (changes, previous).

  Auth:  Authorization: Bearer <API key or token> — admin or a write holder on the catalog

  Request:  {"default_format_version": 3} and/or {"tier_policy": "hot"} and/or {"snapshot_retention_days": 30}   (the path holds the warehouse_id or the catalog's name)

  Response:  the row of GET /v1/warehouses/{id} (name, warehouse_id, warehouse, warehouse_name, project_id, project, created_at, my_level, my_via, default_format_version, the public fields, catalog_uri) plus updated[] (the fields that changed), snapshot_retention_days, snapshot_retention{source, days, ...}, previous{the settings sent}, format{writers[], readers[], note} for the default now in force, note

  Errors:  400 missing_field (empty body), 400 invalid_field (an unknown setting; accepts[]), 400 invalid_value (a format version not 2 or 3, versions{} says who writes each; a retention outside 1-90, min and max say so), 403 forbidden (read level, can_grant[] names who can; a tier or retention change by a non-admin), 404 warehouse_not_found, 409 ambiguous_name

  CLI:  lhbox catalog update <name or id> --default-format-version 2|3 --tier hot|cold --snapshot-retention-days N|default --virtual-host on|off --edge-reads on|off

  Scope:  catalogs:write (an MCP access token; an API key has no scopes)

### POST /v1/warehouses/{warehouse_id}/measure → 200

Measure the catalog's storage NOW instead of waiting for the next reading: the store's own figure for its table bucket and its blob bucket (both as the row names them) -- the live bytes of each bucket, and its live stored chunks as the object count (approximately one per file) -- summed and stored as a usage sample (source: on_demand) that GET /v1/usage, GET /v1/warehouses and the account page show until the next reading. The control plane reads the same figure for every catalog every few minutes anyway (source: store); this is for right after a load or a delete. Iceberg metadata files, which the store keeps inline, are not counted. Quota enforcement (the bucket quota, the 409s) uses the same figure. Once a minute per catalog. Audited as warehouse.measure.

  Auth:  Authorization: Bearer <API key or token> — read level on the catalog

  Request:  the path holds the warehouse_id or the catalog's name; no body

  Response:  name, warehouse_id, measured_at, stale_after (measured_at + the store reading interval, 300 s), source: on_demand, bytes, table_bytes, blob_bytes, objects, blob_objects, complete (true), partial (false), errors (null), seconds, tier, storage{current, limit, headroom, measured_at, stale_after, over_limit, …}, public_storage{…}, bucket_quotas{quotas (the values written), unchanged, …}, note

  Errors:  404 warehouse_not_found (not yours; a member token: no grant on it), 409 ambiguous_name, 429 rate_limited (retry_after_seconds; Retry-After header: measured less than a minute ago -- read the figure from GET /v1/usage), 502 storage_error (the store did not report its usage; nothing stored, safe to retry at once, no rate-limit mark is set)

  CLI:  lhbox catalog measure <name or id>

  Scope:  catalogs:write (an MCP access token; an API key has no scopes)

### GET /v1/warehouses/{warehouse_id}/grants → 200

Who holds what on the warehouse: explicit grants and the implicit ones (admins write via admin, members read via membership, the creator write via creator, a legacy unconfined token write via token). A level of none is a membership read an admin revoked for this warehouse. A holder of namespace-level grants carries them in `namespaces` and whether they apply in `namespaces_apply` (only while it holds nothing on the catalog); a token holding only namespaces is listed with level none.

  Auth:  Authorization: Bearer <API key or token> — any member of the organisation

  Response:  [{principal_id, kind, display, level (read|write|none), via, granted_by, granted_at, namespaces?[{namespace, level, granted_by, granted_at}], namespaces_apply?}]

  Errors:  404 warehouse_not_found

  CLI:  lhbox catalog grants <name or id>

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

### PUT /v1/warehouses/{warehouse_id}/grants/{principal_id} → 200

Grant read or write on a warehouse to a member or a token of the organisation. A read grant yields read-only catalog and storage credentials (t-<uuid>-ro); a write grant the read/write ones. With `namespace`: read or write on that one namespace only, for a principal holding nothing on the catalog, through its own storage identity which the catalog and the storage confine to its namespaces (admins only; an enterprise feature, off unless enabled for the organisation).

  Auth:  Authorization: Bearer <API key or token> — admin, or a human holding write (granting at most their own level, to someone without a row or on a row they made; another person's row, a revocation and a legacy unconfined token are an admin's)

  Request:  {"level": "read"|"write", "namespace"?: "<namespace>"}

  Response:  warehouse_id, principal_id, kind, display, level, via, granted_by (with namespace: namespace, via namespace, namespaces[{namespace, level}], identity)

  Errors:  400 invalid_value, 403 forbidden (level, can_grant[]), 403 enterprise_feature (a namespace grant without enterprise features), 404 principal_not_found (not in this organisation), 404 namespace_not_found, 409 catalog_grant_held (a namespace grant to someone holding the catalog: revoke that first), 409 implicit_grant, 409 principal_inactive (a namespace grant to a suspended principal or a revoked token), 502 storage_error (a namespace grant recorded but not yet in storage: retry the same request)

  CLI:  lhbox catalog grant <name or id> --principal … --level read|write [--namespace NS]

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### DELETE /v1/warehouses/{warehouse_id}/grants/{principal_id} → 200

Revoke a grant. Revoking a member's implicit membership read records level none for this warehouse; an admin's implicit write cannot be revoked (change their role). With ?namespace=<ns>: that namespace grant only (admins); the last one deletes the principal's own storage identity.

  Auth:  Authorization: Bearer <API key or token> — admin, or the person who made the grant

  Response:  warehouse_id, principal_id, kind, display, revoked{level, via}, level, via (with namespace: namespace, namespaces left, identity, identity_state, storage ok|pending)

  Errors:  403 forbidden, 404 grant_not_found, 409 implicit_grant

  CLI:  lhbox catalog revoke <name or id> --principal … [--namespace NS] [--yes]

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### DELETE /v1/warehouses/{warehouse_id} → 202

Delete a catalog, with its name typed in the body. By default the delete starts a grace period (7 days on the service): before the 202 the catalog is gone for every caller and out of every list, its grants are removed, a public catalog's anonymous-read policy is removed, warehouse.delete is audited; right after, in the background, its key pairs, its catalog access and its uploaders are revoked. Its name stays reserved, its storage is kept and still counts against the organisation's storage (not against catalogs per project), and POST /v1/warehouses/{id}/restore puts it back until purge_after. After purge_after the next maintenance sweep (every 6 hours) purges it. Vended storage sessions already handed out run to their own expiry (about an hour). With "purge": true -- or on a catalog already in its grace period -- the deletion is immediate and irreversible: in the background (usually within a minute) tables dropped as the tenant, both buckets and the identities with their policies removed, then warehouse.delete_complete with the table count and every storage step, and the tombstone GET /v1/warehouses/{id} reports (404, state deleting -> deleted, the counts). The nightly backup copy ages out within its retention window (14 days).

  Auth:  Authorization: Bearer <API key or token> — admin of the organisation

  Request:  {"confirm": "<the catalog name, exactly>", "purge": false}

  Response:  202 status: retained (grace period: purge_after, grace_days, restorable: true, restore) or deleting (purge), warehouse_id, name, project_id, bytes_at_deletion, measured_at, grants_removed, usage_samples_removed, tables_dropped: null, storage: null, storage_complete: null, poll (GET /v1/warehouses/{id}), cdn_purge (a public catalog: {status: pending | skipped}: the public-data edge's purge owed, or nothing on the edge; else null), warnings (while pending), note

  Errors:  400 confirmation_required (confirm_with names the expected value), 400 invalid_value (purge not a boolean), 403 forbidden (not an admin), 404 warehouse_not_found, 404 warehouse_deleted (state retained with purge_after and restore, or deleting/deleted with the counts), 409 delete_in_progress (Retry-After: 5; the teardown is running)

  CLI:  lhbox catalog delete <name> --confirm [--purge]

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/warehouses/{warehouse_id}/restore → 200

Restore a catalog in its grace period, by id or by the name of a deleted catalog you could see: the same id, name, buckets, settings and tables, and its grants. If its credentials were already revoked it gets a new key pair (fetch a fresh recipe with lhbox connect). A public catalog comes back private; revoked uploaders stay revoked. Audited warehouse.restore.

  Auth:  Authorization: Bearer <API key or token> — admin of the organisation

  Response:  restored, warehouse_id, name, project_id, deleted_at, purge_after, grants_restored, was_public, public: false, storage, note, catalog (the row of GET /v1/warehouses/{id})

  Errors:  403 forbidden (not an admin), 404 warehouse_not_found, 409 not_restorable (being purged, purged, or its project or organisation is gone), 409 not_deleted, 409 already_exists, 409 limit_exceeded (the project is at its catalogs limit), 409 ambiguous_name

  CLI:  lhbox catalog restore <id or name>

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/warehouses/{warehouse_id}/rotate → 200

Replace both of the warehouse's catalog credentials (the read/write identity t-<uuid> and the read-only t-<uuid>-ro; the CLI prints the two new secrets as ******** unless --show-secrets). Each new key is issued before the old one is deleted, so there is never a moment without a working key. The old READ/WRITE key is deleted before the answer: it and every catalog token minted from it are refused at once. The old READ-ONLY key (it could only read) is deleted by the background worker right after the answer -- one IAM write, about 10 s at the host's current identity count, later only if a catalog deletion's teardown is queued ahead of it -- and audited as warehouse.rotate_complete. Every OTHER key of both identities (a key can mint more keys for its own identity at the store) is reaped by that same background job, which runs under the per-catalog key lock and keeps whatever the row names when it runs, so it can never delete a later rotation's key. Storage sessions already vended run out on their own within an hour. Why the split: every IAM write costs the store a propagation deadline.

  Auth:  Authorization: Bearer <API key or token>

  Response:  warehouse_id, warehouse_name, catalog_credential{client_id, client_secret}, catalog_credential_ro{client_id, client_secret}, previous_client_id, previous_revoked, previous_client_id_ro, previous_revoked_ro (false at the answer), previous_ro_revocation{status: queued, audit, expected}, rotated_at, note, warning (only if the old read/write key could not be deleted)

  Errors:  401 reauth_required (a person's browser session signed in more than 10 minutes ago: POST /v1/auth/reauth, then retry; an API key is never asked), 404 warehouse_not_found, 409 ambiguous_name (two warehouses share the name; candidates[])

  CLI:  lhbox catalog rotate <name or id> [--show-secrets] [--yes]

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/warehouses/{warehouse_id}/uploaders → 201

A write-only credential for the catalog's blob bucket (<handle>--<catalog>--blobs; b-<uuid> on older catalogs; the answer's `bucket`), for a relay, a camera, a field device or a partner that only drops files: one IAM identity u-<warehouse_uuid>-<short> whose policy allows exactly s3:PutObject on arn:aws:s3:::<blob bucket>/<prefix>* (single and multipart uploads) and, only with list: true, s3:ListBucket on the blob bucket conditioned on s3:prefix <prefix>* (the device must list with ?prefix=). Nothing on the table bucket, no reads, no deletes, no catalog access (a catalog token minted from the pair is refused). The secret is in this answer once and is not stored, except sealed for an uploader given drop-box protocols (the drop box's relay signs as it; deleted at revocation). With protocols (ftp, ftps, sftp) the uploader also gets a login at the drop box (ftp.lakehousebox.com: FTP/FTPS on 21, passive 50000-50019; SFTP on 2222): username <handle>.<catalog>.<name>, a generated 24-character password shown once in `dropbox`, files of at most 256 MiB, relayed under the prefix within seconds; the plan's dropbox_uploaders counts them (10 by default). Cost: 2 IAM writes at the store (CreateUser, CreateAccessKey), plus the two statements merged into the blob bucket's policy (an S3 call); nothing is re-put later.

  Auth:  Authorization: Bearer <API key or token> — write level on the catalog

  Request:  {"name": …, "prefix"?: "photos/2026/", "list"?: false, "expires_at"?: null, "protocols"?: ["ftps", "sftp"]}

  Response:  uploader_id, warehouse_id, warehouse_name, name, prefix, list, iam_user, access_key_id, secret_access_key (once), dropbox{host, protocols, ftp_port, sftp_port, passive_ports, status, note, username, password (once)} or null, endpoint, bucket, region, scope, policy, iam_writes, expires_at, created_at, created_by, status, recipe{aws_cli, python_boto3, curl_presign_note}, note

  Errors:  401 reauth_required (a person's browser session signed in more than 10 minutes ago: POST /v1/auth/reauth, then retry; an API key is never asked), 400 bad_name / invalid_value (prefix grammar, list, expires_at, protocols), 403 forbidden (read level), 404 warehouse_not_found, 409 already_exists (an active uploader of that name, or the drop-box login is taken), 409 limit_exceeded (dropbox_uploaders), 502 storage_error

  CLI:  lhbox catalog uploader create <catalog> --name relay --prefix photos/ [--list] [--protocols ftps,sftp] (--save FILE | --show-key)

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/warehouses/{warehouse_id}/uploaders → 200

The catalog's uploaders without their secrets (never shown again); revoked ones only with ?include_revoked=true.

  Auth:  Authorization: Bearer <API key or token> — read level on the catalog

  Response:  warehouse_id, warehouse_name, bucket, count, uploaders[{uploader_id, name, prefix, list, iam_user, access_key_id, created_by, created_at, expires_at, revoked_at, status: active|expired|revoked}], note

  Errors:  404 warehouse_not_found

  CLI:  lhbox catalog uploader list <catalog> [--all]

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

### DELETE /v1/warehouses/{warehouse_id}/uploaders/{uploader_id} → 200

Revoke an uploader (by id or name): its access key is deleted at the store FIRST, so every request signed with it is refused from then on (a multipart upload in flight cannot complete), then its statements come off the blob bucket's policy and its identity is deleted: 2 IAM writes before the answer (3 for an uploader made before 2026-09-21, whose inline policy is deleted too). Idempotent: a revoked uploader answers 200 with already_revoked and retries whatever step is still pending. A catalog's deletion removes its uploaders' identities too.

  Auth:  Authorization: Bearer <API key or token> — write level on the catalog

  Response:  the uploader's row (status: revoked, revoked_at), revoked, already_revoked, storage{access_key, bucket_policy | user_policy, user}, iam_writes, note, warning (only if the key or the identity could not be deleted; revoked is true only when both are gone, since deleting the identity is what removes any other key it minted for itself)

  Errors:  403 forbidden (read level), 404 warehouse_not_found / uploader_not_found

  CLI:  lhbox catalog uploader revoke <catalog> --uploader <id|name> [--yes]

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/sinks → 201

An ingest sink for ONE existing, unpartitioned table: producers without an engine POST JSON arrays to its send route with the send-only key this answer shows once; the roller appends what is waiting to the table every roll_seconds (default 300, floor 60), or sooner when roll_bytes (32-64 MiB) or inactivity_seconds say so, stamping __ingest_ts. One sink per table. No IAM write and no catalog commit at creation: the first roll adds the optional __ingest_ts column and the metadata-retention properties. The send key is a hash on the sink row, never a principal: it can neither become a token nor hold a grant.

  Auth:  Authorization: Bearer <API key or token> — write level on the catalog

  Request:  {"name": …, "table": "<ns>.<table>", "warehouse": "<id or name>", "roll_seconds"?: 300, "roll_bytes"?: 67108864, "inactivity_seconds"?: null, "device"?: false, "device_require"?: {"field": …, "equals": …}, "device_strip"?: […], "mapping"?: {"preset": "ecowitt"} | {…}}

  Response:  sink_id, name, table, warehouse_id, warehouse_name, bucket, state, roll{seconds, bytes, inactivity_seconds, count}, prefix, ingest_url, send_url, send_key (once), send_key_id, table_changes{add_column, partition: none, properties, applied}, lag{}, received{}, rejects{}, device{}, mapping{}, iam_writes: [], created_at, note; with device: true also device_token (once), device_url, device_url_http, device_url_chars

  Errors:  401 reauth_required (a person's browser session signed in more than 10 minutes ago: POST /v1/auth/reauth, then retry; an API key is never asked), 400 bad_name / invalid_value / invalid_mapping / unknown_target_column / unknown_preset, 403 forbidden (read level), 404 warehouse_not_found / table_not_found (with the namespace's tables), 409 sink_exists / already_exists / partitioned_table_unsupported / limit_exceeded (device_sinks), 404 not_found while sinks are off

  CLI:  lhbox sink create --catalog <catalog> --table <ns>.<table> [--roll-seconds 300] [--roll-bytes N] [--inactivity-seconds N] (--save FILE | --show-key)

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/sinks → 200

The sinks of every catalog the caller holds read on (an admin of the organisation: every catalog), or of one with ?warehouse=, with lag, last roll and rejects; never a key. ?include_deleted=true adds the deleted ones.

  Auth:  Authorization: Bearer <API key or token> — read level

  Response:  count, sinks[{sink_id, name, table, warehouse_id, state, disabled_reason, roll{}, prefix, lag{batches_waiting, bytes_waiting, oldest_waiting_at, seconds_behind}, received{}, last_roll{}, rejects{}, created_at}], note

  Errors:  404 warehouse_not_found

  CLI:  lhbox sink list [--catalog <id or name>]

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

### GET /v1/sinks/{sink_id} → 200

One sink by id or name: state, roll policy, lag (bytes and batches waiting, seconds behind), last roll (at, batches, rows, rejected_rows, snapshot_id, seconds), rejects counters and where the rejects files are, totals received.

  Auth:  Authorization: Bearer <API key or token> — read level on its catalog

  Response:  the sink's row as in GET /v1/sinks plus warehouse_name, bucket (a sink of a catalog you cannot read is 404 sink_not_found)

  Errors:  404 sink_not_found, 409 ambiguous_name

  CLI:  lhbox sink get <sink id or name>

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

### DELETE /v1/sinks/{sink_id} → 202

Delete a sink: its send key is refused from now on, batches still waiting are discarded (the roller purges the intake prefix on its next tick; rejects files age out after 7 days), the table is untouched. 202.

  Auth:  Authorization: Bearer <API key or token> — write level on its catalog

  Response:  the sink's row (state: deleted, deleted_at), pending_batches_discarded, note

  Errors:  403 forbidden (read level), 404 sink_not_found

  CLI:  lhbox sink delete <sink id or name> [--force] [--yes]

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/sinks/{sink_id} → 202

The send route. Body: a JSON array of objects (<= 16 MiB); header Idempotency-Key: the batch id (1-128 chars of A-Z a-z 0-9 . _ : -). Each element's bytes are stored unchanged, one per line, as ONE NDJSON object under _sink/<sink_id>/<batch_id>.ndjson in the catalog's blob bucket, written as the catalog's own identity: no IAM write, no catalog call, one small S3 PUT. The same batch id again answers 200 duplicate without writing. Elements that are not objects are rejected here (reasons[]); whether a row fits the table's schema is decided at the roll and shows in GET /v1/sinks/{id} rejects. Intake bytes are metered as ingest_bytes (GET /v1/usage), not as storage.

  Auth:  Authorization: Bearer <API key or token> — anyone else is 403 forbidden; an MCP access token is 403 send_only. A send by a principal is audited as sink.send

  Request:  [ {…}, {…}, … ]   with headers Authorization: Bearer <send_key>, Idempotency-Key: <batch id>, Content-Type: application/json

  Response:  202 {accepted, rejected, reasons[{index, reason, got}], batch_id, sink_id, bytes, received_at, state: pending, note} | 200 {duplicate: true, batch_id, accepted, rejected, bytes, received_at, state: pending|committed}

  Errors:  400 not_an_array / empty_batch / missing_idempotency_key / invalid_batch_id, 401 invalid_key, 403 forbidden, 404 sink_not_found (deleted), 409 sink_disabled / quota_exceeded, 413 batch_too_large, 502 storage_error (safe to retry with the same batch id)

  CLI:  lhbox sink send <sink id> batch.json [--batch-id <id>]

### POST /d/{token} → 200

A sink's device route, for devices that cannot send a JSON array with Authorization and Idempotency-Key headers (a weather gateway posting a form over plain HTTP, a webhook posting one object). Body: form-urlencoded, one JSON object, a JSON array, NDJSON or CSV with a header, at most 256 KiB. The batch id is the Idempotency-Key when sent, else derived from the body, so a retried post answers 200 duplicate. A pinned body secret (device_require) is checked and, with device_strip, removed before anything is stored. Then the send route's store step (quota gate, one PUT of intake NDJSON). ?dry=1 answers the records it would store.

  Auth:  Authorization: Bearer <API key or token> — no header

  Request:  the device's body, any of the formats above

  Response:  202 {accepted, rejected, batch_id, sink_id, bytes, received_at, state: pending} | 200 {duplicate: true, …} | ?dry=1: 200 {dry_run: true, records, stripped, batch_id, accepted, rejected}

  Errors:  400 unreadable_body / empty_batch / invalid_batch_id, 401 invalid_device_token / invalid_device_secret, 409 sink_disabled / quota_exceeded, 413 body_too_large / batch_too_large (the body as NDJSON over 16 MiB), 415 unsupported_media_type, 429 rate_limited (Retry-After), 502 storage_error

### POST /v1/sinks/{sink_id}/test → 200

A dry run of the device route with your own credential: the body (any format POST /d/<token> takes, <= 256 KiB) is parsed, stripped and mapped exactly as the device route would, and nothing is stored or metered. The pinned secret is reported (device_secret: ok | mismatch | null), not required.

  Auth:  Authorization: Bearer <API key or token> — read level on its catalog

  Request:  the device's body, with its Content-Type

  Response:  dry_run: true, sink_id, batch_id, accepted, rejected, records, stripped, device_secret, bytes; with a mapping also mapping{preset, mapping_version}, rows, rows_total, rejects, unknown_fields

  Errors:  400 unreadable_body / empty_batch, 403 forbidden, 404 sink_not_found, 413 body_too_large, 415 unsupported_media_type

  CLI:  lhbox sink test <sink> <file> [--content-type TYPE]

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

### POST /v1/sinks/{sink_id}/device-token → 201

Give a sink a device token, or replace the one it has (the old one is refused from this answer on). Shown once; its SHA-256 is kept. POST /v1/sinks takes "device": true to make one at creation.

  Auth:  Authorization: Bearer <API key or token> — write level on its catalog

  Response:  the sink's row, device_token (once), device_url, device_url_http (with a dedicated device host), device_url_chars, note

  Errors:  401 reauth_required (a person's browser session signed in more than 10 minutes ago: POST /v1/auth/reauth, then retry; an API key is never asked), 403 forbidden (read level), 404 sink_not_found

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### DELETE /v1/sinks/{sink_id}/device-token → 200

Remove a sink's device route: the token is refused from now on; the send key and the sink go on.

  Auth:  Authorization: Bearer <API key or token> — write level on its catalog

  Response:  the sink's row (device.enabled: false), note

  Errors:  403 forbidden (read level), 404 sink_not_found

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### PATCH /v1/sinks/{sink_id} → 200

Change what a sink's device route checks and strips: device_require ({field, equals} or {field, equals_sha256}; null clears) and device_strip (field names; [] clears); the plain value is hashed at once and never stored. And its mapping: a declarative document (drop, rename, select, constants, timestamp, melt with a closed list of unit conversions) or {"preset": "ecowitt"}, validated against the table's current schema and applied by the roller to every batch not yet rolled (applies_to_pending_batches); null removes it. POST /v1/sinks takes the same mapping at creation.

  Auth:  Authorization: Bearer <API key or token> — write level on its catalog

  Request:  {"device_require"?: {"field": "PASSKEY", "equals": "…"} | null, "device_strip"?: ["model"], "mapping"?: {"preset": "ecowitt"} | {…} | null}

  Response:  the sink's row with device{enabled, require_field, strip, refused, created_at, last_used_at}, mapping{version, preset, params, rules, melt, unmatched, timestamp, mapping_version, updated_at}, warehouse_name, applies_to_pending_batches (with mapping)

  Errors:  400 invalid_value / invalid_mapping / unknown_target_column / unknown_preset / unknown_conversion / unknown_param, 403 forbidden (read level), 404 sink_not_found / table_not_found

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/indexes → 201

An index rule: every object uploaded under a prefix of the catalog's blob bucket becomes one row of an existing table, its columns read by extractors from a closed list (basic: key, name, size, etag, content type, times, uploader; image: width, height, EXIF time; filename_time: a time from the file name in a named zone; path_parts: named groups of the key; constant). The rule owns an internal sink on the table, so the rows are committed by the roller like any sink's; found within poll_seconds (default 60) and committed at the internal sink's next roll (roll_seconds, default 300). Append-only. Zero IAM writes; the plan's blob_indexes limits how many.

  Auth:  Authorization: Bearer <API key or token> — write level on the catalog

  Request:  {"name": …, "warehouse": …, "prefix": "camera/", "table": "<ns>.<table>", "extractors": ["basic", "image", …], "rename"?: {"last_modified": "uploaded_at"}, "poll_seconds"?: 60}

  Response:  index_id, name, prefix, table, extractors, rename, poll_seconds, state, sink_id, columns{stored, not_stored, produced}, bucket, iam_writes: [], note

  Errors:  400 invalid_extractors / invalid_value / missing_columns, 403 forbidden, 404 table_not_found, 409 sink_exists / already_exists / partitioned_table_unsupported / limit_exceeded (blob_indexes)

  CLI:  lhbox index create --catalog <catalog> --name <name> --prefix <prefix> --table <ns>.<table> --extractors '<json>' [--rename a=b]

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/indexes → 200

The index rules of every catalog the caller holds read on (an admin of the organisation: every catalog), or of one with ?warehouse=.

  Auth:  Authorization: Bearer <API key or token> — read level

  Response:  count, indexes[{index_id, name, prefix, table, extractors, poll_seconds, state, last_polled_at, objects_indexed_total, watermark, last_error, sink{lag, last_roll, rejects}}]

  CLI:  lhbox index list

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

### GET /v1/indexes/{index_id} → 200

One index rule by id or name: what it watches, its last poll, what it indexed, its internal sink's lag.

  Auth:  Authorization: Bearer <API key or token> — read level

  Response:  the rule as in GET /v1/indexes

  Errors:  404 index_not_found (also for a rule of a catalog you cannot read), 409 ambiguous_name

  CLI:  lhbox index get <index>

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

### DELETE /v1/indexes/{index_id} → 202

Stop indexing: the rule and its internal sink are deleted; the table, its rows and the uploaded objects stay.

  Auth:  Authorization: Bearer <API key or token> — write level

  Response:  the rule (state: deleted), note

  Errors:  403 forbidden, 404 index_not_found

  CLI:  lhbox index delete <index> [--yes]

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### PUT /v1/warehouses/{warehouse_id}/lifecycle → 200

Set the S3 lifecycle rules of the catalog's BLOB bucket (never the table bucket), replacing what was configured: each rule expires the objects under a prefix after N days (24 h each from the object's last write) or on a date, and/or aborts multipart uploads not completed after N days. The control plane writes the S3 LifecycleConfiguration XML and PUTs it as the tenant (the blob-bucket policy written at creation grants exactly that), then reads it back. The store's lifecycle worker passes daily and after every restart: deletion up to 24 h after an object is due, permanent; the storage meter follows at its next reading (every few minutes) or POST …/measure. Refused before the store sees it: expire_days 0 (the store accepts it and never fires it), any transition (no storage classes: 501 at the store), unknown fields, more than 1000 rules or 1 MiB. Tag and size filters are boto3's with the catalog credential; GET parses them back.

  Auth:  Authorization: Bearer <API key or token> — write level on the catalog

  Request:  {"rules": [{"id"?: "camera-90d", "prefix"?: "camera/", "expire_days"?: 90, "expire_date"?: "2026-12-31", "abort_incomplete_multipart_days"?: 7, "enabled"?: true}, …]}

  Response:  warehouse_id, name, bucket (the blob bucket), configured: true, rules[{id, prefix, enabled, expire_days, expire_date, abort_incomplete_multipart_days, expressible}], count, xml_bytes, note

  Errors:  400 invalid_value (expire_days 0 or not a whole number, both expiry forms, unknown field with `accepted`, duplicate ids, empty rules, the prefix grammar) / not_supported (a transition), 403 forbidden (read level), 404 warehouse_not_found, 413 too_large (over 1 MiB of XML), 502 storage_error

  CLI:  lhbox catalog lifecycle set <catalog> --prefix camera/ --expire-days 90 [--abort-multipart-days 7]

  Scope:  catalogs:write (an MCP access token; an API key has no scopes)

### GET /v1/warehouses/{warehouse_id}/lifecycle → 200

The lifecycle rules configured on the catalog's blob bucket, read from the store as the tenant and parsed back -- rules set with boto3 included: tags, size_greater_than / size_less_than and `other` (a Disabled Transition, NoncurrentVersionExpiration) appear on the rule and `expressible` says whether PUT here could re-create it. configured: false with an empty list when none is set. ?include_xml=true adds the stored document verbatim.

  Auth:  Authorization: Bearer <API key or token> — read level on the catalog

  Response:  warehouse_id, name, bucket, configured, rules[…], count, note, xml (only with ?include_xml=true)

  Errors:  404 warehouse_not_found, 502 storage_error

  CLI:  lhbox catalog lifecycle get <catalog> [--xml]

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

### GET /v1/warehouses/{warehouse_id}/blobs → 200

One page of the catalog's BLOB bucket, listed as the catalog's own identity (ListObjectsV2, never the admin key): objects {key, name, size, last_modified, etag} and, with a delimiter (default `/`), the common prefixes as folders {prefix, name} -- the sink's `_sink/` among them. ?prefix= descends; ?delimiter= (empty for a flat listing); ?token= continues a truncated page; ?max_keys= 1..1000 (clamped). ?urls=1 adds a presigned GET `url` (900 s) to every object, so the account page's Files section shows thumbnails without a request per file. Bytes never pass through the API: the browser loads them from the store. Uploader keys are S3 credentials, not API credentials: they cannot call this.

  Auth:  Authorization: Bearer <API key or token> — read level on the catalog

  Request:  ?prefix=&delimiter=/&token=&max_keys=1000&urls=0

  Response:  warehouse_id, name, bucket, prefix, delimiter, max_keys, objects[{key, name, size, last_modified, etag, url?}], prefixes[{prefix, name}], count, truncated, next_token, note, urls_expire_in + urls_expire_at (with ?urls=1)

  Errors:  400 invalid_prefix | invalid_delimiter | invalid_token, 403 forbidden (no read grant; can_grant names who can), 404 warehouse_not_found, 502 storage_error

  CLI:  lhbox file ls [prefix] --catalog <catalog>

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

### GET /v1/warehouses/{warehouse_id}/blobs/presign → 200

One presigned GET URL for an object of the catalog's blob bucket: AWS SigV4 query auth signed with the catalog's own key pair, on the store's public endpoint (path-style, `https://s3.<host>/<bucket>/<key>`), valid ?expires= seconds (900 by default, 30..3600, clamped). ?download=1 adds response-content-disposition=attachment (signed) so the store hands the file out under its name. The URL is bound to GET, this key and the expiry; the store verifies it, the API never sees the bytes. The key is not checked for existence: the store answers 404 to whoever follows the link.

  Auth:  Authorization: Bearer <API key or token> — read level on the catalog

  Request:  ?key=<object key>&expires=900&download=0

  Response:  warehouse_id, name, bucket, key, url, method (GET), expires_in, expires_at, download, note

  Errors:  400 invalid_key (empty, > 1024 bytes, control character, leading slash), 403 forbidden, 404 warehouse_not_found

  CLI:  lhbox file get <key> --catalog <catalog>

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

### POST /v1/warehouses/{warehouse_id}/blobs/upload → 200

One presigned PUT URL for a key of the catalog's file bucket: AWS SigV4 query auth signed with the catalog's own key pair, on the store's public endpoint (path-style), valid 900 s, bound to PUT, this key and (when given) this Content-Type and the owner's headers: cache_control (Cache-Control), content_disposition, content_language and metadata (each name sent as x-amz-meta-<name>), which the file keeps and the CDN of published files passes on. Values are printable ASCII on one line (no CR/LF), at most 512 characters; Cache-Control takes directive characters only; a file name in content_disposition must be a type the catalog's files accept; metadata at most 20 names and 2 KB. content_encoding and expires are refused (publish compressed data as .csv.gz; the CDN drops Expires). The caller PUTs the bytes to the store with exactly the returned headers; the API never sees them. A key ending in `/` with an empty body is a folder marker. The store enforces the storage limit and the accepted file types on the PUT itself (415 UnsupportedFileType, 403 over the limit). The platform's places are refused: every root key starting with `_` (feed intake, indexes, probe files) and the root `AGENTS.md` (its own route). The account page's Files section uses it for Upload and New folder. A file over 5 GB (or any large one) goes up in parts: POST …/blobs/multipart.

  Auth:  Authorization: Bearer <API key or token> — write level on the catalog

  Request:  {"key": "public/tiles.pmtiles", "content_type": "application/vnd.pmtiles", "cache_control": "public, max-age=86400", "content_disposition": "attachment; filename=\"tiles.pmtiles\"", "content_language": "en", "metadata": {"source": "ign"}}  (all but key optional)

  Response:  warehouse_id, name, bucket, key, url, method (PUT), headers (send exactly these), expires_in, expires_at, note

  Errors:  400 invalid_key | reserved_key | invalid_content_type | invalid_cache_control | invalid_content_disposition | invalid_content_language | invalid_metadata | unsupported_content_encoding | unsupported_expires, 403 forbidden (read only), 404 warehouse_not_found

  CLI:  lhbox file put <path> [key] --catalog <catalog> --cache-control

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/warehouses/{warehouse_id}/blobs/multipart → 200

Start a multipart upload of one key of the catalog's file bucket, for a large file: the key follows /blobs/upload's rules and must be a file type the file bucket accepts; size (bytes, at most 5 TiB) must fit the storage limit's headroom. content_type and the owner's headers (as for /blobs/upload) are stored with the upload, so the file carries them. The upload is started as the catalog's own identity. Answers upload_id, upload_token (the declared size, sealed: the complete wants it back and refuses parts that add up to more) and a plan: part_size (64 MiB, more for a file that would need over 10,000 parts) and part_count. Then: part URLs (…/multipart/parts), a PUT per part, …/multipart/complete; …/multipart/abort on failure. An open upload keeps its parts' bytes until it is aborted; a lifecycle rule with abort_incomplete_multipart_days clears forgotten ones. Audited.

  Auth:  Authorization: Bearer <API key or token> — write level on the catalog

  Request:  {"key": "tiles/world.pmtiles", "size": 2147483648, "content_type": "application/vnd.pmtiles", "cache_control": "public, max-age=86400"}

  Response:  warehouse_id, name, bucket, key, upload_id, upload_token, size, part_size, part_count, min_part_size, max_parts, headers, note

  Errors:  400 invalid_key | reserved_key | invalid_size | invalid_content_type | invalid_cache_control | … (as /blobs/upload), 403 forbidden (read only), 404 warehouse_not_found, 409 quota_exceeded (over the limit, or the size does not fit its headroom), 415 unsupported_file_type, 502 storage_error

  CLI:  lhbox file put <path> [key] --catalog <catalog> --part-size

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/warehouses/{warehouse_id}/blobs/multipart/parts → 200

Presigned PUT URLs for parts of an open multipart upload, 1 to 100 a call (part numbers 1..10000): each signed with the catalog's own key pair for PUT, this key, this upload and this part number, valid 900 s. PUT the part's bytes to its URL with no other header (every part but the last at least 5 MiB) and keep the answer's ETag. Ask for URLs as you go rather than all at once. Refused while the catalog's scope is over its storage limit.

  Auth:  Authorization: Bearer <API key or token> — write level on the catalog

  Request:  {"key": "tiles/world.pmtiles", "upload_id": "…", "part_numbers": [1, 2, 3, 4]}

  Response:  warehouse_id, bucket, key, upload_id, method (PUT), parts[{part_number, url}], expires_in, expires_at

  Errors:  400 invalid_key | reserved_key | invalid_upload_id | invalid_part_numbers, 403 forbidden, 404 warehouse_not_found, 409 quota_exceeded

  CLI:  lhbox file put <path> --parallel

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/warehouses/{warehouse_id}/blobs/multipart/complete → 200

Complete a multipart upload from the parts the store holds for it, with the create's upload_token. `parts` (each part's number and the ETag its PUT answered) is optional but recommended: it must name the same part numbers the store holds (409 parts_mismatch otherwise, nothing aborted), and it chooses between two copies of a part sent twice (without it, 409 parts_ambiguous). Every part but the last is at least 5 MiB. Parts adding up to more than the declared size abort the upload (409 size_exceeds_declared). The catalog is measured again (at most every 30 s): over the storage limit, the upload is aborted and its parts discarded (409 quota_exceeded, aborted: true). A completion the store does not answer in time is 504 completion_unknown: list the key to see whether it completed. Audited (catalog.blob_multipart_complete).

  Auth:  Authorization: Bearer <API key or token> — write level on the catalog

  Request:  {"key": "tiles/world.pmtiles", "upload_id": "…", "upload_token": "…", "parts": [{"part_number": 1, "etag": "…"}]}  (parts optional)

  Response:  warehouse_id, name, bucket, key, upload_id, size, parts, etag, completed

  Errors:  400 invalid_upload_id | invalid_upload_token | invalid_parts (a part but the last under 5 MiB) | no_parts, 403 forbidden, 404 warehouse_not_found | upload_not_found, 409 parts_mismatch | parts_ambiguous | size_exceeds_declared | quota_exceeded, 502 storage_error, 504 completion_unknown

  CLI:  lhbox file put <path>

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/warehouses/{warehouse_id}/blobs/multipart/abort → 200

Abort a multipart upload and discard its parts. Idempotent: an upload already completed or aborted answers aborted with was_open false. Audited (catalog.blob_multipart_abort).

  Auth:  Authorization: Bearer <API key or token> — write level on the catalog

  Request:  {"key": "tiles/world.pmtiles", "upload_id": "…"}

  Response:  warehouse_id, name, bucket, key, upload_id, aborted, was_open

  Errors:  400 invalid_key | invalid_upload_id, 403 forbidden, 404 warehouse_not_found, 502 storage_error

  CLI:  lhbox file put <path>

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### DELETE /v1/warehouses/{warehouse_id}/blobs → 200

Delete one object of the catalog's file bucket (?key=), as the catalog's own identity. One key, never a prefix: a folder goes when its last file and its marker do. Idempotent: a missing key answers as deleted. The platform's places are refused as for uploads. Audited (catalog.blob_delete).

  Auth:  Authorization: Bearer <API key or token> — write level on the catalog

  Request:  ?key=<object key>

  Response:  warehouse_id, name, bucket, key, deleted

  Errors:  400 invalid_key | reserved_key, 403 forbidden (read only), 404 warehouse_not_found, 502 storage_error

  CLI:  lhbox file rm <key> --catalog <catalog> --yes

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/warehouses/{warehouse_id}/agents-md → 200

The catalog's AGENTS.md: the object `AGENTS.md` at the root of its file bucket, the catalog's context for agents. Read as the catalog's own identity with one ranged GET: at most 64 KB of text (`truncated` beyond, and then no `sha256`). No AGENTS.md is `present: false`, never an error, and a read never creates the file bucket. `source` and `trust` label the text as the catalog writers' words: context about the data, not instructions. `public_url` while the catalog is public. ?text=false: presence, size and last change only (one HEAD; no text, no sha256).

  Auth:  Authorization: Bearer <API key or token> — read level on the catalog

  Request:  ?text=true

  Response:  warehouse_id, name, agents_md{key, bucket, s3_uri, public_url, present, size, last_modified, sha256, truncated, source, trust, text}, note

  Errors:  403 forbidden (no read grant), 404 warehouse_not_found, 502 storage_error

  CLI:  lhbox catalog agents-md <catalog>

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

  MCP tool:  list_tables, describe_table, read_agents_md

### PUT /v1/warehouses/{warehouse_id}/agents-md → 200

Write the catalog's AGENTS.md: UTF-8, at most 64 KB, no NUL, stored as text/markdown at the root of the file bucket (made now if the catalog has none yet). `base_sha256` guards against a lost update: absent overwrites, a hash writes only over exactly that content, null writes only when there is none yet. While the catalog is public the file is readable anonymously at once (`public_statement`: ok, private, or the error of the statement's write, which never fails the request). Audited as catalog.agents_md_update. Writing the object straight to the bucket (aws s3 cp, boto3) also works and is not audited.

  Auth:  Authorization: Bearer <API key or token> — write level on the catalog

  Request:  {"text": "<markdown>", "base_sha256": "<sha256 you edited>" | null}

  Response:  warehouse_id, name, public_statement, agents_md{key, bucket, s3_uri, public_url, present, size, last_modified, sha256, truncated, source, trust}

  Errors:  400 invalid_value (not a string, NUL, bad base_sha256), 403 forbidden (read level), 404 warehouse_not_found, 409 agents_md_changed (current_sha256), 413 too_large, 502 storage_error

  CLI:  lhbox catalog agents-md <catalog> --set FILE|-

  Scope:  catalogs:write (an MCP access token; an API key has no scopes)

  MCP tool:  set_agents_md

### DELETE /v1/warehouses/{warehouse_id}/agents-md → 200

Remove the catalog's AGENTS.md. Idempotent. Audited as catalog.agents_md_delete.

  Auth:  Authorization: Bearer <API key or token> — write level on the catalog

  Response:  warehouse_id, name, agents_md{..., present: false}

  Errors:  403 forbidden (read level), 404 warehouse_not_found, 502 storage_error

  CLI:  lhbox catalog agents-md <catalog> --remove

  Scope:  catalogs:write (an MCP access token; an API key has no scopes)

### DELETE /v1/warehouses/{warehouse_id}/lifecycle → 200

Remove every lifecycle rule from the catalog's blob bucket (DELETE ?lifecycle as the tenant). Idempotent: 200 with had_rules false when none was configured. Objects a rule already deleted are gone.

  Auth:  Authorization: Bearer <API key or token> — write level on the catalog

  Response:  warehouse_id, name, bucket, configured: false, had_rules, rules_removed, rules: [], count: 0, note

  Errors:  403 forbidden (read level), 404 warehouse_not_found, 502 storage_error

  CLI:  lhbox catalog lifecycle clear <catalog> [--yes]

  Scope:  catalogs:write (an MCP access token; an API key has no scopes)

### POST /v1/warehouses/{warehouse_id}/public → 200

Make a warehouse PUBLIC at the storage level: admin only, the warehouse name typed in the body. One plain bucket policy lets anyone read every object (data files and all retained metadata) with no credentials; writes stay refused. It also creates the catalog's public-reader identity, so any account can attach the catalog read-only by name (GET /v1/public/{handle}/{catalog}/connection), and anyone with no account attaches it read-only by name through the public catalog (GET /public-catalog/v1/config?warehouse=<handle>/<catalog>, which hands out no credential). Its bytes move to the public tier (5 GB; an operator raises a reviewed organisation to 50 GB); 409 quota_exceeded (tier public) when that would overflow. The catalog's file bucket stays private unless `files` names ONE folder of it: anyone may then GET (range requests included) and list the files under that folder, and nothing else of the file bucket. A new folder replaces the old one; `"files": null` stops publishing files and keeps the tables public; no `files` key changes nothing.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"confirm": "<the warehouse name, exactly>", "files": "<optional: one folder of the file bucket, e.g. public/; null to stop>"}

  Response:  warehouse_id, name, project_id, public: true, public_since, public_url, public_files_prefix, public_files_url (where readers read the folder), public_files_s3_url (the same folder at the S3 endpoint), public_note, public_by, policy, files_policy, tier: public, public_storage_bytes{…}, public_read{…}, bucket_quotas{quotas, errors}, cdn_purge ({status: pending | skipped} when a stopped or changed folder owes the CDN a purge, else null), warnings (while pending), note

  Errors:  400 confirmation_required, 400 invalid_files_prefix, 403 org_policy_forbids (the organisation's publish_catalogs setting: admins by default, member people on a catalog they can write, or nobody; an admin may always narrow a public catalog; remedy names the admins), 403 forbidden (a member without write, where members may publish), 403 person_required (a member token), 404 warehouse_not_found, 409 quota_exceeded (tier public), 502 storage_error

  CLI:  lhbox catalog publish <name> --confirm [--files <prefix>/ | --no-files]

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### DELETE /v1/warehouses/{warehouse_id}/public → 200

Make a public warehouse private again: admin only. The bucket policy is removed and anonymous reads are refused at once, the published folder of the file bucket included; bytes return to the private tier.

  Auth:  Authorization: Bearer <API key or token>

  Response:  warehouse_id, name, project_id, public: false, public_since: null, public_url: null, public_files_url: null, was_public, policy_removed, files_policy_removed, tier: private, storage_bytes{…}, bucket_quotas{quotas, errors}, cdn_purge ({status: pending | skipped}: the public-data edge's purge owed and running in the background, or nothing on the edge; null when it was not public and no purge is owed), warnings (while pending), note

  Errors:  403 org_policy_forbids (not an admin, unless the organisation lets members publish: then a member with write), 404 warehouse_not_found

  CLI:  lhbox catalog unpublish <name>

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/public/{handle}/{catalog}/connection → 200

Attach a PUBLIC catalog of any organisation by name, read-only: any account gets the catalog's public-reader key (catalog read only: no writes, no other catalog, no private files; shared by every reader, replaced when the owner unpublishes) and a DuckDB recipe that attaches every table in one statement. A catalog that is not public answers exactly as one that does not exist.

  Auth:  Authorization: Bearer <API key or token>

  Response:  handle, name, warehouse_id, read_only: true, bucket, warehouse_name, warehouse, catalog_uri, catalog_alias, catalog_credential{client_id, client_secret}, duckdb_sql, note, public_url, tables[{namespace, name, table, columns[{name, type, required, doc}]}] (at most 200; null with tables_note when the list could not be read)

  Errors:  401 unauthorized, 404 public_catalog_not_found, 409 public_catalog_ambiguous (two public catalogs of one name, from before readable bucket names), 503 public_reader_unavailable (Retry-After)

  CLI:  lhbox duckdb --catalog <handle>/<catalog>; lhbox connect --catalog <handle>/<catalog>

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

  MCP tool:  open_public_catalog

### POST /v1/sql/preview → 200

Run a scheduled-SQL query once and write nothing: rows, row count, Iceberg columns, the schema and format version create_table would make (format_version, else the catalog's default; a geometry under 2 is listed in problems), and whether the rows fit a table. Works without the scheduled-SQL feature and for an unclaimed organisation's key (a smaller budget, one at a time, sql_previews_per_hour; `sink_create` says what creating the sink still needs).

  Auth:  Authorization: Bearer <API key or token>

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/sinks/{sink_id}/run → 202

Queue one run of a scheduled SQL sink now, outside its schedule.

  Auth:  Authorization: Bearer <API key or token>

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/sinks/{sink_id}/dedupe → 202

Keep only the latest row per key in a keyed sink's whole table, once: the roller's next tick does it as one commit.

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox sink dedupe SINK [--wait]

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/sql/connections → 201

A named, write-only credential for scheduled SQL (bearer, custom header or basic), bound to one https URL prefix of one catalog: the executor sends it only to URLs under that prefix. No route answers the value.

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox sink connection create NAME --catalog C --url-prefix URL --bearer|--header NAME|--basic --value-env VAR|--value @FILE

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/sql/connections → 200

A catalog's connections: name, URL prefix, kind, header name, dates. Never a value.

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox sink connection list --catalog C

  Scope:  account:read (an MCP access token; an API key has no scopes)

### GET /v1/sql/connections/{name} → 200

One of a catalog's connections by name (?warehouse=), as the list shows it. Never its value.

  Auth:  Authorization: Bearer <API key or token>

  Errors:  404 connection_not_found

  Scope:  account:read (an MCP access token; an API key has no scopes)

### PUT /v1/sql/connections/{name} → 200

Replace a connection's value (a rotated key); the next preview or run uses it.

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox sink connection replace NAME --catalog C --value-env VAR|--value @FILE

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### DELETE /v1/sql/connections/{name} → 200

Delete a connection; the catalog's SQL sinks whose query names a URL under its prefix are paused (paused_reason connection_deleted).

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox sink connection delete NAME --catalog C --yes

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/sinks/{sink_id}/runs → 200

A scheduled SQL sink's runs: state, timings, rows, egress counters, errors, commits. Read level on the sink's catalog (a sink of a catalog you cannot read is 404 sink_not_found).

  Auth:  Authorization: Bearer <API key or token>

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

## Tables, credentials and connection recipes

### POST /v1/tables → 201

Create an Iceberg table through the catalog, as the warehouse's own identity. The namespace is created on demand. Engines can also create tables directly (DuckDB CREATE TABLE, PyIceberg create_table); this route exists for a schema with field ids, identifier fields and a chosen format version.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"warehouse_id": <id or name>, "namespace": …, "name": …, "schema": <Iceberg struct schema JSON>, "format_version": 3, "properties": {"write.target-file-size-bytes": "268435456", …}}   (format_version optional: 2 or 3; WITHOUT it the catalog's default_format_version applies, 2 unless changed with PATCH /v1/warehouses/{id}; geometry/geography columns need 3; GET /v1/config/formats says which engines write each. properties optional: the table's own Iceberg properties, the rules of PATCH /v1/table; format-version is not one of them. Every unpartitioned table gets write.target-file-size-bytes = 134217728 (128 MiB) and write.parquet.row-group-limit = 500000 (the layout of /docs/performance/), each unless the body sets its own value)   Header Idempotency-Key

  Response:  table (ns.name), warehouse_id, warehouse, namespace, name, table_uuid, location, format_version (the real one), format_version_requested, format_version_source (request | catalog_default), format_version_honoured, properties{} (as created), properties_defaulted[] (the keys LakehouseBox set because the body did not: write.target-file-size-bytes, write.parquet.row-group-limit), writers[], readers[], note (who writes and who only reads that version, from GET /v1/config/formats); for v3 also warnings[] and v3_columns[{name, type}] when the schema has geometry/geography columns

  Errors:  400 missing_field, 400 invalid_value (format_version not 2 or 3: versions{} says who writes each; or a geometry/geography column with an effective format version of 2: remedy says --format-version 3, catalog update --default-format-version 3, or WKB in a binary column; or a bad property key/value, or format-version sent as a property), 404 warehouse_not_found, 409 quota_exceeded (over the storage limit), 4xx/502 catalog_error (catalog_status, catalog_type, catalog_message)

  CLI:  lhbox table create <catalog>.<namespace>.<table> --column name:type …

  Scope:  catalogs:write (an MCP access token; an API key has no scopes)

### GET /v1/namespaces → 200

Namespaces in a catalog, read from the catalog service.

  Auth:  Authorization: Bearer <API key or token>

  Request:  ?warehouse=<id or name>  (warehouse or warehouse_id: both spellings name the catalog, by id or by name, on every route below)

  Response:  warehouse, warehouse_id, namespaces[{namespace}]

  CLI:  lhbox namespace list --catalog …

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

### GET /v1/tables → 200

Every table in a catalog, or in one namespace. detail=true adds each table's row_count, size_bytes, last_updated (the current snapshot's commit) and doc, read from its metadata and current snapshot summary (no data file is read; null where a summary lacks them). search= keeps the tables whose name, column names or column docs contain every word (a word one typo from a column name matches too), best first, and implies detail. At most 200 tables' metadata are read per call (note says how many were not).

  Auth:  Authorization: Bearer <API key or token>

  Request:  ?warehouse=<id or name>&namespace=<optional>&detail=true&search=<words>  (or warehouse_id=)

  Response:  warehouse, warehouse_id, namespaces_searched[], tables[{namespace, name, table, (detail) row_count, row_count_is_exact, size_bytes, last_updated, doc, (search) match{score, columns}}], search?, note?, namespace_grants? (a caller holding namespaces, not the catalog: only those are listed)

  Errors:  404 namespace_not_found (available[])

  CLI:  lhbox table list --catalog … [--namespace …]

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

  MCP tool:  list_tables

### GET /v1/public/{handle}/{catalog_name} → 200

A PUBLIC catalog's page without an account (PLAN §10.1): handle, name, catalog (handle/name), bucket, public_since, public_url, tables (each: namespace, name, columns with type and doc, row_count, last_updated, root_url, metadata_url) and recipes, a map of named entries (recipes.duckdb: no account and no credentials, one view per table; an entry may say what it requires). Only what the public bucket already exposes. A private catalog, an unknown handle or name, or a catalog that is not live answers the same 404 public_catalog_not_found. The listing is cached 60 s per catalog (one fill at a time; unpublishing takes effect at once); 30 requests a minute per address (burst 10), past it 429 rate_limited with Retry-After; a catalog that cannot be read answers 503 temporarily_unavailable.

  Auth:  none

  Request:  (no body, no Authorization)

### GET /public-catalog/v1/config → 200

The client's first call, ?warehouse=<handle>/<catalog>: answers the prefix of every later path, <handle>.<catalog>, and the read endpoints it serves (no write exists). 400 without a name. The public catalog: a read-only Iceberg REST catalog of every PUBLIC catalog, for engines attaching one by name with no account, served through https://public-catalog.lakehousebox.com: ATTACH '<handle>/<catalog>' (TYPE iceberg, ENDPOINT 'https://public-catalog.lakehousebox.com', AUTHORIZATION_TYPE 'none'). Iceberg's error model; anything that is not a live public catalog answers the same 404; answers cached 30 s at the edge; past 600 store reads a minute per address, 429.

  Auth:  none

  Request:  (no Authorization)

### GET /public-catalog/v1/{prefix}/namespaces → 200

The catalog's namespaces. The public catalog: a read-only Iceberg REST catalog of every PUBLIC catalog, for engines attaching one by name with no account, served through https://public-catalog.lakehousebox.com: ATTACH '<handle>/<catalog>' (TYPE iceberg, ENDPOINT 'https://public-catalog.lakehousebox.com', AUTHORIZATION_TYPE 'none'). Iceberg's error model; anything that is not a live public catalog answers the same 404; answers cached 30 s at the edge; past 600 store reads a minute per address, 429.

  Auth:  none

  Request:  (no Authorization)

### GET /public-catalog/v1/{prefix}/namespaces/{namespace} → 200

One namespace: its name (its properties are the owner's and are not passed on). HEAD answers whether it exists. The public catalog: a read-only Iceberg REST catalog of every PUBLIC catalog, for engines attaching one by name with no account, served through https://public-catalog.lakehousebox.com: ATTACH '<handle>/<catalog>' (TYPE iceberg, ENDPOINT 'https://public-catalog.lakehousebox.com', AUTHORIZATION_TYPE 'none'). Iceberg's error model; anything that is not a live public catalog answers the same 404; answers cached 30 s at the edge; past 600 store reads a minute per address, 429.

  Auth:  none

  Request:  (no Authorization)

### GET /public-catalog/v1/{prefix}/namespaces/{namespace}/tables → 200

The namespace's tables. The public catalog: a read-only Iceberg REST catalog of every PUBLIC catalog, for engines attaching one by name with no account, served through https://public-catalog.lakehousebox.com: ATTACH '<handle>/<catalog>' (TYPE iceberg, ENDPOINT 'https://public-catalog.lakehousebox.com', AUTHORIZATION_TYPE 'none'). Iceberg's error model; anything that is not a live public catalog answers the same 404; answers cached 30 s at the edge; past 600 store reads a minute per address, 429.

  Auth:  none

  Request:  (no Authorization)

### GET /public-catalog/v1/{prefix}/namespaces/{namespace}/tables/{table} → 200

One table: metadata-location, metadata, and a config naming where to read its files with no key (the public-data edge; empty keys, so a client never signs). Nothing else the store answers. HEAD answers whether it exists. The public catalog: a read-only Iceberg REST catalog of every PUBLIC catalog, for engines attaching one by name with no account, served through https://public-catalog.lakehousebox.com: ATTACH '<handle>/<catalog>' (TYPE iceberg, ENDPOINT 'https://public-catalog.lakehousebox.com', AUTHORIZATION_TYPE 'none'). Iceberg's error model; anything that is not a live public catalog answers the same 404; answers cached 30 s at the edge; past 600 store reads a minute per address, 429.

  Auth:  none

  Request:  (no Authorization)

### GET /v1/table → 200

Schema (with each column's doc), the real format version, sort_order, doc, last_updated, size_bytes, current_snapshot_summary and counts, read from the table's current metadata; snapshots=N adds snapshot_log, the last N commits newest first (committed_at, operation, added_records, deleted_records, total_records): the ingestion cadence and its gaps. rows is NET of delete files: the snapshot summary's total-records minus its position and equality delete counts when the writer left them, else counted from the manifest list and the delete manifests (rows_source says which path). Position deletes remove one row each, so the net is exact; with equality deletes (predicates) rows_is_exact is false and rows_note says why. identifier_field_ids are not a primary key: the catalog accepts duplicate rows.

  Auth:  Authorization: Bearer <API key or token>

  Request:  ?warehouse=<id or name>&namespace=…&name=…&snapshots=<0..50>  (or warehouse_id=)

  Response:  table, namespace, name, warehouse, warehouse_id, table_uuid, location, format_version, schema[{id, name, type, required}], identifier_field_ids[], partition_spec, snapshots, current_snapshot_id, rows, data_files, delete_files, rows_source (snapshot_summary | snapshot_summary_net_of_deletes | manifest_list | manifests | manifests_net_of_deletes | no_snapshot | no_manifest_list), rows_is_exact; when delete files exist also rows_gross, position_delete_rows, equality_delete_rows and, if inexact, rows_note; writers[], readers[], note (who writes and who only reads the version the table has, from GET /v1/config/formats); warnings[] for v3; properties{} (the table's Iceberg properties: format-version, geo.* for an imported GeoParquet, whatever PATCH /v1/table set)

  Errors:  404 namespace_not_found, 404 table_not_found (available[])

  CLI:  lhbox table get <catalog>.<namespace>.<table>

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

  MCP tool:  describe_table

### PATCH /v1/table → 200

Set and/or remove table properties, and describe the table and its columns: ONE commit through the catalog (Iceberg REST commitTable with set-properties / remove-properties, and add-schema / set-current-schema for column descriptions, as the catalog's own identity), visible to every engine on its next load. `doc` is the table's description (its `comment` property); `column_docs` gives top-level columns theirs (each column's Iceberg `doc`): `describe_table` and `GET /v1/table` show both, and `list_tables` search matches them. Write holders only; audited table.update with the previous values. `lhbox table import` uses it to keep a GeoParquet's `geo` metadata as geo.encoding, geo.crs, geo.columns, geo.primary_column (the convention in docs/engines). The format version is not a property this route changes.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"warehouse_id": <id or name>, "namespace": …, "name": …, "properties": {"geo.crs": "EPSG:4326", …}, "remove": ["key", …], "doc": "One row per …", "column_docs": {"ts": "When …", …}}  (any of properties, remove, doc, column_docs; property values are strings -- numbers and booleans are stringified, nested objects refused; keys 1-128 chars of letters, digits, ".", "_", "-"; a description is at most 4000 characters, null removes it; at most 200 columns a request)

  Response:  table (ns.name), namespace, name, warehouse, warehouse_id, properties{} (after the commit), set[] (keys set), removed[] (keys asked to be removed), columns_described[] (columns whose description changed), metadata_location. Descriptions as they already are commit nothing.

  Errors:  400 missing_field (none of properties, remove, doc, column_docs), 400 invalid_value (a bad key, a nested value, a value over 32768 characters, a description empty or over 4000 characters, or format-version: reserved[] says which keys are), 400 column_not_found (columns[], available[]), 403 forbidden (read level), 404 namespace_not_found, 404 table_not_found, 409 schema_changed (the schema changed while it ran; send it again), 4xx/502 catalog_error (the catalog's own refusal, relayed: catalog_status, catalog_type, catalog_message)

  CLI:  lhbox table set-properties <catalog>.<namespace>.<table> --property key=value … [--remove key …]; lhbox table document <catalog>.<namespace>.<table> --doc TEXT [--column name=TEXT …] [--from FILE]

  Scope:  catalogs:write (an MCP access token; an API key has no scopes)

  MCP tool:  document_table

### DELETE /v1/table → 200

Drop ONE table through the catalog (its DROP with purgeRequested=true, as the catalog's own identity): its data and metadata files go, the storage figure follows at the next reading. Write holders only; refused while a sink or a blob index rule writes to the table; in a PUBLIC catalog also force: true from an admin of the organisation, since its public URL stops serving the table. Audited table.delete with status dropped|failed. Cannot be undone; the CLI wants --catalog named, and asks unless --yes (never without a terminal or with --json).

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"warehouse_id": <id or name>, "namespace": …, "name": …, "force": true (public catalogs only)}

  Response:  dropped (ns.name), namespace, name, warehouse, warehouse_id, purged: true

  Errors:  400 missing_field, 403 forbidden (read level; or force in a public catalog without the admin role), 404 namespace_not_found, 404 table_not_found (available[] names the tables; already_dropped: true when another request dropped it first), 409 table_has_sinks (sinks[] names them), 409 public_catalog (a public catalog without force: true), 4xx/502 catalog_error

  CLI:  lhbox table delete <namespace>.<table> --catalog <name> [--yes] [--force]

  Scope:  catalogs:write (an MCP access token; an API key has no scopes)

### DELETE /v1/namespace → 200

Drop ONE EMPTY namespace through the catalog. A namespace that still holds tables or views is refused with them named: nothing is cascaded, drop each first. Refused while a sink targets a table of it. Write holders only; in a PUBLIC catalog also force: true from an admin. Audited namespace.delete with status dropped|failed.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"warehouse_id": <id or name>, "namespace": …, "force": true (public catalogs only)}

  Response:  dropped (the namespace), namespace, warehouse, warehouse_id

  Errors:  400 missing_field, 403 forbidden (read level; or force without the admin role), 404 namespace_not_found (already_dropped: true when another request dropped it first), 409 namespace_not_empty (tables[] and views[] name what is in it), 409 table_has_sinks, 409 public_catalog, 4xx/502 catalog_error

  CLI:  lhbox namespace delete <namespace> --catalog <name> [--yes] [--force]

  Scope:  catalogs:write (an MCP access token; an API key has no scopes)

### POST /v1/credentials → 200

Short-lived storage credentials scoped to ONE table's object prefix, vended by the catalog. For direct S3 access from a script; engines that load tables through the catalog obtain these themselves. A write holder gets a read/write credential (the catalog's read/write identity); a read holder gets a read-only one (its read-only identity): GetObject on that table's prefix, ListBucket of that table's path and GetBucketLocation; no write, and no other table's files (a listing of the path without its trailing slash can also name the files of a table whose name begins with this one's, not yet closed). The lifetime is fixed at one hour; a credential stops working at once when the key pair it was issued from is rotated (then ask again).

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"warehouse_id": <id or name>, "namespace": …, "name": …}  ("warehouse" is accepted for the same field)

  Response:  table, location, scope, level (read or write), access (read or read/write), identity (t-<uuid> or t-<uuid>-ro), storage{s3.access-key-id, s3.secret-access-key, s3.session-token, s3.endpoint, s3.session-token-expires-at-ms and the other s3.* keys the catalog vends}, expires_at, notes[] (only when the endpoint had to be rewritten for your network)

  Errors:  403 forbidden (level none; remedy names the grant command), 404 namespace_not_found, 404 table_not_found, 409 quota_exceeded (over the storage limit: no new write credential; a read holder's read-only one is still issued), 403 read_session_not_offered (the store vends a read holder a session that is not limited to reading this table: none is handed out, and asking again gets the same answer), 502 catalog_error

  CLI:  lhbox credentials <catalog>.<namespace>.<table>

  Scope:  catalogs:write (an MCP access token; an API key has no scopes)

### GET /v1/object → 200

One file of a catalog's blob bucket (a photo whose key an index table holds): an image (JPEG, PNG, WebP, GIF) comes back downscaled to max_dimension on its long edge as JPEG, a text file of 64 KB or less as text, anything else as metadata; every answer carries a 15-minute download_url. Read level on the catalog; nothing outside its blob bucket. The bytes are fetched and decoded on the query host in a throwaway process (an image past 60 megapixels or a corrupt file comes back as metadata, not decoded; photos are turned upright from their EXIF orientation, transparency lands on white). An MCP access token needs catalogs:read.

  Auth:  Authorization: Bearer <API key or token>

  Request:  ?catalog=<name or id>&key=<object key>&max_dimension=<64..4096, default 1500>

  Response:  kind (image|text|file), content_type, size_bytes, key; image: mime_type, data_base64, width, height, original_width, original_height; text: text; file: note; catalog, bucket, download_url, download_expires_at

  Errors:  400 invalid_key (the key), 400 invalid_value (max_dimension), 403 insufficient_scope (an MCP token without catalogs:read), 404 warehouse_not_found (a catalog you cannot read, as for one that does not exist), 404 not_found (the object), 429 busy (Retry-After) | rate_limited (query_requests_per_second; Retry-After), 502 store_error, 504 store_timeout, 503 query_unavailable

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

  MCP tool:  get_object

### POST /v1/query → 200

Runs one read statement (SELECT, WITH … SELECT, DESCRIBE, SUMMARIZE, EXPLAIN) in DuckDB on our side, over the catalogs you can read, and returns the rows: the path for hosts that cannot run DuckDB against LakehouseBox (no shell, or a sandbox that blocks the network). Read-only three ways (the parser, a READ_ONLY attach, the catalogs' read-only identities). No quota: 20 s per query in one queue every tenant shares, organisations taking turns, with queries_per_org running and queries_per_principal in flight at once, and query_requests_per_second per key (GET /v1/usage lists them); right after a busy, the same key is answered busy by the API for up to 2 s (answered_by: api). For anything heavier, run your own DuckDB (lhbox duckdb). An MCP access token needs catalogs:read. A repeat of a query on tables nobody has committed to since is answered from a result cache without running it or waiting in the queue (at most 15 minutes; 60 s when it uses the clock or chance; system views and table functions always run); cache: false runs it anyway. dry_run: true runs nothing and says what it would read, from each table's metadata.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"sql": "SELECT … FROM catalog.namespace.table …", "max_rows"?: 200, "cache"?: true, "dry_run"?: false}

  Response:  columns[{name, type}], rows[[…]], row_count, truncated, truncated_by (rows|characters), elapsed_ms, queued_ms, bytes_scanned, max_rows_applied (+ max_rows_requested, max_rows_note when a larger max_rows was reduced); at most max_rows (default 200, max 2,000) and ~50,000 characters; timestamps ISO 8601 UTC. cache{hit, stored_at, expires_at | not_cached (why), run (a hit: the figures of the run that made it)}; snapshots[{table, snapshot_id, committed_at}] when its tables were pinned (a hit has queued_ms and bytes_scanned 0). With dry_run: dry_run, statement, tables[{table, snapshot_id, committed_at, rows, data_files, bytes} | {table, unknown}], reads_at_most{rows, data_files, bytes} (an upper bound: every live file of every table read; null when a table or a figure is unknown), cached, volatile, note (+ tables_note)

  Errors:  400 missing_field | invalid_value (max_rows, cache, dry_run) | read_only | one_statement | function_not_allowed | not_allowed (the sandbox refused a file, URL or setting) | sql_error | catalog_required | namespace_required (a bare SHOW TABLES: SHOW ALL TABLES or SHOW TABLES FROM catalog.namespace) | table_name_required | cte_name_not_allowed (a CTE named like a system view or a file or URL: rename it) | table_not_allowed (DuckDB's system catalog: pg_catalog, main.duckdb_*, system, temp) | ambiguous_catalog, 403 insufficient_scope (an MCP token without catalogs:read), 404 not_found (a catalog you cannot read, as for one that does not exist), 408 query_timeout, 429 busy (Retry-After; `limit` names the full one: queries_per_principal | queries_waiting_per_org | max_wait_seconds | pool_workers (an account an agent made without one, until it is claimed, shares a few workers with every such account; past them the answer comes at once and its remedy says how to run the query in your own DuckDB, with public_catalogs and public_recipe when it read public catalogs); for up to 2 s after one, answered by the API itself with answered_by: api and last_limit), 429 rate_limited (query_requests_per_second; Retry-After), 502 catalog_unavailable, 503 provisioning (Retry-After) | query_unavailable

  CLI:  lhbox query "SELECT …" [--max-rows N] [--dry-run]

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

  MCP tool:  query

### GET /v1/connection → 200

A paste-ready recipe for your engine, with the catalog credential of the identity matching your level inside: write holders get t-<uuid> (reads and writes), read holders get t-<uuid>-ro (the catalog refuses its commits, the store its PUT/DELETE). With ?level=read a write holder gets the read-only identity's recipe too (created on first need): the one to hand a colleague who should read the catalog, or to attach in a session that must not write -- the account page's Catalog explorer attaches every catalog this way, and takes a pasted read-only recipe under 'Add catalog'. The snowflake recipe is the read-only identity (static keys, ALLOW_WRITES = FALSE) unless a write holder asks for level=write: then the read/write identity, ALLOW_WRITES = TRUE and a catalog-linked database. LakehouseBox is never in the query path: the engine talks to the catalog and the object store directly. The API returns the pair; the CLI prints the secret as ******** wherever the recipe repeats it unless --show-secrets (a masked DuckDB recipe starts with an error() that says so), and the sample code reads it from the variables credential_env names. An MCP access token never gets the static pair (it would outlive the connection; a public catalog's shared reader key at GET /v1/public/{handle}/{catalog}/connection is public by design): its answer is credentials: vended_only with level, catalog_uri and, for a write holder, vended_credentials naming POST /v1/credentials (about an hour, one table). A caller holding namespace-level grants and nothing on the catalog gets its own identity's recipe (duckdb and pyiceberg only), level_via namespace, and namespaces[{namespace, level, tables}]: the catalog does not list namespaces to it, so the recipe names them; level=read with a write namespace grant answers 409 read_recipe_unavailable. A catalog-level grant (PUT .../grants with a level) ends the principal's namespace grants on the catalog (namespace_grants_ended). A recipe whose key pair writes, asked with a browser session or an identity provider's sign-in, needs a recent sign-in (the session's login or POST /v1/auth/reauth within the last 10 minutes; the provider's auth_time): 401 reauth_required otherwise, its reauth field saying how. The read-only recipe and an API key are not asked, nor an account without a password (Google sign-in only) or a provider that sends no auth_time.

  Auth:  Authorization: Bearer <API key or token>

  Request:  ?warehouse_id=<id or name>&engine=duckdb&level=read  (or warehouse=; duckdb default, pyiceberg, spark, snowflake, trino, polars, clickhouse (clickhouse always read-only, level=write is a 400; snowflake read-only unless level=write, which also records an audit event); level optional: read for the read-only recipe whatever you hold, write needs write, absent = the level you hold; edge_auto=1: for a catalog with edge caching on, DuckDB's read recipe through the CDN, else the storage's; edge=true: the CDN or a refusal saying why; edge=false: the storage; /docs/edge-caching/)

  Response:  engine, catalog_uri, warehouse (s3://<bucket>/), warehouse_name (the table bucket: <handle>--<catalog>, or w-<uuid> on older catalogs), level (read|write), level_via (your grant's via, engine for snowflake, requested when ?level=read lowered it), identity (t-<uuid> | t-<uuid>-ro), catalog_credential{client_id, client_secret, oauth2_server_uri, grant: client_credentials, note}, storage_endpoint, blob_bucket (<handle>--<catalog>--blobs), blob_note, blob_example_python (boto3; reads the pair from the environment), credential_env{client_id, client_secret, note} (the environment variables the sample code reads the pair from: LHBOX_<ALIAS>[_RO]_CLIENT_ID / _CLIENT_SECRET), token_endpoint, token_exchange{…}, warehouse_id, project_id, format_version_hint{default (the catalog's default_format_version), note, versions{"2", "3": {writers, readers, note}}} (always present; the note says the catalog prefers 3 when it does), notes[] (the first says the level), and per engine: sql (duckdb, snowflake) | python (reads the pair from the variables credential_env names, never inlined) + catalog_credential_string (pyiceberg: the pair as the one "<client_id>:<client_secret>" string RestCatalog(credential=…) takes) | conf{spark.sql.catalog.<alias>.*} (spark); catalog_alias (duckdb, spark: the catalog's name, else lake) -- two catalogs' recipes paste into one session without colliding

  Errors:  400 unsupported_engine (available[]), 400 invalid_value (level other than read|write), 401 reauth_required (a recipe that writes, to a sign-in that is not recent: reauth names how to renew it), 403 forbidden (no grant on the warehouse, or level=write held at read; can_grant[] names who can; can_grant_self true when that includes the caller, an admin token, which grants itself), 404 warehouse_not_found, 503 provisioning + Retry-After: 30 (the catalog's storage has not accepted a write yet: status, retry_after_seconds, remedy; retry after it -- the control plane probes again every 30 s for 10 min)

  CLI:  lhbox connect --catalog … --engine duckdb|pyiceberg|spark|snowflake|trino|polars|clickhouse [--readonly|--write] [--show-secrets]

  Scope:  catalogs:read (an MCP access token; an API key has no scopes)

### GET /v1/config/formats → 200

Which Iceberg format versions a table here can have, the default for LakehouseBox's own create paths, and for each version the engines that write it and those that only read it -- the ONE list (FORMAT_WRITERS in the control plane) every other sentence about format versions is rendered from. Public, like /v1/auth/config: a CLI, an agent or a page reads this instead of carrying its own list. Version strings are machine-parseable, `<engine>[>=<version>[+<lib>-<version>]]` (duckdb>=1.5.5, spark>=3.5+iceberg-1.11, pyiceberg>=0.12, spark); the prose is in note. A catalog's own default is default_format_version on GET /v1/warehouses/{id}.

  Auth:  none

  Response:  default (2), versions{"2": {writers[], readers[], note}, "3": {writers[], readers[], note}}, v3_only_types[] (geometry, geography), note

## Organisations

### GET /v1/orgs → 200

The organisations you belong to and your role in each; for a token, the organisation that owns it and the token's role. domain_offers lists the organisations you may join because they verified and opened your address's domain (nothing is joined until you accept one with POST /v1/orgs/{org_id}/join).

  Auth:  Authorization: Bearer <API key or token>

  Response:  organizations[{org_id, name, handle, status, role (admin|member), domain_join, deprovision, policy{publish_catalogs, connect_agents, hosted_agents} (GET /v1/orgs/{org_id}/policy), domains[{domain, verified, verified_at, verified_via, txt_name, txt_value}], members, created_at}], domain_offers[{org_id, name, handle, domain, role, accept}]

  CLI:  lhbox org list

  Scope:  account:read (an MCP access token; an API key has no scopes)

### PATCH /v1/orgs/{org_id} → 200

Rename the organisation, or open it to its verified domains: people whose address is on one are then offered membership (domain_offers) and join only if they accept; nobody is added automatically. deprovision chooses what removing a member stops: 'tokens' (the default: their tokens and connections) or 'everything' (also the sinks, blob uploaders and hosted agents they made).

  Auth:  Authorization: Bearer <API key or token> — admin of the organisation

  Request:  {"name"?, "domain_join"?: true|false, "deprovision"?: "tokens"|"everything"}

  Response:  the organisation as in GET /v1/orgs

  Errors:  403 insufficient_role, 400 no_verified_domain (claim and verify a domain first), 400 invalid_value

  CLI:  lhbox org set [--name …] [--domain-join on|off] [--deprovision tokens|everything]

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/orgs/{org_id}/policy → 200

What the organisation's members may do: who may publish catalogs (publish_catalogs: admins, the default; members, on a catalog they can write; or nobody), who may connect agents -- tokens, `lhbox login`, MCP clients and personal API keys -- (connect_agents: members, the default, or admins) and who may create hosted agents (hosted_agents: members, the default, or admins). Also what you may do, whom to ask, and the catalogs public now.

  Auth:  Authorization: Bearer <API key or token> — any member or token

  Response:  org_id, policy{publish_catalogs, connect_agents, hosted_agents}, settings[{key, value, allowed[], default, description}], your_role, you_may{key: bool}, editable (you are an admin and a person), admins[] (whom to ask), public_catalogs[{warehouse_id, name, public_since, public_by}]

  Errors:  404 org_not_found

  CLI:  lhbox org policy

  Scope:  account:read (an MCP access token; an API key has no scopes)

### PATCH /v1/orgs/{org_id}/policy → 200

Change what the organisation's members may do. A refused action answers 403 org_policy_forbids with the setting, its value and a remedy naming the admins. Nothing made before is undone: catalogs public now stay public (listed in public_catalogs, with a note, for you to unpublish), connections and hosted agents keep working until revoked or deleted. Audited as org.policy_update with each change's before and after.

  Auth:  Authorization: Bearer <API key or token> — a person, admin

  Request:  {"publish_catalogs"?: "admins"|"members"|"nobody", "connect_agents"?: "members"|"admins", "hosted_agents"?: "members"|"admins"}

  Response:  as GET, plus changed{key: {from, to}} and note (when catalogs stay public)

  Errors:  403 insufficient_role|person_required (a token, whatever its role), 400 invalid_field|invalid_value

  CLI:  lhbox org policy [--publish-catalogs admins|members|nobody] [--connect-agents members|admins] [--hosted-agents members|admins]

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/orgs/{org_id}/domains → 201

Claim an email domain. The claim does nothing until it is verified: publish the TXT record the answer names (_lakehousebox.<domain>, a random value per claim), then call .../verify. Public mail domains (gmail.com, outlook.com, gmx.de, yahoo.co.uk, …) are refused outright. Several organisations may hold an unverified claim; the first to verify holds the domain alone. A claim on a domain another organisation holds verified is recorded too (held_by_another_organisation): it verifies only once that organisation's own TXT value is gone from the record.

  Auth:  Authorization: Bearer <API key or token> — admin

  Request:  {"domain": "yourcompany.com"}

  Response:  the organisation, plus claim{domain, verified, txt_name, txt_value, verify}, note, next, held_by_another_organisation (only when true)

  Errors:  400 public_mail_domain, 400 invalid_value, 400 too_many_claims (20 unverified)

  CLI:  lhbox org domain --domain …

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/orgs/{org_id}/domains/{domain}/verify → 200

Verify a claim: the control plane looks up the TXT record _lakehousebox.<domain> and compares it with the claim's value. The record must be a TXT record at that very name: an answer reached through a CNAME verifies nothing. When another organisation holds the domain verified and its own value is gone from the record, the domain passes to this claim (took_over; the other claim is unverified). Only a verified domain can be opened (PATCH domain_join). A verified claim is looked up again once a day; three definite misses in a row (no such name, or the value absent; never a resolver failure) unverify it, and so do 7 days without any answer.

  Auth:  Authorization: Bearer <API key or token> — admin

  Response:  the organisation, plus claim{…, verified: true}, already_verified, took_over (only when true)

  Errors:  404 domain_not_found (also when the claim was released during the lookup), 409 domain_not_verified (with txt_name, txt_value, records_found), 409 domain_claimed (another organisation's value is still published), 429 rate_limited (30 lookups an hour per organisation), 503 dns_unavailable

  CLI:  lhbox org domain --domain … --verify

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### DELETE /v1/orgs/{org_id}/domains/{domain} → 200

Release a claimed domain, verified or not. Members who joined through it stay; nobody new is offered it, and domain join switches itself off when no verified domain is left.

  Auth:  Authorization: Bearer <API key or token> — admin

  Response:  the organisation, plus removed: <domain>

  Errors:  404 domain_not_found

  CLI:  lhbox org domain --domain … --remove

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/orgs/{org_id}/join → 200

Accept a domain offer: join, as a member, an organisation that verified and opened your address's domain (GET /v1/orgs lists them as domain_offers). Accepting is the person's own act, done with their own login or API key; an organisation's token is offered nothing. Idempotent for a member. A person who left the organisation or was removed from it (matched by account, and by address across a new account; remembered for 12 months) is not let back in this way; an admin's invitation is.

  Auth:  Authorization: Bearer <API key or token> — a person

  Response:  the organisation, plus already_member

  Errors:  403 left_organisation, 404 no_domain_offer, 403 idp_org_mismatch (signed in through another organisation's own identity provider, which joins its person to that organisation only)

  CLI:  lhbox org join --org …

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/orgs/{org_id}/members → 200

Who is in the organisation.

  Auth:  Authorization: Bearer <API key or token> — any member

  Response:  org_id, members[{principal_id, role, created_at, display_name, email, kind}]

  CLI:  lhbox org members

  Scope:  account:read (an MCP access token; an API key has no scopes)

### PATCH /v1/orgs/{org_id}/members/{member_id} → 200

Change a member's role. Two roles, admin and member ("owner" is accepted as an alias for admin for one version and never returned). An organisation always keeps at least one admin.

  Auth:  Authorization: Bearer <API key or token> — admin

  Request:  {"role": "admin"|"member"}

  Response:  org_id, principal_id, role

  Errors:  404 member_not_found, 409 last_admin

  CLI:  lhbox org role --principal … --role admin|member

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### DELETE /v1/orgs/{org_id}/members/{member_id} → 200

Remove a member (admin), or leave the organisation (your own principal_id). The last admin can do neither. Every token the person created in the organisation, and every token those created, is revoked in the same request (their `lhbox login` and MCP tokens included), and the invitations they or those tokens sent that nobody accepted are withdrawn. Sinks, blob uploaders and hosted agents they made keep working and are listed in left_behind for an admin to keep or delete, unless the organisation's deprovision setting is 'everything': then they are stopped too and listed in stopped.

  Auth:  Authorization: Bearer <API key or token> — admin, or any member removing themselves

  Response:  org_id, principal_id, removed: true, left, tokens_revoked[], invitations_withdrawn, left_behind{sinks[], uploaders[], agents[]}, stopped{sinks[], indexes[], uploaders[], agents[]} and warning (deprovision 'everything' only), keys_rotated[] and keys_rotated_note (when keys rotate), suspended, own_api_keys_revoked, own_sessions_revoked (an account the organisation manages), note

  Errors:  404 member_not_found, 403 insufficient_role, 409 last_admin

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/orgs/{org_id}/invitations → 201

Invite an address. The token is returned once (and mailed when the deployment has a mail sender). If the address signs up later, the invitation is applied automatically. One live invitation per address: inviting it again refreshes it (same invitation_id, a new link and 7 days; the old link stops working; refreshed: true). The mail names the sender by the verified address of the person behind the call (for a token: the person who created it), which must be verified where the deployment verifies addresses. At most 20 invitations per person and 50 per organisation in any hour, re-sends included.

  Auth:  Authorization: Bearer <API key or token> — admin

  Request:  {"email": …, "role": "member"|"admin"}

  Response:  org_id, invitation_id, email, role, token (shown once), mailed, expires_in (7 days), refreshed, accept_url, accept_with, note

  Errors:  403 insufficient_role, 403 inviter_unknown, 403 inviter_unverified, 429 rate_limited (Retry-After; limit, per: person|organisation)

  CLI:  lhbox org invite --email … --role member|admin

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/orgs/{org_id}/invitations → 200

The organisation's pending invitations, expired ones included (expired: true); never the token. Accepted and withdrawn invitations are not listed.

  Auth:  Authorization: Bearer <API key or token> — admin

  Response:  org_id, invitations[{invitation_id, email, role, invited_by, created_at, expires_at, expired}]

  Errors:  403 insufficient_role

  Scope:  account:read (an MCP access token; an API key has no scopes)

### DELETE /v1/orgs/{org_id}/invitations/{invitation_id} → 200

Withdraw a pending invitation: its link stops working (404 invitation_not_found) and signing up or logging in with the address no longer applies it. An accepted invitation is a membership: remove the member instead.

  Auth:  Authorization: Bearer <API key or token> — admin

  Response:  org_id, invitation_id, email, withdrawn: true

  Errors:  404 invitation_not_found, 403 insufficient_role, 409 invitation_accepted

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/orgs/invitations/accept → 200

Redeem an invitation. The caller's email must be the invited one. A withdrawn invitation is not found, and neither is one whose sender could not send it now (removed, suspended, revoked, or no longer an admin). Already a member: the invitation is used up and the role held is kept and answered, with already_member: true (a role changes through PATCH /v1/orgs/{org_id}/members/{member_id}).

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"token": …}

  Response:  the organisation

  Errors:  404 invitation_not_found, 410 invitation_expired, 403 invitation_for_someone_else, 403 idp_org_mismatch (signed in through another organisation's own identity provider, which joins its person to that organisation only)

  CLI:  lhbox org accept --token …

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

## Tokens

### POST /v1/orgs/{org_id}/tokens → 201

A token: a machine identity of the organisation with its own API key, revocable on its own, holding exactly the grants given here and an organisation role (admin or member, default member) the organisation routes evaluate exactly as a human member's -- so a token can do everything a human can. An empty or omitted grants list means no warehouse until granted (a warehouse a member token was not granted is 404 for it; an admin token sees every warehouse with its level, and may grant itself). Admins give any grants and either role; members at most their own level on each warehouse and the member role only. Role governs the organisation, grants govern data. Tokens never count for last-admin protection. The pre-contract `warehouses`/`warehouse_ids` list is read as write grants for one more version and answered with a `deprecation` note. `sinks` (sink ids) lets the token send batches to those sinks with its own key (POST /v1/sinks/{sink_id}); giving one needs write on the sink's catalog (an admin is uncapped), and the token must hold read on that catalog in the same request; the right ends with that read grant.

  Auth:  Authorization: Bearer <API key or token> — admin, or a member within their own grants and role (a token is the member or admin it was made)

  Request:  {"name": …, "role"?: "admin"|"member", "grants"?: [{"warehouse_id": …, "level": "read"|"write"}, …], "sinks"?: [sink_id, …]}

  Response:  token_id, name, org_id, role, grants[{warehouse_id, name, level}], sinks[{sink_id, name, warehouse_id, catalog}], created_by, created_by_display, created_at, last_used_at, suspended, warehouses (legacy), api_key (shown once), key_id, note

  Errors:  401 reauth_required (a person's browser session signed in more than 10 minutes ago: POST /v1/auth/reauth, then retry; an API key is never asked), 403 org_policy_forbids (the organisation lets only admins connect agents: connect_agents), 400 invalid_value (grants, role), 400 sink_needs_read (a sink without read on its catalog), 403 forbidden (a level above your own: your_level, requested), 403 insufficient_role (an admin token from a member), 403 creator_revoked (the calling token was revoked while the request was in flight), 404 warehouse_not_found (not in this organisation), 404 sink_not_found (not a live send sink of this organisation), 409 already_exists (name)

  CLI:  lhbox token create --name … [--role admin|member] [--grant <warehouse_id>:read|write …] [--send <sink> …]

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/orgs/{org_id}/tokens → 200

The organisation's tokens with their role and grants, who created each and when its key was last used. A member token is shown only the grants on catalogs it can read itself; grants_hidden counts the others.

  Auth:  Authorization: Bearer <API key or token> — any member

  Response:  org_id, tokens[{token_id, name, role, grants[{warehouse_id, name, level}], sinks[{sink_id, name, warehouse_id, catalog}], grants_hidden (member tokens only, when some are hidden), created_by, created_by_display, created_at, last_used_at, suspended, warehouses (legacy), note (legacy unconfined tokens), key_id, expires_at (null unless an organisation that enforces single sign-on makes the key of the person who approved or made the token expire)}]

  CLI:  lhbox token list

  Scope:  account:read (an MCP access token; an API key has no scopes)

### GET /v1/orgs/{org_id}/tokens/{token_id} → 200

One token by id or by name: the same view as the list (grants as [{warehouse_id, name, level}] on every token route since 2026-09-21), its connection block, creator and last use. Never the key.

  Auth:  Authorization: Bearer <API key or token> — any member

  Response:  the token as in GET /v1/orgs/{org_id}/tokens

  Errors:  404 token_not_found

  Scope:  account:read (an MCP access token; an API key has no scopes)

### PATCH /v1/orgs/{org_id}/tokens/{token_id} → 200

Edit a token's name, grants and/or role; the path names the token by id or by name. The grants list replaces the current one and is capped by the editor's own grants, the role by the editor's own role (an admin is uncapped; a token never edits itself unless it is an admin). Editing a legacy unconfined token confines it. Audited with the previous role.

  Auth:  Authorization: Bearer <API key or token> — admin, or the member who created it

  Request:  {"name"?: …, "role"?: "admin"|"member", "grants"?: [{"warehouse_id": …, "level": …}], "sinks"?: [sink_id, …] (replaces; [] clears)}

  Response:  the token as in GET

  Errors:  400 missing_field, 403 forbidden, 403 insufficient_role, 404 token_not_found, 409 already_exists

  CLI:  lhbox token update <id or name> [--name …] [--role admin|member] [--grant <warehouse_id>:read|write …] [--send <sink> …]

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### DELETE /v1/orgs/{org_id}/tokens/{token_id} → 200

Every key of the token is revoked and the token suspended: it stops at once. So does every token it created, transitively (a token may mint tokens at up to its own level; revoking a leaked token stops the tokens it minted): the whole subtree in one transaction, each child audited as token.revoke with via=cascade, and a token being minted meanwhile by a member of the subtree is refused (403 creator_revoked on POST .../tokens). A descendant revoked earlier keeps its own timestamp; revoking a token that is already revoked sweeps its subtree again. Blob uploaders and sink keys the token created are not tokens: they stay until revoked on their own (DELETE /v1/warehouses/{id}/uploaders/{uploader_id}, the sink's routes). A hosted agent the token or one below it created is paused for good (paused_reason creator_token_revoked), even one already paused, and a run in progress stops at its next call; its identity is not revoked (its runs stay readable), but nobody resumes, runs or tests it again (409 agent_creator_gone): an admin deletes it, or creates a new agent under their own name from its definition. The path names the token by id or by name.

  Auth:  Authorization: Bearer <API key or token> — admin, the member who created it, or the token itself (lhbox logout)

  Response:  token_id, revoked: true, kind, sessions_revoked, cascaded[] (the ids of the tokens below it this call stopped), agents_paused[] (agent_id, name, paused_now; only when there were any)

  Errors:  403 forbidden (neither an admin nor its creator), 404 token_not_found

  CLI:  lhbox token revoke <id or name> [--yes]

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

## Hosted agents

### POST /v1/agents → 201

Create an agent: a prompt LakehouseBox runs next to your data, with read grants on the named catalogs, optional one-line description (at most 200 characters, shown in the lists), schedule, memory keys, email to its owner and integrations. 403 feature_not_enabled when the organisation has no hosted agents. Integrations and web keys are attached by the person who connected them, signed in as themselves: a token is 403 integration_needs_person. 403 org_policy_forbids when the organisation lets only its admins create hosted agents (hosted_agents setting).

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox agent create <name> --prompt TEXT|@FILE --catalog <catalog>

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/agents → 200

The organisation's agents and its allowance (tokens this month, runs today). A member token is not shown the agents that read a catalog it cannot read. may_create: the organisation's hosted_agents setting lets you create one.

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox agent list

  Scope:  account:read (an MCP access token; an API key has no scopes)

### POST /v1/agents/schedule-preview → 200

The next five run times of a schedule, checked against the organisation's limits as a save would be.

  Auth:  Authorization: Bearer <API key or token>

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/agents/models → 200

The models LakehouseBox provides: where they run, whether they see images, how fast each uses the allowance (allowance_multiplier, in tokens of the default model) and its price per million tokens.

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox agent models

  Scope:  account:read (an MCP access token; an API key has no scopes)

### GET /v1/agents/{agent} → 200

One agent: its current definition, catalogs, schedule, memory and last run. `this_key` says what the calling key may do with it, from the same checks the routes make: {manage, read_runs, runs_closed_because} (`you_created` is about the person behind the key, which a member token of the creator is not: it does not manage the agent).

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox agent show <name>

  Scope:  account:read (an MCP access token; an API key has no scopes)

### PATCH /v1/agents/{agent} → 200

A new version of the definition (only the fields named change), new read grants, or pause/resume. 400 invalid_field for a field it does not know. An agent that uses (or, before this change, used) its creator's integrations is changed by that person only, signed in as themselves (a token: 403 integration_needs_person) -- or by a token that person approved with "manage agents" including their integrations, and then only prompt, description, model, schedule, run_seconds, state_keys, notify_owner and paused; each such change is mailed to the person with the command that undoes it. restore_version: <version id> (alone) puts an earlier definition back whole, under the same checks as the fields it changes (404 version_not_found). Anyone who manages it may pause it. Attaching integrations answers integration_review (the prompt and write targets they will run); confirm_definition: true approves a definition another credential wrote, so it may use the person's integrations. An agent whose creator is gone (the view's creator_gone: creator_token_revoked, for good, or owner_gone, while its person is not in the organisation) is resumed by nobody: 409 agent_creator_gone, whose remedy is a new agent under the admin's own name; deleting it stays open. Write tables are held to its creator's level: an edit adding a table its creator cannot write answers 409 creator_cannot_write, and while its creator holds less than write on a table it writes nobody resumes it (409 creator_cannot_write, with the tables; dropping those tables in the same request lifts it).

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox agent update <name> [--prompt ...] [--catalog ...] [--pause|--resume] [--restore-version V]

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### DELETE /v1/agents/{agent} → 200

Stop an agent for good: its identity is suspended; its runs stay readable.

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox agent delete <name> --yes

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/agents/{agent}/run → 202

Run an agent now (202). `{"dry_run": true}`: a test run (its email and memory writes are recorded, not performed; outside the day's runs, with its own cap). 429 daily_runs_used, dry_runs_used or allowance_exhausted; 409 while a run is going, and 409 agent_creator_gone (a test run too) for an agent whose creator is gone; 409 creator_cannot_write (a test run too, whoever asks) while its creator holds less than write on a table it writes.

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox agent run <name> [--dry-run] [--wait]

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/agents/{agent}/subscribers/me → 200

Email me this agent's notices: the caller subscribes themselves, never anyone else. Needs a verified address and read on every catalog the agent reads; for an agent that uses (or used) a person's integrations, its creator and admins only (403 cannot_subscribe); each notice carries the person's own stop link.

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox agent subscribe <name>

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### DELETE /v1/agents/{agent}/subscribers/me → 200

Stop receiving this agent's notices (the caller's own subscription).

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox agent unsubscribe <name>

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/agents/{agent}/brief → 200

What needs the owner first (what the agent flagged, decisions due, a pause, a failed last run, missed slots, failed tool calls, the allowance), then its memory as the agent sees it, its recent runs and the allowance. No model. Whoever may read the agent's memory; for an agent that used a person's integrations, a person only (`withheld`).

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox agent brief <name>

  Scope:  account:read (an MCP access token; an API key has no scopes)

  MCP tool:  agent_brief

### POST /v1/agents/{agent}/talk → 200

`{message, about?}` (`about`: `{topic, item}` of an entry the agent flagged or has due): the agent answers from its brief, its prompt and the last exchanges in `reply`, and may propose a `proposal`: a new prompt (`before`, `after`) and items to mark done, which replaces any proposal still open. Nothing changes when it proposes: the person's next message decides it (a yes applies the stored proposal, a no drops it; `acted` says which: `applied`, `discarded`, `not_applied`), or `.../apply` and `.../discard` do. Whoever may change the agent; for an agent that used a person's integrations, that person. Charged to the inference allowance (429 `allowance_exhausted`; 429 `busy` when many talks are under way); 502 `agent_did_not_answer` when the model failed (nothing changed).

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox agent talk <name> "<message>" [--about <topic>/<item>]

  Scope:  account:manage (an MCP access token; an API key has no scopes)

  MCP tool:  talk_to_agent

### GET /v1/agents/{agent}/talk → 200

The latest exchanges with the agent, newest first (`limit`, at most 50): message, reply, proposal and its state (`proposed`, `applied`, `discarded`, `superseded`, `answered`, `not_applied` (a yes that could not be applied), `failed`). Who may talk to it.

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox agent talk <name>

  Scope:  account:read (an MCP access token; an API key has no scopes)

### POST /v1/agents/{agent}/talk/{talk_id}/apply → 200

Apply a proposal: the prompt becomes a new version of the agent (as an edit would) and the items are marked done in its memory as the person's. 409 `agent_changed` when the agent was edited since the proposal; 409 `talk_decided` when it was already applied or discarded.

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox agent talk <name> "yes"

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/agents/{agent}/talk/{talk_id}/discard → 200

Discard a proposal; nothing changes.

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox agent talk <name> "no"

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/agents/{agent}/runs → 200

An agent's runs: state, tokens, the final message, errors; `detail=true` adds each run's record. Who reads them: an admin, the agent's creator, and anyone of the organisation (a person or a token) who holds read on every catalog the agent was ever given (a run's output and memory carry what earlier runs read), except for an agent that ever used a person's integrations or that ran before that record was kept (2026-11-02); such a reader sees no memory and no notices (`view: catalog_reader`). Otherwise 403 not_agent_manager with `runs_closed_because` (integrations, history_unknown, no_catalogs, catalog_deleted, catalogs_not_held); for a token it also names the token and its role. For an agent that uses or used a person's integrations, a token gets the runs without their final message and no `detail` (403 integration_needs_person): only people read what those runs did.

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox agent runs <name> [--detail]

  Scope:  account:read (an MCP access token; an API key has no scopes)

### GET /v1/agent-runs/{run_id} → 200

One run with its record: every tool call in order (a run_sql call with its SQL), the emails it sent, its memory before and after. Readable by whoever reads the agent's runs (GET /v1/agents/{agent}/runs). For an agent that uses or used a person's integrations, people only (a token: 403 integration_needs_person).

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox agent run <name> --wait

  Scope:  account:read (an MCP access token; an API key has no scopes)

### POST /v1/integrations → 201

Connect an MCP server: OAuth (answers a link to open in a browser signed in to LakehouseBox), an API key, or none; the server's tools are listed. Or store a web API's key (`auth: "web_key"`, `host`, `header_name` or `key_param`) for an agent's fetch_url.

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox integration add <name> --mcp URL

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/integrations → 200

The organisation's integrations (metadata only, never a credential): an admin sees all, a member their own.

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox integration list

  Scope:  account:read (an MCP access token; an API key has no scopes)

### GET /v1/integrations/{ref} → 200

One integration: its state, scopes, tools and the agents that use it; never a credential.

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox integration show <name>

  Scope:  account:read (an MCP access token; an API key has no scopes)

### DELETE /v1/integrations/{ref} → 200

Revoke at the provider and wipe the stored credential.

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox integration delete <name> --yes

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/integrations/{ref}/reconnect → 200

A new authorize link, for the person who connected it (needs_reauth, failed, an expired link).

  Auth:  Authorization: Bearer <API key or token>

  CLI:  lhbox integration reconnect <name>

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

## API keys, terms and account

### GET /v1/auth/config → 200

What a browser or a CLI needs to log a human in: whether the deployment serves the email + password login (password_login; the form at /app/, docs/login.md) and the external OpenID Connect issuers a self-hosted deployment trusts (device-code endpoints). The account page shows the form, the "Log in with <issuer>" button, or both, from this answer; it hides "Create an account" when signup is false.

  Auth:  none

  Response:  issuer, password_login (true when the deployment serves POST /v1/auth/login), signup (false where self-registration is closed), registration{open, ways_in[]}, mfa_required (password accounts must add a second factor), security_profile (ens, or null), signup_endpoint (null when signup is closed), passkeys ({rp_id, login_endpoint} when passkey sign-in is on, else null), external_issuers[{issuer, client_id, authorization_endpoint, token_endpoint, device_authorization_endpoint, jwks_uri}], api_key_exchange

### POST /v1/api-keys → 201

Another API key for the caller (for a second machine, a CI job). Shown once. From a person's browser session it needs a recent sign-in (the login, or POST /v1/auth/reauth, within the last 10 minutes); from an identity provider's token, a recent sign-in there (auth_time); with the person's API key nothing more is asked. A person is mailed a notice naming the key (never the key).

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"label": "ci"}

  Response:  api_key, key_id, label, principal_id, note

  Errors:  401 reauth_required (reauth: POST /v1/auth/reauth, or identity_provider; needs[] names the fields), 403 org_policy_forbids (a key acts as you in every organisation you belong to: refused while one of them lets only its admins connect agents and you are a member there)

  CLI:  lhbox api-key create --label …

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/api-keys → 200

The caller's keys, by id: never the secret.

  Auth:  Authorization: Bearer <API key or token>

  Response:  api_keys[{key_id, label, created_at, last_used_at, revoked_at, renewed_at, expires_at (null unless an organisation that enforces single sign-on makes the key expire)}]

  CLI:  lhbox api-key list

  Scope:  account:read (an MCP access token; an API key has no scopes)

### GET /v1/api-keys/{key_id} → 200

One of your keys by id, as the list shows it: never the secret.

  Auth:  Authorization: Bearer <API key or token>

  Response:  key_id, label, created_at, last_used_at, revoked_at, renewed_at, expires_at

  Errors:  404 api_key_not_found

  Scope:  account:read (an MCP access token; an API key has no scopes)

### PATCH /v1/api-keys/{key_id} → 200

Relabel one of your keys. Nothing else about a key changes: make another instead.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"label": …}

  Response:  key_id, label, created_at, last_used_at, revoked_at, renewed_at, expires_at

  Errors:  400 unknown_field, 404 api_key_not_found

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### DELETE /v1/api-keys/{key_id} → 200

Revoke one of your keys. The key id is the part between al_live_ and the second underscore.

  Auth:  Authorization: Bearer <API key or token>

  Response:  key_id, revoked: true

  Errors:  404 api_key_not_found

  CLI:  lhbox api-key revoke --key-id … [--yes]

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/onboard → 200

For a caller who arrived with a key or an identity-provider token: make sure an organisation, a project and a warehouse named `lake` exist and hand back the recipe. First login without a signup: organization_name + handle name the organisation (a handle is derived from the name when absent); an open invitation for the address is applied instead. An email domain never places anyone: an organisation that verified and opened the address's domain is only offered afterwards (domain_offers; POST /v1/orgs/{org_id}/join). Idempotent: an existing organisation and warehouse are returned, not duplicated. A sign-in that GET /v1/connection would ask to confirm before the pair that writes gets the read-only recipe here instead (or none, when its grants have no read-only form): onboarding never asks.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"engine"?: "duckdb", "accept_terms"?: true, "organization_name"?, "handle"?, "org_id"?}

  Response:  created (bool), warehouse_id, connection, project_id (when created), organization{org_id, name, handle, status, role, joined_via (created|invitation|signup-form|member)}, terms{version, accepted, url}, domain_offers[], note

  Errors:  403 idp_org_not_member (signed in through an organisation's own identity provider, which places its person in that organisation only, and not a member of it), 403 registration_closed (self-hosted, registration closed: the deployment creates no account by self-registration: a person who belongs to no organisation is not given a new one; remedy names the ways in), 400 terms_required (a human who has not accepted the current Terms), 400 invalid_value (handle), 409 handle_taken (suggestion)

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/terms → 200

The Terms version in force and whether you accepted it.

  Auth:  Authorization: Bearer <API key or token>

  Response:  current_version, terms_url, privacy_url, accepted_current, history[{version, accepted_at, via}]

  CLI:  lhbox terms

  Scope:  account:read (an MCP access token; an API key has no scopes)

### POST /v1/terms/accept → 200

Record acceptance of the current Terms version. A person's act: a token (kind token or mcp) is refused; Terms bind the account holder and a token needs no acceptance of its own.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"version"?: <the current version>}

  Response:  accepted: true, version

  Errors:  403 not_a_person (a token), 409 terms_version_mismatch

  CLI:  lhbox terms --accept

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### DELETE /v1/me → 200

Delete your account. A person confirms with the email and the current password, and a code when two-factor authentication is on, whatever credential makes the call (an account that never set a password confirms with its own API key and the email alone, or with a sign-in at its identity provider in the last 10 minutes; a token confirms with its principal_id). Immediate: every API key revoked, every session revoked, identities unlinked, memberships removed; a credential of the deleted account answers 401 account_deleted from then on. An organisation you are the only member of is deleted in the same request, with its tokens and invitations, and so are your own catalogs outside any organisation; those catalogs are unreachable at once and kept for the grace period (7 days, until purge_after), then purged with their storage; backups expire within 14 days after that (Terms). Catalogs you made in an organisation that keeps members stay: they are the organisation's. Every token you created, in any organisation, and every token those created, is revoked with the account.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"confirm": "<your email>", "current_password": "…", "code"?: "123456" (two-factor on)}  or, from a browser session, {"confirm": …, "current_password": …, "passkey": <assertion for POST /v1/auth/passkeys/confirm/start {"purpose": "account_delete"}>}

  Response:  deleted: true, principal_id, keys_revoked, tokens_revoked[], organisations_deleted[], warehouses_deleted[], warehouses_retained[{warehouse_id, name, purge_after}] (each in its grace period, with its own date), purge_after (the earliest), note (organisations_left_empty[] and warehouses_to_purge[], the old contract's fields: always empty now, nothing is left to purge by hand)

  Errors:  400 confirmation_required, 401 reauth_required (needs[] names the fields to send; reauth: identity_provider for an identity provider's token), 401 invalid_credentials, 401 mfa_code_required, 401 invalid_mfa_code, 401 invalid_passkey, 409 last_admin (make someone else an admin first), 409 delete_in_progress (an organisation another deletion holds: refused before the code is checked), 409 password_required

  CLI:  lhbox account delete --confirm <your email> [--password-env VAR] [--code …]

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### GET /v1/me/news → 200

Whether you receive the LakehouseBox product-news email (about once a month), since when and from where, and the exact words of the consent.

  Auth:  Authorization: Bearer <API key or token>

  Response:  enabled, can_subscribe (a person with a verified address, on a deployment with the list), subscribed, since, source (signup-form|account-page), consent{version, text, privacy_url}

  Scope:  account:read (an MCP access token; an API key has no scopes)

### PUT /v1/me/news → 200

Turn the product-news email on or off for your own account (a person with a verified address; a token has no inbox). Turning it on records your consent: the words' version, the time and your IP. Idempotent.

  Auth:  Authorization: Bearer <API key or token>

  Request:  {"subscribed": true|false}

  Response:  as GET, plus changed

  Errors:  400 invalid_value, 403 not_a_person, 409 email_not_verified, 409 news_disabled

  MCP:  an MCP access token is refused here whatever its scopes (403 mcp_human_only): a person or an API key does this

### POST /v1/news/unsubscribe → 200

The unsubscribe link in every newsletter, no login: the token as ?t=… (the List-Unsubscribe one-click POST, RFC 8058) or {"token": …}. The same answer whether or not the address was subscribed.

  Auth:  none

  Request:  ?t=<token> or {"token": …}

  Response:  unsubscribed: true, message

  Errors:  400 invalid_token

### POST /v1/onboarding/unsubscribe → 200

The unsubscribe link in every getting-started email, no login: the token as ?t=… (the List-Unsubscribe one-click POST, RFC 8058) or {"token": …}. Stops those emails only; the newsletter and account emails are separate. The same answer whether or not you had unsubscribed.

  Auth:  none

  Request:  ?t=<token> or {"token": …}

  Response:  unsubscribed: true, message

  Errors:  400 invalid_token

## Usage and limits

### GET /v1/usage → 200

Every limit, the current value and the headroom. Storage is the store's own per-bucket figure, read every few minutes (the latest sample of every warehouse in the scope, summed; Iceberg metadata files, which the store keeps inline, are not counted, and objects is approximate; 'unmetered' until the first sample, which the control plane takes ~2 minutes after a catalog is created); every storage figure carries measured_at and stale_after (when the next reading will have replaced it), and POST /v1/warehouses/{id}/measure refreshes one catalog now. The storage limit applies to the organisation, or to the project when it has none. Over the limit, warehouse and table creation and credential vending answer 409 quota_exceeded and the buckets go read-only at the next enforcement pass. Read this before provisioning; an agent should never meet a limit by failing. A token's answer also names `acting_for` (the person it acts for, with their role in each organisation) and `token_cannot` (what no token does: a token acts with its own role and grants, not those of the person who made it; `lhbox login` signs in as the person, not as a token). Per warehouse it also shows usage that is NOT billed today: the latest footprint split by bucket, one row per UTC day for the last `days` days (the day's last footprint, and the S3 requests and bytes in/out counted that day from the store's per-bucket counters), the footprint trend over the window and the window's traffic totals. `traffic` is null with a `traffic_note` until two consecutive samples carry counters (the maintenance sweep reads them every 6 hours); no object keys and no query contents are collected. The per-catalog rows (warehouse_ids, warehouse_storage) list only the catalogs the caller holds read on (an admin of the organisation: all of them); the totals and the catalog count stay the scope's, since the limits are measured against them (so they include catalogs the caller cannot see).

  Auth:  Authorization: Bearer <API key or token>

  Request:  ?project_id=<optional id or name>&days=<1..30, default 7: the per-catalog history length>

  Response:  principal_id, plan, limits{storage_bytes, public_storage_bytes, objects, warehouses, projects, catalog_operations_per_month, commits_per_month, min_seconds_between_commits, snapshot_retention_days, max_snapshots_per_table, …, query_seconds, query_max_rows, queries_per_org, queries_per_principal, query_requests_per_second}, limits_detail{<limit>: {value, enforced: true|false, enforcement}} (storage, catalogs per project, projects per account, snapshot retention and the query limits are enforced; objects and commit spacing are guidelines: engines commit straight to the catalog; tables and namespaces are not limited), limits_note, history_days, projects[{project_id, name, org_id, warehouses{current, limit, headroom}, warehouse_ids[], storage_bytes{current, limit, headroom, unmetered, measured_at, stale_after, complete, scope, scope_id, warehouses_metered, warehouses, retained_catalogs, retained_bytes (catalogs in their deletion grace period, in the tier they were in), over_limit}, public_storage_bytes{…}, warehouse_storage[{warehouse_id, name, tier, public, bytes, objects, measured_at, unmetered, stale_after, source: store|creation|on_demand|sweep, measuring, buckets{table, blob}, table_bytes, blob_bytes, blob_objects, complete, history[{day, bytes, objects, blob_bytes, blob_objects, complete, measured_at, samples, requests, requests_failed, bytes_in, bytes_out, intervals}], trend{days, from, to, bytes_from, bytes_to, bytes_delta, objects_delta, days_measured} | null, traffic{days, requests, requests_failed, bytes_in, bytes_out, intervals, resets, since, to, source} | null, traffic_note}]}], organizations[{org_id, storage_bytes{…}, public_storage_bytes{…}, role (the caller's: a member's or a token's)}], note

  CLI:  lhbox usage [--full] [--catalog <id or name>] [--days N]  (also: lhbox whoami; both print a brief object unless --full)

  Scope:  account:read (an MCP access token; an API key has no scopes)

  MCP tool:  list_tables (the caller part; skipped without account:read)

### GET /v1/audit → 200

Who did what. Without org_id: your own events. With org_id: an admin sees the organisation's events plus their own; a member only their own within it. Actions include signup.start/verify/complete, recovery.start/verify (before 2026-10-01), api_key.create/revoke, project.create, warehouse.create/rotate, table.create, credentials.vend, org.create/update/domain_claim/domain_verify/domain_unverify/domain_release/invite/invitation_accept/member_join/member_role/member_remove, org.reserve/activate, token.create/update/revoke (agent.* before 2026-09-29; a token.* filter finds both), warehouse.grant/grant_revoke/delete/delete_revoked/purge/restore/delete_complete/rotate_complete/measure, terms.accept, account.delete, quota.reject.

  Auth:  Authorization: Bearer <API key or token>

  Request:  ?org_id=<optional>&since=<24h|7d|30m|ISO-8601>&action=<name or prefix*>&limit=<1..1000, default 100>

  Response:  events[{id, ts, principal_id, org_id, action, target_type, target_id, ip, request_id, detail{}}], count, limit, scope (own|organization), org_id, since, action, retention_days (400)

  Errors:  404 org_not_found

  CLI:  lhbox audit [--org …] [--since 24h|7d|30m|<ISO-8601>] [--action credentials.vend|org.*] [--limit N]

  Scope:  account:read (an MCP access token; an API key has no scopes)

## Back office (platform admins)

### GET /v1/backoffice/whoami → 200

Whether the caller may use the back office, and as whom (the page asks this first).

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Response:  kind (admin|operator), actor, principal_id, email

  Errors:  403 platform_admin_required | mfa_setup_required | mfa_session_required, 401 invalid_credential

### GET /v1/backoffice/overview → 200

The platform in numbers, from the control plane's database: organisations (active in 7/30 days), human accounts (verified, pending, with two-factor), tokens, catalogs, sign-ups per day and per week, bytes stored (the latest sample of every catalog) and the top organisations by bytes, S3 traffic over 7 days, logins, and what the control plane sees of its host (the filesystem holding its state; the rest is in the daily numbers report, linked). Tables are not counted here (null, with a note).

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Request:  ?days=30 (7-180, the sign-ups window)

  Response:  generated_at, organizations{total, active, pending, active_7d, active_30d, without_members}, humans{total, verified, pending, suspended, with_2fa}, tokens, catalogs{total, public, provisioning}, tables (null), signups{per_day[], per_week[], last_7d}, logins_7d, storage{bytes, objects, catalogs_measured, top_organizations[]}, traffic_7d{requests, requests_failed, bytes_in, bytes_out, catalogs_counted}, host{state_db_bytes, state_filesystem, numbers_report_url, note}

### GET /v1/backoffice/users → 200

Human accounts, newest first, searched by email, name or organisation (q, substring, any case). Carries email addresses: personal data, to the admin's browser only.

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Request:  ?q=&limit=50&offset=0

  Response:  users[{principal_id, email, name, created_at, verified, pending, suspended, last_login, mfa, platform_admin, organizations[{org_id, handle, name, status, role, plan}], catalogs, bytes}], count, total, limit, offset, q

### GET /v1/backoffice/orgs → 200

Organisations, newest first, searched by handle or name.

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Request:  ?q=&limit=50&offset=0

  Response:  organizations[{org_id, handle, name, status, created_at, members, last_activity, catalogs, bytes, plan}], count, total, limit, offset, q

### GET /v1/backoffice/orgs/{org_id} → 200

One organisation (id or handle): members with last login and two-factor, tokens, catalogs with their latest sizes, plan and limits, the last 30 audited actions.

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Response:  org_id, handle, name, status, created_at, domain_join, members[], tokens, catalogs[{warehouse_id, name, bucket, status, public, tier_policy, created_at, bytes, objects, measured_at}], bytes, limits (the view of GET /v1/admin/orgs/{org}/limits), audit[]

  Errors:  404 org_not_found

### GET /v1/backoffice/install → 200

A self-hosted install's Install view, read-only (platform/docs/self-hosting.md "Administering an install"): the release (version, commit, signed, key fingerprint), every security setting's effective value and where it is set (.env, the security profile, the default; a secret only as set or not set) beside the ENS guide's value, the identity providers and SCIM, the SIEM export's counters per sink, the last backups, storage in use and the install administrators. Nothing here edits the configuration.

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Response:  generated_at, deployment, release{version, commit, signed, key_fingerprint, loaded_at, note}, security{profile, settings[{env, what, value, source, where, ens, matches_ens, measure}], note}, identity{password_login, issuers[], organization_providers, scim{}}, audit_export{enabled, sinks{}}, backups{state, store, note}, storage{bytes, catalogs, measured_at}, people{}, admins{granted[], from_env[]}

  CLI:  lhbox admin status [--login EMAIL]

### POST /v1/backoffice/orgs → 201

A new organisation and its first admin: an existing person (a verified, active account) becomes its admin at once; any other address gets an admin invitation (mailed when the install has mail; its link answered once). Works with self-registration closed. Audited org.create and org.member_join or org.invite (via backoffice).

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Request:  {name, handle?, admin_email}

  Response:  org_id, name, handle, admin{how (member | invitation), email, principal_id?, previous_role?, invitation_id?, mailed?, accept_url?}

  Errors:  409 handle_taken | principal_suspended, 400 invalid_value

  CLI:  lhbox admin org create <handle> --admin EMAIL [--name N]

### PUT /v1/backoffice/orgs/{org_ref}/admins/{email} → 200

Make an address an admin of the organisation: a member is promoted, an existing person added, any other address invited as admin. Audited (via backoffice).

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Response:  org_id, handle, admin{…}

  Errors:  404 org_not_found, 409 principal_suspended

  CLI:  lhbox admin org admin add <org> <email>

### DELETE /v1/backoffice/orgs/{org_ref}/admins/{member_ref} → 200

An admin of the organisation becomes a member, with what a demotion owes (their tokens capped, their keys rotated); an open admin invitation of the address is withdrawn instead. Audited org.member_role.

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Response:  org_id, principal_id, role (member), keys_rotated? | withdrawn_invitation

  Errors:  404 member_not_found, 409 last_admin | not_an_admin | role_from_group_rule

  CLI:  lhbox admin org admin remove <org> <email>

### POST /v1/backoffice/principals/{principal_ref}/suspend → 200

Suspend a person (reversible): login, sessions, API keys and every token they made are refused until reactivated; the catalog keys they fetched are rotated. Not oneself; another install administrator only with the operator key. Audited principal.suspend.

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Request:  {reason?}

  Response:  principal_id, suspended, changed, keys_rotated

  Errors:  404 principal_not_found, 403 forbidden | admin_target, 409 account_deleted

  CLI:  lhbox admin person suspend <email> [--reason R]

### POST /v1/backoffice/principals/{principal_ref}/reactivate → 200

Lift a suspension; refused for a deleted account and for one the organisation's directory deprovisioned (SCIM reactivates it). Audited principal.unsuspend.

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Response:  principal_id, suspended, changed

  Errors:  409 account_deleted | suspended_by_directory

  CLI:  lhbox admin person reactivate <email>

### POST /v1/backoffice/principals/{principal_ref}/revoke → 200

Revoke every session (browser and MCP), every API key of the person and every token they made, in every organisation; their catalog keys rotated. The account stays. Audited principal.revoke_all.

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Response:  principal_id, api_keys_revoked, sessions_revoked, tokens_revoked, keys_rotated

  CLI:  lhbox admin person revoke-all <email> --yes

### GET /v1/backoffice/oauth-clients → 200

The OAuth/MCP clients an install administrator registered (approved), each with its live connections; ?all=1 adds the dynamic registrations. Never a secret.

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Request:  ?all=1 (the self-registered clients too)

  Response:  dynamic_registration, dynamic_count, clients[{client_id, client_name, client_uri, redirect_uris, application_type, type (public | confidential), registration (install_admin | dynamic), registered_by, created_at, last_used_at, disabled, disabled_at, connections}], note

  CLI:  lhbox admin client list [--all]

### POST /v1/backoffice/oauth-clients → 201

Register an approved OAuth/MCP client: it connects through the consent page with dynamic registration off. Public (PKCE) by default; confidential: a secret answered once (sha256 at rest), sent to /oauth/token by HTTP Basic or client_secret_post. Audited oauth.client_register.

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Request:  {client_name, redirect_uris[], client_id?, client_uri?, confidential?}

  Response:  the client as GET lists it, client_secret? (once)

  Errors:  400 invalid_value | invalid_redirect_uri, 409 client_exists

  CLI:  lhbox admin client add --name N --redirect-uri URI [--client-id ID] [--client-uri URL] [--confidential]

### POST /v1/backoffice/oauth-clients/{client_id}/disable → 200

No new sign-in and no refresh for the client (its access tokens end within 15 minutes); reversible. Audited oauth.client_disable.

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Response:  the client

  Errors:  404 client_not_found

  CLI:  lhbox admin client disable <client_id>

### POST /v1/backoffice/oauth-clients/{client_id}/enable → 200

Undo disable. Audited oauth.client_enable.

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Response:  the client

  Errors:  404 client_not_found

  CLI:  lhbox admin client enable <client_id>

### DELETE /v1/backoffice/oauth-clients/{client_id} → 200

Remove the client and revoke every connection made through it; the hosted agents those connections created are paused (creator_token_revoked), not revoked. Audited oauth.client_remove.

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Response:  client_id, removed, connections_revoked, agents_paused[] (only when there were any)

  Errors:  404 client_not_found

  CLI:  lhbox admin client remove <client_id> --yes

### GET /v1/backoffice/plans → 200

GET /v1/admin/plans for a platform admin: every plan with its limits and features, the names an override may use.

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Response:  the answer of GET /v1/admin/plans

  CLI:  lhbox admin plans --login EMAIL

### GET /v1/backoffice/orgs/{org_id}/limits → 200

GET /v1/admin/orgs/{org}/limits for a platform admin: plan, overrides, effective limits and features, last changes.

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Response:  the answer of GET /v1/admin/orgs/{org}/limits

  Errors:  404 org_not_found

  CLI:  lhbox admin org limits <org> --login EMAIL

### PATCH /v1/backoffice/orgs/{org_id}/limits → 200

PATCH /v1/admin/orgs/{org}/limits for a platform admin: the same validation, the same org_limits_audit row and org.limits.update event, actor admin:<principal_id>.

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Request:  {plan?, overrides?, note?}

  Response:  the view plus changed, bucket_quotas

  Errors:  the errors of PATCH /v1/admin/orgs/{org}/limits

  CLI:  lhbox admin org set <org> [--plan P] [--feature NAME=on|off|N] [limit flags] --login EMAIL

### DELETE /v1/backoffice/orgs/{org_id} → 202

Delete an organisation and every catalog of it (each through the ordinary deletion path: gone at once, storage torn down in the background, a tombstone), its tokens revoked and suspended, its projects, limits, invitations, domains and memberships removed. The members keep their accounts. Audited backoffice.org.delete.

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Request:  {"confirm": "<handle>"}

  Response:  status: deleting, org_id, handle, catalogs[], tokens_revoked, members_detached, note

  Errors:  400 confirmation_required (confirm_with), 404 org_not_found, 409 delete_in_progress (retry_after_seconds)

### DELETE /v1/backoffice/principals/{principal_ref}/mfa → 200

The operator's two-factor reset (DELETE /v1/admin/principals/{…}/mfa) for a platform admin; not for the admin's own account.

  Auth:  a platform admin's browser session that presented a second factor (the account has two-factor on), or the operator key. API keys and tokens never reach it

  Response:  principal_id, reset, was, passkeys_removed, sessions_revoked, api_keys_revoked, mcp_connections_revoked

  Errors:  404 principal_not_found, 403 forbidden (your own)

## Operator (not public by default)

### GET /v1/admin/admins → 200

The platform admins: the granted accounts and the addresses of AL_PLATFORM_ADMIN_EMAILS (verified? two-factor on?).

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Response:  admins[{principal_id, email, granted_at, granted_by, note, mfa}], from_env[{email, principal_id, verified, mfa}], env

### PUT /v1/admin/admins/{principal_ref} → 200

Make a human account (id or email) a platform admin. The back office opens for it only with a verified address and two-factor on. Audited platform.admin_grant.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Request:  {note?}

  Response:  principal_id, platform_admin, mfa, next

  Errors:  404 principal_not_found

### DELETE /v1/admin/admins/{principal_ref} → 200

Take platform admin away (an address in AL_PLATFORM_ADMIN_EMAILS stays one until the variable changes). Audited platform.admin_revoke.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Response:  principal_id, removed, platform_admin, note

  Errors:  404 principal_not_found

### GET /v1/admin/diagnostics/store-profile → 200

The store's own CPU profile over the given seconds, in Go's pprof format (go tool pprof reads it): which of the store's code paths take its CPU on the running cell. Nothing is kept.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Request:  query: seconds (1-30, default 10)

  Response:  the profile (application/octet-stream); 503 store_profile_unavailable when the store's profiling endpoint does not answer, 502 store_profile_too_large past 64 MiB

### GET /v1/admin/diagnostics → 200

What the cell is busy with now (issue #2386): the control plane's SQLite (database, WAL and shared-memory sizes, a PASSIVE checkpoint's answer, how long the write lock takes to get), fsync latency and write rate on the data volume and on the pod's own disk, and the busiest processes over sample_s (by name only, never their arguments). Measured when asked; nothing is kept.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Request:  query: sample_s (0.2-5, default 1)

  Response:  at, sqlite{path, db_bytes, wal_bytes, shm_bytes, checkpoint{busy, log_frames, checkpointed, ms}, lock_wait_ms (null + lock_error past 30 s)}, disks[{path, free_bytes, fsync_ms{p50, max}, write_mb_s}], processes[{name (the store's own processes by their role), count, cpu_pct, max_cpu_pct, read_mb_s, write_mb_s, rss_mb}], memory{memtotal_mb, memavailable_mb}, store{rates[{counter, type, per_s}]} or store{error}, loadavg, took_ms

### GET /v1/admin/admission → 200

The admission caps on NEW accounts (per hour, and a total counted from when it was set), the counts they are held against, and how many addresses wait.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Response:  per_hour, total_cap, cap_since, configured, default_per_hour, slot_reserve_pct, default_slot_reserve_pct, store_slots{max, free, reserve_pct, reserve, full (null = unknown or off), read_at, error}, message, default_message, admitted_last_hour, admitted_since_cap, waitlist_pending, updated_at, updated_by

### PUT /v1/admin/admission → 200

Set the caps: a field sent is set (null removes a cap, or the custom waitlist message), a field left out stays; until the first PUT, per_hour is the deployment's AL_ADMISSION_PER_HOUR (default 3600). Past a cap, a signup (the web form and the code path alike) waits in line and is answered 202 {waitlisted: true, message, next}; nothing is created or mailed. The total counts an admission once its account exists, or while it is under an hour old; releases count against neither cap. Invitations, logins, API keys and device flows are never held. slot_reserve_pct (0-100, 0 = off, null = AL_ADMISSION_SLOT_RESERVE_PCT, default 20): new accounts also wait while the store's free volume slots are at or below that share of its max; an unreadable store never closes the door. Audited admission.update.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Request:  {per_hour?: int|null, total_cap?: int|null, slot_reserve_pct?: int|null, message?: string|null}

  Response:  as GET

  Errors:  400 invalid_field / invalid_value

### GET /v1/admin/waitlist → 200

The addresses waiting, in the order they will be released, and the last releases that failed for good.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Request:  ?limit (1-10000, default 200)

  Response:  waitlist[{email, full_name, organization_name, via, created_at, position}], pending, failed[{email, released_at, released_by}]

### DELETE /v1/admin/waitlist/{email} → 200

Take an address out of the line without mailing it (junk addresses ahead of real people). Audited waitlist.remove.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Response:  {email, removed: true}

  Errors:  404 not_found (not waiting)

### POST /v1/admin/waitlist/release → 200

Release the next `count` in line: each gets the web form's set-password link and finishes like any signup. A release whose mail cannot be sent goes back in line at its place; one that fails otherwise leaves the line (GET's failed). Refused when password login is off. Audited waitlist.release.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Request:  {count: 1-500}

  Response:  released[{email, outcome, delivery?}], errors[{email, outcome: requeued|failed, error}], pending

  Errors:  400 invalid_value; 403 password_login_disabled; 403 registration_closed (AL_SIGNUP_CLOSED)

### GET /v1/admin/onboarding → 200

The onboarding mails at a glance (plane/onboarding.py): the mode (AL_ONBOARDING_MAIL off|dry|on), since when people are mailed, what was sent by mail and delivery, how many opted out, and who is due now.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Response:  mode, since, from, sent{mail:delivery: n}, opted_out, due_now[{principal_id, mail}]

### GET /v1/admin/news → 200

The mailing list at a glance: subscribers, withdrawals by reason, whether the relay, the legal footer and the webhook are configured, and the issues.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Response:  enabled, subscribers, withdrawn{via: n}, sender{from, relay_configured, footer_set, webhook_configured, headers_error}, consent{version, text}, issues[]

  CLI:  lhbox admin news status

### POST /v1/admin/news/issues → 201

A draft newsletter issue from Markdown (headings, paragraphs, bullet lists, `code`, **bold**, https links; <!-- notes --> dropped; no raw HTML). The answer has the rendered HTML. Audited news.issue.create.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Request:  {"subject": …, "body_md": …}

  Response:  issue_id, subject, state (draft), deliveries, body_md, html

  Errors:  400 missing_field / invalid_value

  CLI:  lhbox admin news create --subject S --file issue.md

### GET /v1/admin/news/issues/{issue_id} → 200

One issue: text, rendered HTML, state (draft|tested|sending|sent) and the delivery counts.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Response:  issue_id, subject, state, tested_at, send_started_at, send_finished_at, deliveries{queued, sending, sent, skipped, failed, unknown (caught mid-hand-over by a restart: never resent)}, body_md, html

  Errors:  404 not_found

  CLI:  lhbox admin news show <issue_id>

### PATCH /v1/admin/news/issues/{issue_id} → 200

Edit a draft; an edit clears tested_at (the send needs a test of the final text).

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Request:  {"subject"?: …, "body_md"?: …}

  Response:  as GET

  Errors:  404 not_found, 409 already_sent

  CLI:  lhbox admin news edit <issue_id> [--subject S] [--file issue.md]

### POST /v1/admin/news/issues/{issue_id}/test → 200

Send the issue to one address, subject prefixed [test]; records tested_at, which the real send needs.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Request:  {"to": …}

  Response:  as GET, plus sent_to, footer_set

  Errors:  400 invalid_value, 404 not_found, 502 mail_error, 503 news_relay_missing

  CLI:  lhbox admin news test <issue_id> --to you@example.com

### POST /v1/admin/news/issues/{issue_id}/send → 202

Send the issue to everyone subscribed now, once: the recipients are fixed now and mailed in the background (a restart resumes, nobody is mailed twice; someone who unsubscribes before their turn is skipped; a relay in trouble pauses the send and retries, then leaves the rest queued). On an issue already sending this resumes it; retry_failed also puts the rows the relay refused for good back in line. Needs the list on, the relay, valid extra headers, the sender's name and postal address, and a test after the last edit. Audited news.issue.send / news.issue.resume.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Request:  {"confirm": "<issue_id>", "retry_failed"?: true}

  Response:  issue_id, recipients, state: sending, next; resuming: issue_id, resumed: true, queued, requeued, state, next

  Errors:  400 confirmation_required, 404 not_found, 409 news_disabled / news_headers_invalid / news_footer_missing / test_required / already_sent (nothing left to send), 503 news_relay_missing

  CLI:  lhbox admin news send <issue_id> [--retry-failed]

### GET /v1/admin/plans → 200

The plan registry: every plan by name with its limits (free and custom, both the free plan's numbers today), the field names an override may name, and how each limit is enforced.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Response:  plans{<name>: limits}, default_plan, fields[], limits_detail, note

  CLI:  lhbox admin plans

### GET /v1/admin/orgs/{org_id}/limits → 200

One organisation (org_id or handle): its plan, its overrides, the effective limits (plan merged with overrides) with their enforcement, the operator's note and the last ten changes.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Response:  org_id, handle, name, plan, overrides, limits, limits_detail, note, updated_at, updated_by, plans[], audit[{id, actor, at, before, after, note}]

  CLI:  lhbox admin org limits <org>

### PATCH /v1/admin/orgs/{org_id}/limits → 200

Put an organisation on a plan and/or override limits. A null removes that override; other overrides stay. Every call is one row in org_limits_audit and one org.limits.update event in the organisation's audit log. When a storage limit changed, every bucket of the organisation gets its hard quota re-set at once; the read-only flip or un-flip itself follows the store's own quota pass (within about a minute; the host's enforce pass after each maintenance sweep is a backstop). The refusals (409 quota_exceeded, limit_exceeded) read the new limit on the next request.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Request:  {plan?: free|custom, overrides?: {storage_bytes|public_storage_bytes|objects|warehouses|projects|...: non-negative integer | null}, note?}; a plan feature is an override like any limit: {"overrides": {"public_images": 1}} switches photos from public folders on, 0 off, null back to the plan

  Response:  the GET view plus changed (bool) and bucket_quotas (the re-applied quotas, or null)

  Errors:  400 invalid_field (unknown top-level field), 400 unknown_plan, 400 unknown_limit, 400 invalid_value, 400 nothing_to_change, 404 org_not_found

  CLI:  lhbox admin org set <org> [--plan P] [--feature NAME=on|off|N] [--storage-gb N ...] [--clear FIELD]

### DELETE /v1/admin/domains/{domain} → 200

Release a domain claim for a dispute settled outside this API: the verified claim on the domain, or with ?org=<org_id or handle> that organisation's claim, verified or not. Domain join switches off when the organisation has no verified domain left; audited org.domain_release with actor operator.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Response:  domain, org_id, released: true, was_verified, domain_join_switched_off

  Errors:  404 domain_not_found

### DELETE /v1/admin/principals/{principal_ref}/mfa → 200

Two-factor off and every passkey removed, for a person who lost both the phone and the backup codes, or a device (principal_id or email), after the operator has checked who is asking; every session of the account and every API key of the person are revoked and every MCP connection the person approved disconnected (when there was something to reset); audited auth.mfa_reset.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Response:  principal_id, reset (bool), was (active|pending|null), passkeys_removed, sessions_revoked, api_keys_revoked, mcp_connections_revoked

  Errors:  404 principal_not_found

### POST /v1/admin/warehouses/{warehouse_id}/restore → 200

Restore a catalog in its grace period by warehouse_id, as POST /v1/warehouses/{id}/restore does, without the tenant's role check; audited warehouse.restore with actor operator.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Response:  restored, warehouse_id, name, project_id, deleted_at, purge_after, grants_restored, was_public, public, storage, note

  Errors:  400 invalid_value (not a UUID), 404 warehouse_not_found, 409 not_restorable, 409 already_exists, 409 limit_exceeded

### DELETE /v1/admin/handles/{handle}/reservation → 200

Release a deleted organisation's handle before its reservation ends (a deleted organisation's handle is reserved until its catalogs are purged at the store and at the public-data edge and AL_HANDLE_RESERVE_DAYS have passed); audited handle.release with actor operator.

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Response:  handle, released: true, org_id, retired_at, reserved_until, held_by (grace | catalogs | edge | null)

  Errors:  404 not_found (no reservation for that handle)

### GET /v1/admin/orgs/{org_id}/limits/audit → 200

Every change to the organisation's plan and overrides, newest first (?limit=, default 100, max 1000).

  Auth:  the deployment's operator key (AL_OPERATOR_KEY) as Authorization: Bearer. Without one configured every /v1/admin/* path answers 404

  Response:  org_id, handle, count, events[{id, actor, at, before, after, note}]

## Internal (not public)

These exist in the control plane for the operator and for development stacks. They require the deployment's bootstrap secret and the public gateway answers 404 for every /internal/* path. Listed so the surface is complete, except a few operator-only routes, which are omitted.

### POST /internal/dropbox/auth → 200

SFTPGo's external-auth hook on the drop-box host: a login in, the SFTPGo user out (home, upload-only permissions, the protocols the uploader and the plan allow), or an empty username to refuse. Requires AL_DROPBOX_HOOK_SECRET; 404 without it.

  Auth:  the drop box's own secret (X-Dropbox-Secret; the bootstrap secret is refused). api.lakehousebox.com serves this path only to the drop-box host's address (404 for every other); without AL_DROPBOX_HOOK_SECRET the route does not exist

### GET /internal/dropbox/uploaders → 200

For the drop box's relay: the live drop-box uploaders (id, login, bucket, prefix; never a key), and the ones revoked in the last day. Requires AL_DROPBOX_HOOK_SECRET; 404 without it.

  Auth:  the drop box's own secret (X-Dropbox-Secret; the bootstrap secret is refused). api.lakehousebox.com serves this path only to the drop-box host's address (404 for every other); without AL_DROPBOX_HOOK_SECRET the route does not exist

### POST /internal/dropbox/presign → 200

For the drop box's relay: {uploader_id, key, content_type?} in, one presigned PUT out (900 s), signed with that uploader's own key for a key under its prefix; the signed headers come back to send exactly. Requires AL_DROPBOX_HOOK_SECRET; 404 without it.

  Auth:  the drop box's own secret (X-Dropbox-Secret; the bootstrap secret is refused). api.lakehousebox.com serves this path only to the drop-box host's address (404 for every other); without AL_DROPBOX_HOOK_SECRET the route does not exist

  Errors:  400 invalid_key / invalid_content_type, 404 not_found (no live drop-box uploader with that id and prefix)

### POST /internal/slices/claim → 200

For the slicer service (worker/slices.py, its own process): {limit, warehouse_ids?} -> at most limit due rules (interval since the last check, or a refresh asked for; never without the enterprise plan, never a held catalog unless named), each claimed for this run and handed over with its catalog's key pair and the deleted rules of its layout it may adopt and each slice's state. A claimed rule is not due again until its report or a 10-minute timeout, so one broken rule never starves the others; a timed-out claim is recorded as a lost run (run_lost) and doubles the next wait.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### POST /internal/slices/{slice_id}/report → 200

One run's outcome: ok (every copy done for source_version, the source's snapshot and schema), partial (cut short by the time bound: due again at once), waiting, unchanged, or error (each copy not rewritten, by code: only those retry, after a growing wait); each copy's state is merged; values left unsliced (a collision, the cap); the value map is append-only (409 value_map_conflict otherwise); new namespaces are audited. A report answers one claim (its `claim`): a late one of a run recorded as lost, or one for a deleted rule, is 409 stale_report / slice_not_active and changes nothing.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### POST /internal/intakes → 200

A messaging intake on an existing sink, with its members: {sink_id, name, members: [{address, label}], addendum_version, accepted_by}; the sink's table must have the message columns, and an admin of the organisation (accepted_by, their email) must have accepted the WhatsApp addendum in force (addendum_version; 400 addendum_required).

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### GET /internal/intakes → 200

Every messaging intake, its members (numbers masked) and its held messages by state.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### GET /internal/indexes → 200

For the poller (worker/index.py, in the sink-roller process): every active rule with its catalog's key pair, blob bucket, extractors and whether it is due.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### POST /internal/indexes/{index_id}/batch → 202

The poller's records for one rule, stored in its internal sink by the sink's own store step; the batch id is the hash of the (key, etag) pairs, so a replay answers duplicate.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### POST /internal/indexes/{index_id}/report → 200

One poll's outcome: when, objects listed and indexed, the watermark, the error.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### GET /internal/sinks → 200

For the roller (worker/roll.py): every active or disabled sink with its catalog's key pair, buckets, roll policy and pending ledger, and `storage` (ok | gone | unattached | unknown: whether a missing table can be believed; the roller disables for table_missing only on ok), plus the sinks deleted in the last 7 days whose intake prefix it must purge. Empty while AL_SINKS_ENABLED is off.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### POST /internal/sinks/{sink_id}/roll → 200

The roller reports one roll: the batch ids committed (marked committed with the snapshot id), rows, rejected rows, seconds, an optional disabled_reason (table_missing disables the sink, unless the catalog's storage is marked gone: then it holds); ledger rows committed more than 7 days ago are pruned.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### POST /internal/sinks/{sink_id}/enable → 200

The roller, at most every 5 minutes per sink disabled for table_missing: the same as the operator's POST /v1/admin/sinks/{sink_id}/enable (the table loaded as the tenant and checked as a new sink's would be).

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

  Response:  sink_id, enabled, reason

  Errors:  404 table_not_found (still missing), 409 not_table_missing, a sink creation refusal with stays_disabled

### POST /internal/warehouses/{warehouse_id}/sink-hold → 200

For test harnesses: {seconds: 1..900} (required) holds one catalog's sinks, purges and blob index from the stack's roller (they are listed held, and the roller skips them) while the harness rolls that catalog itself (roll.py --once --warehouse <id>); {seconds: 0} ends the hold. A hold never lasts past 900 s from its start, however often it is renewed, and after it ends the catalog cannot be held again for 60 s (409 hold_cooldown). Hold before the catalog has work: a roller tick already in flight may still take it once. In memory: a restart ends every hold. Each hold and release is logged.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### POST /internal/setup/token → 201

A self-hosted install's one-time setup token (`./al setup-link`, run by `./al bootstrap`): single use, AL_SETUP_TOKEN_TTL_SECONDS (an hour), a new one replaces the previous; 409 setup_done once the install has an install administrator, 404 on the managed service. Answers the token once, the link and the CLI line.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### POST /internal/principals → 200

Operator: create a principal and its first key. email_verified: true (a human with an email only) vouches for the address, as a signup's code proves it; inviting needs a verified address.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### POST /internal/principals/{principal_id}/suspend → 200

Operator: suspend or unsuspend a principal (the enforcement point).

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### POST /internal/api-keys/{key_id}/revoke → 200

Operator: revoke any key.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### GET /internal/signup-codes/{email} → 200

Development stacks without a mail server: read a pending code.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### GET /internal/public-images → 200

The S3 gateway's forward_auth for a photo (.jpg .jpeg .png .webp) read WITHOUT credentials from a catalog's file bucket: 200 only when the bucket (the host's first label for virtual-hosted S3, else the path's first segment, from the X-LHB-Host / X-LHB-Path headers the gateway sets) is the file bucket of a catalog whose organisation has the plan feature public_images; 403 with an S3 XML AccessDenied otherwise. Asked per request, so the operator's switch (PATCH /v1/admin/orgs/{org}/limits with overrides.public_images = 1 or 0; lhbox admin org set ORG --feature public_images=on) takes effect at once. Data types never ask: the gateway passes them to the store, and refuses every other type itself.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

  Response:  200 (empty) or 403 application/xml

### GET /internal/tls-ask → 200

Caddy's on-demand TLS gate for virtual-hosted S3 names (<bucket>.s3.<domain>, the shape Snowflake's S3-compatible volumes need), asked before a certificate is obtained or renewed: 200 only for a bucket of an existing catalog with virtual_host set (an admin's choice, public catalogs included) -- its table or blob bucket, looked up by name in the row's bucket / blob_bucket columns, so <handle>--<catalog>, …--blobs and the older w-/b-<uuid> are all served -- under the configured S3 host; 404 otherwise (not_virtual_host for a catalog that has not opted in; a deleting catalog included); 429 tls_issue_rate for a NEW name past AL_TLS_NEW_PER_WEEK (default 20) in 7 days, while a name approved before is always answered. Answers domain, bucket, warehouse_id, kind (tables|blobs), issue. Called on the docker network; never served publicly.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### GET /internal/login-tokens/{email} → 200

Development stacks without a mail server: read a pending set-password or reset link token (purpose=set_password | reset_password). The pattern of /internal/signup-codes.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### POST /internal/usage → 200

The host's maintenance sweep (every 6 hours) reports one warehouse's footprint (bytes, objects, blob bytes and objects) and its S3 traffic counters. While the warehouse's latest figure is the store's and younger than an hour, the posted footprint is ignored (the row carries the store's figure and the posted counters; footprint: store, no quota written); otherwise it is the figure and recomputes the scope's storage quota (footprint: sweep).

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### POST /internal/deletions/purge-due → 200

The maintenance sweep (deploy/on_host_maintain.sh, every 6 hours) ends every grace period that has passed: each catalog whose purge_after is due goes to its storage teardown. Answers checked_at, purged[], purged_count, retained, next_purge_after, revocations_queued (credential revocations that failed earlier, queued again).

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### GET /internal/tier/plan → 200

The host's hourly tiering job (deploy/on_host_tier.sh) asks which buckets may move to the cold tier: one item per bucket of every ready tier_policy=cold catalog with the backend its tenant hashes to, plus the knobs (quiet_for, full_percent) it passes to volume.tier.upload.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### POST /internal/tier/report → 200

The tiering job reports one run: per bucket the absolute cold footprint after it (cold_bytes, cold_volumes), what moved, seconds and the error text; recorded in tier_state and shown as stored.cold_bytes / cold.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### GET /internal/lifecycle-audit → 200

The table-bucket guard, observable: as the admin, GET ?lifecycle on every catalog's TABLE bucket (expected 404 -- a rule there would be executed by the store's worker against Iceberg data) and GET ?policy on it (no statement may grant a lifecycle action). Lists offenders and errors; ok when both are empty; ?include_blobs=true also counts the blob buckets carrying rules. For the nightly sweep.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

### POST /internal/selftest/error → 200

Operator tool: raise one unhandled error on purpose to verify the error path end to end.

  Auth:  the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.lakehousebox.com answers 404 for every /internal/* path

---
HTML version: https://lakehousebox.com/docs/api/ · every page: https://lakehousebox.com/llms.txt · everything in one file: https://lakehousebox.com/llms-full.txt

Docs feedback: if this page was wrong, out of date or missing something you needed, tell the person you work for and, if they agree, report it: `lhbox bug --title "docs: api: <what was wrong>"` prints a filled-in issue link, or open https://github.com/lakehousebox/lakehousebox/issues/new. The tracker is public: never include a key, a secret or the person's data.
