API reference

Base URL https://api.tablemere.eu. 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.tablemere.eu and the object store at https://s3.tablemere.eu are separate surfaces with their own credential, the catalog's catalog_credential from GET /v1/connection. They never accept Tablemere 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).

Auth none
Response issuer, jwks_uri, token_endpoint, grant_types_supported

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), token_type: Bearer, expires_in: 900, principal_id
Errors 401 invalid_api_key, 401 api_key_revoked, 403 principal_suspended, 429 rate_limited after 10 wrong secrets for one key id within 600 s (Retry-After header)

GET /healthz → 200

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

Auth none
Response ok, catalog_ok, issuer, kid

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).

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} (claimed domain), terms{version, url, privacy_url, note}, next, note, expires_in (600 s), code_length (6)
Errors 400 invalid_value (email; handle: reason invalid|reserved, suggestion), 409 handle_taken (suggestion), 409 already_exists (the address has an account; recover the key instead), 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 tablemere 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.

Auth none
Request {"email": …, "code": "123456", "accept_terms": true, "engine": "duckdb"} (engine: duckdb|pyiceberg|spark, default duckdb)
Response api_key (shown once), api_key_note, principal_id, project_id, warehouse_id, organization{org_id, role, joined_via}, connection{as GET /v1/connection}, next[], terms{version, accepted, url}
Errors 404 no_pending_signup, 410 code_expired, 401 invalid_code (attempts_left; five attempts, then a new code is needed), 400 terms_required (accept_terms must be exactly true), 409 already_exists
CLI tablemere 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 verification_required on the managed service

GET /v1/handles/{handle} → 200

Is this organisation handle free? For the signup form as you type (per-address rate limit). Handles: 2-40 lowercase letters, digits and dashes, starting and ending with a letter or digit; app, api, docs, legal, admin, tablemere, www, s3, catalog and auth are reserved.

Auth none
Response handle, valid, available, reason (invalid|reserved|taken|null), suggestion (a free handle near it), pattern
Errors 429 rate_limited

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.

Auth none
Request {"email": …}
Response email, delivery (email when a code was mailed; smtp when the address has no account and nothing was sent; log on a development stack), expires_in (600 s), next
Errors 429 rate_limited (same 5/hour/address budget as signup; retry_after_seconds when a code is under 60 s old)
CLI tablemere recover --email …

POST /v1/recover/verify → 200

The right code mints a NEW API key. Earlier keys keep working until revoked.

Auth none
Request {"email": …, "code": "123456"}
Response api_key (shown once), key_id, principal_id, note
Errors 404 no_pending_recovery, 410 code_expired, 401 invalid_code (attempts_left)
CLI tablemere recover --email … --code … --save

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. The code path for agents and the CLI stays at POST /v1/signup/start.

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}, terms, next, return_to
Errors 400 invalid_value (handle grammar or a reserved word; suggestion carries a free one), 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 open-domain one), records the Terms version the form showed, and returns a session.

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
Errors 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).

Auth none
Request {"email": …, "password": …}
Response the session shape of POST /v1/auth/password/set
Errors 401 invalid_login, 403 principal_suspended, 429 rate_limited (10 failures per address per hour, retry_after_seconds; 120 attempts per caller address per hour)

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).

Auth none
Request {"refresh_token": …}
Response the session shape, with the new refresh_token
Errors 401 invalid_refresh_token | refresh_reused | session_revoked | session_expired

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.

Auth none
Request {"refresh_token": …} or Authorization: Bearer <access token>
Response revoked, session_id

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

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)

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.

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, a notice mailed; returns a fresh session.

Auth none
Request {"token": …, "password": …}
Response the session shape plus sessions_revoked, first_password
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; a notice is mailed.

