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).
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"}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.
{"email": …, "code": "123456", "accept_terms": true, "engine": "duckdb"} (engine: duckdb|pyiceberg|spark, default duckdb)tablemere 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: 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.
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": …}tablemere recover --email …POST /v1/recover/verify → 200
The right code mints a NEW API key. Earlier keys keep working until revoked.
{"email": …, "code": "123456"}tablemere 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. 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 open-domain one), records the Terms version the form showed, and returns a session.
{"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), warehouse_ids which warehouses (default the organisation's default warehouse).
{"client": "cli", "hostname": …, "client_name": …, "requested_level": "read|write", "warehouse_ids": […]} (all optional)tablemere loginGET /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).
?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 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.
{"user_code": …, "level": "read|write", "warehouse_ids": […], "deny": false}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.
{"device_code": …}tablemere loginProjects 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 stringtablemere project create --name …GET /v1/projects → 200
Projects you own or reach through an organisation you belong to.
tablemere project listPOST /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.
{"project_id": <id or name>, "name": …} Header Idempotency-Keytablemere 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.
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).
{"confirm": "<the warehouse name, exactly>"}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.
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.
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.
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.
{"level": "read"|"write"}tablemere catalog grant --catalog … --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).
tablemere catalog revoke --catalog … --principal …GET /v1/warehouses → 200
The warehouses of one project, ids and names side by side.
?project=<id or name>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.
{"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-Keytablemere table create --catalog … --namespace … --name … --column name:type …GET /v1/namespaces → 200
Namespaces in a warehouse, read from the catalog.
?warehouse=<id or name>tablemere namespace list --catalog …GET /v1/tables → 200
Every table in a warehouse, or in one namespace.
?warehouse=<id or name>&namespace=<optional>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).
?warehouse=<id or name>&namespace=…&name=…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.
{"warehouse_id": <id or name>, "namespace": …, "name": …}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.
?warehouse_id=<id or name>&engine=duckdb (duckdb default, pyiceberg, spark, snowflake)tablemere connect --catalog … --engine duckdb|pyiceberg|spark|snowflakeOrganisations
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.
tablemere 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}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.
{"domain": "yourcompany.com"}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.
GET /v1/orgs/{org_id}/members → 200
Who is in the organisation.
tablemere 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"}tablemere 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"}tablemere org invite --email … --role member|adminPOST /v1/orgs/invitations/accept → 200
Redeem an invitation. The caller's email must be the invited one.
{"token": …}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.
{"name": …, "role"?: "admin"|"member", "grants"?: [{"warehouse_id": …, "level": "read"|"write"}, …]}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.
tablemere agent listPATCH /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.
{"name"?: …, "role"?: "admin"|"member", "grants"?: [{"warehouse_id": …, "level": …}]}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.
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.
POST /v1/api-keys → 201
Another API key for the caller (for a second machine, a CI job). Shown once.
{"label": "ci"}tablemere api-key create --label …GET /v1/api-keys → 200
The caller's keys, by id: never the secret.
tablemere 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.
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.
{"engine"?: "duckdb", "accept_terms"?: true, "organization_name"?, "handle"?, "org_id"?}GET /v1/terms → 200
The Terms version in force and whether you accepted it.
tablemere termsPOST /v1/terms/accept → 200
Record acceptance of the current Terms version.
{"version"?: <the current version>}tablemere terms --acceptDELETE /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).
{"confirm": "<your email>"}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.
?project_id=<optional id or name>&days=<1..30, default 7: the per-catalog history length>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.
?org_id=<optional>&since=<24h|7d|30m|ISO-8601>&action=<name or prefix*>&limit=<1..1000, default 100>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.
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/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.
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.