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. 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.
Words: a catalog in the account page, the CLI and these docs is a warehouse in the API (/v1/warehouses, warehouse_id, my_level); the API keeps that name until a versioned rename adds /v1/catalogs aliases.
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 /.well-known/openid-configuration → 200
OpenID metadata for the tokens this API issues (client_credentials, RS256).
GET /jwks.json → 200
The RS256 public key that verifies tokens from POST /v1/tokens.
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.
{"api_key": "al_live_…"}GET /healthz → 200
Liveness: the state database answers and the catalog answers /v1/config.
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 on a claimed, open domain joins that organisation instead (will_join).
{"email": "you@example.com", "workspace"?: "acme", "full_name"?: "Ana Lopez", "organization_name"?: "Acme Labs", "handle"?: "acme-labs"}lhbox signup --email …POST /v1/signup/verify → 201
Step 2 of 2: the right code creates the account, its API key, an organisation (owned by you unless an invitation or an open verified domain places you elsewhere), a project named after the workspace, a warehouse named `lake`, and returns the connection recipe.
{"email": …, "code": "123456", "accept_terms": true, "engine": "duckdb"} (engine: duckdb|pyiceberg|spark, default duckdb)lhbox signup --email … --code … --accept-terms --savePOST /v1/signup → 201
The one-call, UNVERIFIED signup for local development stacks. The managed service answers 403 verification_required: use /v1/signup/start + /verify.
{"email", "workspace", "engine", "accept_terms"}GET /v1/handles/{handle} → 200
Is this organisation handle free? For the signup form as you type (per-address rate limit). Handles: one character, or 3-40 lowercase letters, digits and dashes, starting and ending with a letter or digit; 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.
POST /v1/recover/start → 202
Lost API key: a code to the account's email. The answer is identical whether or not the address has an account, so accounts cannot be enumerated here.
{"email": …}lhbox recover --email …POST /v1/recover/verify → 200
The right code mints a NEW API key. Earlier keys keep working until revoked.
{"email": …, "code": "123456"}lhbox recover --email … --code … --saveLogin 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 address on a claimed and open domain joins that organisation as a member (via: domain); the invitation wins when both apply. The code path for agents and the CLI stays at POST /v1/signup/start.
{"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)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, or the open-domain one as a member -- 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.
{"token": …, "password": …} (12 to 256 characters, not containing the address or the organisation name)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).
{"email": …, "password": …}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).
{"refresh_token": …}POST /v1/auth/logout → 200
Revokes the session, by its refresh token or by the access token in Authorization (sid claim). The access token itself expires within 15 minutes.
{"refresh_token": …} or Authorization: Bearer <access token>GET /v1/auth/sessions → 200
The caller's live sessions: where and when they logged in; `current` marks the one making the call.
DELETE /v1/auth/sessions/{session_id} → 200
Revokes one of the caller's sessions.
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.
{"email": …}POST /v1/auth/password/reset → 200
The reset link: a new password, every session revoked, a notice mailed; returns a fresh session.
{"token": …, "password": …}POST /v1/auth/password/change → 200
A logged-in human changes their password; every OTHER session is revoked; a notice is mailed.
{"current_password": …, "new_password": …}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 is the agent the approval creates (default the hostname), requested_level what it asks for (default write), 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 and still accepted.
{"client": "cli", "hostname": …, "client_name": …, "requested_level": "read|write", "warehouses": [{"name": …, "level": …}]} (all optional)lhbox login [--no-wait]GET /v1/auth/device/pending → 200
The consent screen's content for a pending code (humans only; nothing is decided by reading it): the agent's name, EVERY catalog of the organisation the caller holds something on -- `requested` marks the ones the CLI named, `level` the level the agent would hold there (the requested one, capped by the caller's own; null when not requested) -- and `requested` as the list the page sends back.
?user_code=XXXX-XXXXPOST /v1/auth/device/approve → 200
A logged-in human connects the CLI as an AGENT of the organisation -- a token named after the machine, member role, holding exactly the catalogs and levels chosen in `warehouses` (the consent page's picker; each level capped by the approver's own) -- or refuses it with deny: true. The older pair still works: level lowers the requested level (never raises it), warehouse_ids narrows the set. An organisation without a catalog gets "demo_data" first. The answer is approved, not connected: the agent exists once the machine polls (claimed: false; GET /v1/auth/device/status follows it). Only humans; a token approving would hand out a key of the token.
{"user_code": …, "warehouses": [{"warehouse_id" | "name": …, "level": "read|write"}], "deny": false}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 agent is created when the machine polls -- so the page says "waiting for <agent> 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.
?user_code=XXXX-XXXXPOST /v1/auth/device/token → 200
The CLI polls until the human 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), expired_token, access_denied. Once approved: the key of the NEW AGENT (never the person's), labelled "cli <hostname>", shown once, with the connection it holds. Revoking the agent revokes the connection.
{"device_code": …}lhbox login | lhbox login --resume [--wait]Remote MCP: OAuth 2.1 authorization server
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 (token_endpoint_auth_methods_supported: none), Client ID Metadata Documents supported, iss on authorization responses, and the deprecated registration_endpoint while dynamic registration is on.
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.
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.
?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=…GET /v1/oauth/authorize/pending → 200
The consent page's content for a pending authorization (a logged-in human, via a session): 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). Nothing is decided by reading it.
?request_id=<from the consent page URL>POST /v1/oauth/authorize/decision → 200
The logged-in human allows or denies. 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.
{"request_id": …, "allow": true, "grants": [{"warehouse_id": …, "level": "read|write"}], "org_id"?: …, "role"?: "member|admin"} or {"request_id": …, "allow": false}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.
grant_type=authorization_code&code=…&code_verifier=…&client_id=…&redirect_uri=…&resource=… or grant_type=refresh_token&refresh_token=…&client_id=…[&scope=…]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.
token=…[&token_type_hint=refresh_token|access_token]POST /oauth/register → 201
RFC 7591 dynamic registration, the DEPRECATED fallback for hosts that cannot publish a Client ID Metadata Document. Public clients only: https redirect URIs, loopback http on any port, or a native app's private-use scheme. Registrations unused for 90 days are pruned. Off when AL_OAUTH_ALLOW_DCR is false.
{"client_name": …, "redirect_uris": […], "grant_types"?: [authorization_code, refresh_token], "response_types"?: [code], "token_endpoint_auth_method"?: "none", "client_uri"?, "logo_uri"?, "application_type"?: "web|native", "software_id"?}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: hetzner, the deployment's own region.
{"name": …, "provider"?, "region"?, "org_id"?} Header Idempotency-Key: any stringlhbox project create --name …GET /v1/projects → 200
Projects you own or reach through an organisation you belong to.
lhbox project listPOST /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").
{"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-Keylhbox catalog create <name> [--project …] [--default-format-version 2|3]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). 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. Storage sessions already vended run out on their own within 900 s. Why the split: every IAM write costs the store a 10 s propagation deadline.
lhbox catalog rotate <name or id>DELETE /v1/warehouses/{warehouse_id} → 202
Delete a warehouse for good, with its name typed in the body. Irreversible, in two steps. Before the 202: the catalog is gone for every caller and out of every list, every grant on it is revoked, its usage samples go, a public warehouse's anonymous-read policy is removed, warehouse.delete is audited. In the background (usually within a minute; one worker, so a queue of deletions runs in turn): tables dropped as the tenant, both buckets and the identities (the read-only one where it was ever needed) 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). Until the teardown reaches the identities, a recipe already in an engine's hands can still reach the catalog and the bucket directly. Why: every IAM write costs the store a 10 s propagation deadline and this route made six of them while the person waited. The nightly backup copy ages out within its retention window (14 days).
{"confirm": "<the warehouse name, exactly>"}lhbox catalog delete <name> --confirmPOST /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 never stored. Cost: 2 IAM writes at the store (CreateUser, CreateAccessKey), each a propagation deadline of up to 10 s at the host's current identity count, plus the two statements merged into the blob bucket's policy (an S3 call); nothing is re-put later.
{"name": …, "prefix"?: "photos/2026/", "list"?: false, "expires_at"?: null}lhbox catalog uploader create <catalog> --name relay --prefix photos/ [--list] (--save FILE | --show-key)GET /v1/warehouses/{warehouse_id}/uploaders → 200
The catalog's uploaders without their secrets (never stored); revoked ones only with ?include_revoked=true.
lhbox catalog uploader list <catalog> [--all]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.
lhbox catalog uploader revoke <catalog> --uploader <id|name>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.
{"name": …, "table": "<ns>.<table>", "warehouse": "<id or name>", "roll_seconds"?: 300, "roll_bytes"?: 67108864, "inactivity_seconds"?: null}lhbox sink create <catalog> --table <ns>.<table> [--roll-seconds 300] [--roll-bytes N] [--inactivity-seconds N] (--save FILE | --show-key)GET /v1/sinks → 200
The sinks of every catalog the caller can see (or of one with ?warehouse=), with lag, last roll and rejects; never a key. ?include_deleted=true adds the deleted ones.
lhbox sink list [--catalog <id or name>]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.
lhbox sink get <sink id or name>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.
lhbox sink delete <sink id or name> [--discard-pending]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.
[ {…}, {…}, … ] with headers Authorization: Bearer <send_key>, Idempotency-Key: <batch id>, Content-Type: application/jsonlhbox sink send <sink id> batch.json [--batch-id <id>]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 and the catalog stays private. Its bytes move to the 50 GB public tier; 409 quota_exceeded (tier public) when that would overflow.
{"confirm": "<the warehouse name, exactly>"}lhbox catalog publish <name> --confirmDELETE /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; bytes return to the private tier.
lhbox catalog unpublish <name>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 the next hourly sample 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.
{"rules": [{"id"?: "camera-90d", "prefix"?: "camera/", "expire_days"?: 90, "expire_date"?: "2026-12-31", "abort_incomplete_multipart_days"?: 7, "enabled"?: true}, …]}lhbox catalog lifecycle set <catalog> --prefix camera/ --expire-days 90 [--abort-multipart-days 7]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.
lhbox catalog lifecycle get <catalog> [--xml]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.
lhbox catalog lifecycle clear <catalog>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 agent write via agent). A level of none is a membership read an admin revoked for this warehouse.
lhbox catalog grants <name or id>PUT /v1/warehouses/{warehouse_id}/grants/{principal_id} → 200
Grant read or write on a warehouse to a member or an agent of the organisation. A read grant yields read-only catalog and storage credentials (t-<uuid>-ro); a write grant the read/write ones.
{"level": "read"|"write"}lhbox catalog grant <name or id> --principal … --level read|writeDELETE /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).
lhbox catalog revoke <name or id> --principal …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.
?project=<id or name> (optional)lhbox catalog list [--project …]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.
the path holds the warehouse_id or the catalog's namelhbox catalog get <name or id>PATCH /v1/warehouses/{warehouse_id} → 200
Change a catalog's settings: today 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. Sending the value already set changes and audits nothing. Audited as warehouse.update (changes, previous).
{"default_format_version": 3} (the path holds the warehouse_id or the catalog's name)lhbox catalog update <name or id> --default-format-version 2|3POST /v1/warehouses/{warehouse_id}/measure → 200
Measure the catalog's storage NOW instead of waiting for the hourly sweep: bytes and objects of its table bucket and its blob bucket (both as the row names them), listed as the tenant, summed and stored as a usage sample (source: on_demand) that GET /v1/usage, GET /v1/warehouses and the account page show until the sweep's next sample. Why it exists: the sweep lists warehouses at its start and measures each once an hour, so a load that landed after a catalog's sample read the old figure for up to an hour (2026-09-20: 1,818 bytes for 63 minutes after a 2.9 GB load). Same objects as the sweep counts, two differences stated in `note`: taken before the sweep's snapshot expiry and compaction, and a dropped table's files count until they are purged. Quota enforcement (the bucket quota, the 409s) still follows the sweep's sample. At most 50 listing pages (50,000 objects) per bucket: past that partial: true and bytes is a lower bound. Once a minute per catalog. Audited as warehouse.measure.
the path holds the warehouse_id or the catalog's name; no bodylhbox catalog measure <name or id>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.
{"warehouse_id": <id or name>, "namespace": …, "name": …, "schema": <Iceberg struct schema JSON>, "format_version": 3} (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) Header Idempotency-Keylhbox table create --catalog … --namespace … --name … --column name:type …GET /v1/namespaces → 200
Namespaces in a catalog, read from the catalog service.
?warehouse=<id or name> (warehouse or warehouse_id: both spellings name the catalog, by id or by name, on every route below)lhbox namespace list --catalog …GET /v1/tables → 200
Every table in a catalog, or in one namespace.
?warehouse=<id or name>&namespace=<optional> (or warehouse_id=)lhbox table list --catalog … [--namespace …]GET /v1/table → 200
Schema, the real format version and counts, read from the table's current metadata. 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.
?warehouse=<id or name>&namespace=…&name=… (or warehouse_id=)lhbox table get --catalog … --namespace … --name …PATCH /v1/table → 200
Set and/or remove table properties: ONE commit through the catalog (Iceberg REST commitTable with set-properties / remove-properties, as the catalog's own identity), visible to every engine on its next load. 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.
{"warehouse_id": <id or name>, "namespace": …, "name": …, "properties": {"geo.crs": "EPSG:4326", …}, "remove": ["key", …]} (properties and/or remove; values are strings -- numbers and booleans are stringified, nested objects refused; keys 1-128 chars of letters, digits, ".", "_", "-")lhbox table set-properties --catalog … --namespace … --name … --property key=value … [--remove key …]POST /v1/credentials → 200
Short-lived storage credentials scoped to ONE table's object prefix, vended by the catalog to the warehouse's read/write identity. For direct S3 access from a script; engines that load tables through the catalog obtain these themselves. Write holders only: a vended session is read/write whoever asks for it (measured, docs/security.md), so a read holder is refused and pointed at the static read-only credential in GET /v1/connection.
{"warehouse_id": <id or name>, "namespace": …, "name": …} ("warehouse" is accepted for the same field)lhbox credentials --catalog … --namespace … --table …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). The snowflake recipe is always the read-only identity (static keys, ALLOW_WRITES = FALSE). LakehouseBox is never in the query path: the engine talks to the catalog and the object store directly.
?warehouse_id=<id or name>&engine=duckdb (or warehouse=; duckdb default, pyiceberg, spark, snowflake)lhbox connect --catalog … --engine duckdb|pyiceberg|spark|snowflakeGET /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}.
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.
lhbox org listPATCH /v1/orgs/{org_id} → 200
Rename the organisation, or let people whose verified email is on a claimed domain join as members.
{"name"?, "domain_join"?: true|false}lhbox org set [--name …] [--domain-join on|off]POST /v1/orgs/{org_id}/domains → 201
Claim an email domain. The proof is your own verified address on that domain; public mail domains (gmail.com, outlook.com, …) are never claimable; a domain belongs to one organisation.
{"domain": "yourcompany.com"}lhbox org domain --domain …DELETE /v1/orgs/{org_id}/domains/{domain} → 200
Release a claimed domain. Members who joined through it stay; nobody new joins through it, and domain join switches itself off when no domain is left.
GET /v1/orgs/{org_id}/members → 200
Who is in the organisation.
lhbox org membersPATCH /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.
{"role": "admin"|"member"}lhbox org role --principal … --role admin|memberDELETE /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.
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.
{"email": …, "role": "member"|"admin"}lhbox org invite --email … --role member|adminPOST /v1/orgs/invitations/accept → 200
Redeem an invitation. The caller's email must be the invited one.
{"token": …}lhbox org accept --token …Agents
POST /v1/orgs/{org_id}/agents → 201
A token (shown as "agent"): 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 an agent 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.
{"name": …, "role"?: "admin"|"member", "grants"?: [{"warehouse_id": …, "level": "read"|"write"}, …]}lhbox agent create --name … [--role admin|member] [--grant <warehouse_id>:read|write …]GET /v1/orgs/{org_id}/agents → 200
The organisation's tokens with their role and grants, who created each and when its key was last used.
lhbox agent listGET /v1/orgs/{org_id}/agents/{agent_id} → 200
One token by id or by name: the same view as the list (grants as [{warehouse_id, name, level}] on every agent route since 2026-09-21), its connection block, creator and last use. Never the key.
lhbox agent get <id or name>PATCH /v1/orgs/{org_id}/agents/{agent_id} → 200
Edit a token's name, grants and/or role; the path names the agent 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 agent confines it. Audited with the previous role.
{"name"?: …, "role"?: "admin"|"member", "grants"?: [{"warehouse_id": …, "level": …}]}lhbox agent update --agent <id or name> [--name …] [--role admin|member] [--grant <warehouse_id>:read|write …]DELETE /v1/orgs/{org_id}/agents/{agent_id} → 200
Every key of the agent is revoked and the agent suspended: it stops at once. The path names the agent by id or by name.
lhbox agent revoke --agent <id or name>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 (AL_OIDC_ISSUERS; device-code endpoints). The account page shows the form, the "Log in with <issuer>" button, or both, from this answer.
POST /v1/api-keys → 201
Another API key for the caller (for a second machine, a CI job). Shown once.
{"label": "ci"}lhbox api-key create --label …GET /v1/api-keys → 200
The caller's keys, by id: never the secret.
lhbox api-key listDELETE /v1/api-keys/{key_id} → 200
Revoke one of your keys. The key id is the part between al_live_ and the second underscore.
lhbox api-key revoke --key-id …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 address on a claimed, open domain joins that organisation instead and the response says so. Idempotent: an existing organisation and warehouse are returned, not duplicated.
{"engine"?: "duckdb", "accept_terms"?: true, "organization_name"?, "handle"?, "org_id"?}GET /v1/terms → 200
The Terms version in force and whether you accepted it.
lhbox termsPOST /v1/terms/accept → 200
Record acceptance of the current Terms version. A person's act: a token (kind agent or mcp) is refused; Terms bind the account holder and a token needs no acceptance of its own.
{"version"?: <the current version>}lhbox terms --acceptDELETE /v1/me → 200
Delete your account. Immediate: every API key revoked, every session revoked, identities unlinked, memberships removed, agents of organisations left empty stopped; a credential of the deleted account answers 401 account_deleted from then on. Data of organisations left without members is erased within 30 days and leaves backups within 14 more (Terms).
{"confirm": "<your email>"}lhbox account delete --confirm <your email>Usage and limits
GET /v1/usage → 200
Every limit, the current value and the headroom. Storage is metered hourly by maintenance (the latest sample of every warehouse in the scope, summed; 'unmetered' until the first sample, which the control plane takes itself ~2 minutes after a catalog is created); every storage figure carries measured_at and stale_after (when the sweep 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. 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 SeaweedFS'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 hourly samples carry counters; no object keys and no query contents are collected.
?project_id=<optional id or name>&days=<1..30, default 7: the per-catalog history length>lhbox usage [--full] [--catalog <id or name>] [--days N] (also: lhbox whoami; both print a brief object unless --full)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, api_key.create/revoke, project.create, warehouse.create/rotate, table.create, credentials.vend, org.create/update/domain_claim/invite/invitation_accept/member_join/member_role/member_remove, org.reserve/activate, agent.create/update/revoke, warehouse.grant/grant_revoke/delete/delete_complete/rotate_complete/measure, terms.accept, account.delete, quota.reject.
?org_id=<optional>&since=<24h|7d|30m|ISO-8601>&action=<name or prefix*>&limit=<1..1000, default 100>lhbox audit [--org …] [--since 24h|7d|30m|<ISO-8601>] [--action credentials.vend|org.*] [--limit N]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.
POST /internal/principals → 200
Operator: create a principal and its first key.
POST /internal/principals/{principal_id}/suspend → 200
Operator: suspend or unsuspend a principal (the enforcement point).
POST /internal/api-keys/{key_id}/revoke → 200
Operator: revoke any key.
POST /internal/selftest/error → 200
Operator tool: raise one unhandled error on purpose to verify the error path end to end.
GET /internal/signup-codes/{email} → 200
Development stacks without a mail server: read a pending code.
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, plus the sinks deleted in the last 7 days whose intake prefix it must purge. Empty while AL_SINKS_ENABLED is off.
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); ledger rows committed more than 7 days ago are pruned.
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.
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): 200 only for a bucket of an existing warehouse -- 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 (a deleting catalog included), so nobody can make the gateway mint certificates for arbitrary names. Answers domain, bucket, warehouse_id, kind (tables|blobs), issue. Called on the docker network; never served publicly.
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.
POST /internal/usage → 200
The host's hourly maintenance run reports one warehouse's footprint (bytes, objects, blob bytes and objects); recording it recomputes the scope's storage quota.
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 (research/27).
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.