Auth Authorization: Bearer <API key or token>
Request {"current_password": …, "new_password": …}
Response changed: true, other_sessions_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)

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), warehouse_ids which warehouses (default the organisation's default warehouse).

Auth none
Request {"client": "cli", "hostname": …, "client_name": …, "requested_level": "read|write", "warehouse_ids": […]} (all optional)
Response device_code, user_code (XXXX-XXXX), verification_uri, verification_uri_complete, expires_in (900), interval (5), client_name, requested_level, warehouse_ids
Errors 429 rate_limited (60 per hour per caller address), 400 invalid_value
CLI tablemere login

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, the warehouse(s) it would be connected to, the requested level and the level it would hold, capped by the caller's own on each warehouse (capped: true when lowered).

Auth Authorization: Bearer <API key or token>
Request ?user_code=XXXX-XXXX
Response user_code, client, hostname, client_name, requested_level, level, organization, warehouses[{warehouse_id, name, level, my_level, my_via, capped, pending}], expires_in
Errors 404 device_code_not_found (mistyped, expired or already decided), 403 forbidden (a token), 409 org_required

POST /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 approved grants, capped by the approver's own level -- or refuses it with deny: true. level lowers the requested level (never raises it); warehouse_ids picks the warehouses. An organisation without a warehouse gets "My catalog" first. Only humans; a token approving would hand out a key of the token.

Auth Authorization: Bearer <API key or token>
Request {"user_code": …, "level": "read|write", "warehouse_ids": […], "deny": false}
Response approved, user_code, client, hostname, client_name, label, requested_level, connection{name, level, org_id, warehouses[{warehouse_id, name, level}]}, note
Errors 404 device_code_not_found (mistyped, expired or already decided), 403 forbidden (a token; or a warehouse the approver holds nothing on), 404 warehouse_not_found, 409 org_required

POST /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.

Auth none
Request {"device_code": …}
Response api_key, key_id, label, principal_id (the agent's), kind: agent, token_type: api_key, connection{agent_id, name, org_id, role, warehouses[{warehouse_id, name, level}], created_by, grants}, note
Errors 400 authorization_pending | slow_down | expired_token | access_denied | invalid_grant
CLI tablemere login

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.

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 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 tablemere project create --name …

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 tablemere project list

POST /v1/warehouses → 201

A warehouse is one Iceberg catalog: one table bucket (w-<uuid>) plus one storage identity confined to it by the catalog. Also a blob bucket (b-<uuid>) for non-Iceberg files.

Auth Authorization: Bearer <API key or token>
Request {"project_id": <id or name>, "name": …} Header Idempotency-Key
Response warehouse_id, name, project_id, project, provider, region, catalog_uri, warehouse (s3://w-<uuid>/), warehouse_name (w-<uuid>), maintenance{compaction: true, target_file_size_mb: 128, snapshot_retention_days: 7, max_snapshots: 20, disableable: false}
Errors 409 already_exists, 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 tablemere catalog create --project … --name …

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 keys and every catalog token minted from them are refused at once; storage sessions already vended run out on their own within 900 s.

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, rotated_at, note, warning (only if an old key could not be deleted)
Errors 404 warehouse_not_found, 409 ambiguous_name (two warehouses share the name; candidates[])
CLI tablemere catalog rotate --catalog …

DELETE /v1/warehouses/{warehouse_id} → 200

Delete a warehouse for good, with its name typed in the body. Immediate and irreversible: tables dropped, both buckets deleted, both identities and their policies removed, grants and usage samples removed. Deleted now from the live system; 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 warehouse name, exactly>"}
Response deleted: true, warehouse_id, name, project_id, tables_dropped, bytes_at_deletion, measured_at, grants_removed, usage_samples_removed, storage{step: ok|HTTP n}, storage_complete, note, warning (only when something is left for the operator)
Errors 400 confirmation_required (confirm_with names the expected value), 403 forbidden (not an admin), 404 warehouse_not_found, 409 delete_in_progress (Retry-After)
CLI tablemere catalog delete --catalog … --confirm <catalog name>

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.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 keys and every catalog token minted from them are refused at once; storage sessions already vended run out on their own within 900 s.

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, rotated_at, note, warning (only if an old key could not be deleted)
Errors 404 warehouse_not_found, 409 ambiguous_name (two warehouses share the name; candidates[])
CLI tablemere catalog publish --catalog … --confirm <name>

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; bytes return to the private tier.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 keys and every catalog token minted from them are refused at once; storage sessions already vended run out on their own within 900 s.

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, rotated_at, note, warning (only if an old key could not be deleted)
Errors 404 warehouse_not_found, 409 ambiguous_name (two warehouses share the name; candidates[])
CLI tablemere catalog unpublish --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.

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}]
Errors 404 warehouse_not_found
CLI tablemere catalog grants --catalog …

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.

Auth Authorization: Bearer <API key or token> — admin, or a human holding write (granting at most their own level)
Request {"level": "read"|"write"}
Response warehouse_id, principal_id, kind, display, level, via, granted_by
Errors 400 invalid_value, 403 forbidden (level, can_grant[]), 404 principal_not_found (not in this organisation)
CLI tablemere catalog grant --catalog … --principal … --level read|write

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).

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
Errors 403 forbidden, 404 grant_not_found, 409 implicit_grant
CLI tablemere catalog revoke --catalog … --principal …

GET /v1/warehouses → 200

The warehouses of one project, ids and names side by side.

Auth Authorization: Bearer <API key or token>
Request ?project=<id or name>
Response project, project_id, catalog_uri, warehouses[{name, warehouse_id, warehouse, created_at, my_level (read|write|none), my_via}], limit, headroom
CLI tablemere catalog list --project …

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": 2} (2 default; 3 is writable only by Spark 3.5 + Iceberg 1.11) Header Idempotency-Key
Response table (ns.name), warehouse_id, warehouse, namespace, name, table_uuid, location, format_version, format_version_requested, format_version_honoured; for v3 also warnings[], writable_by[], not_writable_by[]
Errors 400 missing_field, 400 invalid_value (format_version), 404 warehouse_not_found, 409 quota_exceeded (over the storage limit), 4xx/502 catalog_error (catalog_status, catalog_type, catalog_message)
CLI tablemere table create --catalog … --namespace … --name … --column name:type …

GET /v1/namespaces → 200

Namespaces in a warehouse, read from the catalog.

Auth Authorization: Bearer <API key or token>
Request ?warehouse=<id or name>
Response warehouse, warehouse_id, namespaces[{namespace}]
CLI tablemere namespace list --catalog …

GET /v1/tables → 200

Every table in a warehouse, or in one namespace.

Auth Authorization: Bearer <API key or token>
Request ?warehouse=<id or name>&namespace=<optional>
Response warehouse, warehouse_id, namespaces_searched[], tables[{namespace, name, table}]
Errors 404 namespace_not_found (available[])
CLI tablemere table list --catalog … [--namespace …]

GET /v1/table → 200

Schema, the real format version and counts, read from the table's current metadata. Rows are counted from the snapshot summary when the writer left one, else from the manifest list (rows_source says which).

Auth Authorization: Bearer <API key or token>
Request ?warehouse=<id or name>&namespace=…&name=…
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; warnings[] for v3
Errors 404 namespace_not_found, 404 table_not_found (available[])
CLI tablemere table get --catalog … --namespace … --name …

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.

Auth Authorization: Bearer <API key or token>
Request {"warehouse_id": <id or name>, "namespace": …, "name": …}
Response table, location, scope, level (write), identity (t-<uuid>), 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 read or none; remedy names the grant command or /v1/connection), 404 namespace_not_found, 404 table_not_found, 409 quota_exceeded (over the storage limit: no new write credential; reading through the catalog credential still works), 502 catalog_error
CLI tablemere 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). Tablemere is never in the query path: the engine talks to the catalog and the object store directly.

Auth Authorization: Bearer <API key or token>
Request ?warehouse_id=<id or name>&engine=duckdb (duckdb default, pyiceberg, spark, snowflake)
Response engine, catalog_uri, warehouse (s3://w-<uuid>/), warehouse_name (w-<uuid>), level (read|write), level_via, identity (t-<uuid> | t-<uuid>-ro), catalog_credential{client_id, client_secret, oauth2_server_uri, grant: client_credentials, note}, storage_endpoint, blob_bucket (b-<uuid>), blob_note, blob_example_python, token_endpoint, token_exchange{…}, warehouse_id, project_id, notes[] (the first says the level), and per engine: sql (duckdb, snowflake) | python (pyiceberg) | conf{spark.sql.catalog.lake.*} (spark)
Errors 400 unsupported_engine (available[]), 403 forbidden (no grant on the warehouse; can_grant[] names who can), 404 warehouse_not_found
CLI tablemere connect --catalog … --engine duckdb|pyiceberg|spark|snowflake

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.

Auth Authorization: Bearer <API key or token>
Response organizations[{org_id, name, handle, status, role (admin|member), domain_join, domains[{domain, verified_via}], members, created_at}]
CLI tablemere org list

PATCH /v1/orgs/{org_id} → 200

Rename the organisation, or let people whose verified email is on a claimed domain join as members.

Auth Authorization: Bearer <API key or token> — admin of the organisation
Request {"name"?, "domain_join"?: true|false}
Response the organisation as in GET /v1/orgs
Errors 403 insufficient_role, 400 no_domain (claim a domain first)
CLI tablemere 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.

Auth Authorization: Bearer <API key or token> — admin
Request {"domain": "yourcompany.com"}
Response the organisation
Errors 400 public_mail_domain, 403 domain_not_yours, 409 domain_claimed
CLI tablemere 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.

Auth Authorization: Bearer <API key or token> — admin
Response the organisation, plus removed: <domain>
Errors 404 domain_not_found

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 tablemere org members

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 tablemere org role --principal … --role admin|member

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.

Auth Authorization: Bearer <API key or token> — admin, or any member removing themselves
Response org_id, principal_id, removed: true, left
Errors 404 member_not_found, 403 insufficient_role, 409 last_admin

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.

Auth Authorization: Bearer <API key or token> — admin
Request {"email": …, "role": "member"|"admin"}
Response org_id, email, role, token (shown once), mailed, expires_in (7 days), accept_with, note
CLI tablemere org invite --email … --role member|admin

POST /v1/orgs/invitations/accept → 200

Redeem an invitation. The caller's email must be the invited one.

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
CLI tablemere 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.

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"}, …]}
Response agent_id, name, org_id, role, grants[{warehouse_id, name, level}], created_by, created_by_display, created_at, last_used_at, suspended, warehouses (legacy), api_key (shown once), key_id, note
Errors 400 invalid_value (grants, role), 403 forbidden (a level above your own: your_level, requested), 403 insufficient_role (an admin token from a member), 404 warehouse_not_found (not in this organisation), 409 already_exists (name)
CLI tablemere 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.

Auth Authorization: Bearer <API key or token> — any member
Response org_id, agents[{agent_id, name, role, grants[{warehouse_id, name, level}], created_by, created_by_display, created_at, last_used_at, suspended, warehouses (legacy), note (legacy unconfined agents)}]
CLI tablemere agent list

PATCH /v1/orgs/{org_id}/agents/{agent_id} → 200

Edit a token's name, grants and/or role; 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.

Auth Authorization: Bearer <API key or token> — admin, or the member who created it
Request {"name"?: …, "role"?: "admin"|"member", "grants"?: [{"warehouse_id": …, "level": …}]}
Response the token as in GET
Errors 400 missing_field, 403 forbidden, 403 insufficient_role, 404 agent_not_found, 409 already_exists
CLI tablemere agent update --agent-id … [--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.

Auth Authorization: Bearer <API key or token> — admin, or the member who created it
Response agent_id, revoked: true
Errors 404 agent_not_found
CLI tablemere agent revoke --agent-id …

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.

Auth none
Response issuer, password_login (true when the deployment serves POST /v1/auth/login), 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.

Auth Authorization: Bearer <API key or token>
Request {"label": "ci"}
Response api_key, key_id, label, principal_id, note
CLI tablemere api-key create --label …

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}]
CLI tablemere api-key list

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 tablemere 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.

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|domain|invitation|signup-form|member)}, terms{version, accepted, url}, note
Errors 400 terms_required (a human who has not accepted the current Terms), 400 invalid_value (handle), 409 handle_taken (suggestion)

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 tablemere terms

POST /v1/terms/accept → 200

Record acceptance of the current Terms version.

Auth Authorization: Bearer <API key or token>
Request {"version"?: <the current version>}
Response accepted: true, version
Errors 409 terms_version_mismatch
CLI tablemere terms --accept

DELETE /v1/me → 200

Delete your account. Immediate: every API key revoked, identities unlinked, memberships removed, agents of organisations left empty stopped. Data of organisations left without members is erased within 30 days and leaves backups within 14 more (Terms).

Auth Authorization: Bearer <API key or token>
Request {"confirm": "<your email>"}
Response deleted: true, principal_id, keys_revoked, organisations_left_empty[], warehouses_to_purge[], note
Errors 400 confirmation_required, 409 last_admin (make someone else an admin first)
CLI tablemere 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); 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.

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, limits{storage_bytes, objects, tables, namespaces, warehouses, catalog_operations_per_month, commits_per_month, min_seconds_between_commits, snapshot_retention_days, max_snapshots_per_table}, history_days, projects[{project_id, name, org_id, warehouses{current, limit, headroom}, warehouse_ids[], storage_bytes{current, limit, headroom, unmetered, measured_at, complete, scope, scope_id, warehouses_metered, warehouses, over_limit}, public_storage_bytes{…}, warehouse_storage[{warehouse_id, name, tier, public, bytes, objects, measured_at, unmetered, 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 tablemere usage [--catalog <id or name>] [--days N] (also: tablemere whoami)

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, 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 tablemere 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.

Auth the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.tablemere.eu 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.tablemere.eu 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.tablemere.eu 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.tablemere.eu 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.tablemere.eu answers 404 for every /internal/* path

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 the bucket of an existing warehouse under the configured S3 host, 404 otherwise, so nobody can make the gateway mint certificates for arbitrary names. Called on the docker network; never served publicly.

Auth the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.tablemere.eu 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.tablemere.eu answers 404 for every /internal/* path

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.

Auth the deployment's bootstrap secret (X-Bootstrap-Secret). Not public: api.tablemere.eu answers 404 for every /internal/* path