API reference¶
Base URL when running locally: http://127.0.0.1:5000. The same information is available interactively at /docs
and as an OpenAPI document at /openapi.json.
If the server has any key configured - SQL2API_API_KEY or a scoped key created through /api_keys - send it
with every request as X-API-Key: <key> (/health, /docs, /ui, /openapi.json and /metrics are always
public; /openapi.json's saved-query section still varies with who's asking). See
Authentication and permissions.
| Endpoint | Method | Purpose |
|---|---|---|
/execute_sql |
POST | Run ad-hoc SQL |
/q/<name> |
GET, POST | Run a saved query as an endpoint |
/execute_sql_from_file |
POST | Run a saved query by file path |
/execute_sql_with_parameters_from_file |
POST | Same as above (kept for compatibility) |
/save_sql_to_file |
PATCH | Save a query / add a version |
/list_files |
GET | List saved queries |
/saved_sql/<name> |
DELETE | Delete a saved query or one version |
/view_file_content |
GET | Raw saved-query file |
/connections |
GET, PATCH | List / add / update connections |
/connections/<name> |
DELETE | Delete a connection |
/connections/<name>/schema |
GET | List a connection's tables/views and their columns |
/api_keys |
GET, POST | List / create scoped API keys |
/api_keys/<name> |
PATCH, DELETE | Update / revoke a scoped API key |
/health |
GET | {"status": "ok", "version": "..."} |
/metrics |
GET | Prometheus text-format metrics |
Common query parameters¶
These apply to every endpoint that returns rows.
| Parameter | Default | Description |
|---|---|---|
format |
json |
json, ndjson, csv, tsv, xml, yaml or xlsx. For the POST endpoints it may also be given in the JSON body. |
page |
1 |
1-based page number. |
page_size |
10 |
Rows per page, at most SQL2API_MAX_PAGE_SIZE (default 1000). |
timeout |
server limit | Seconds the query may run before it is cancelled with a 504. It can lower the server limit (SQL2API_QUERY_TIMEOUT, default 30; 0 disables it) but never raise it. For the POST endpoints it may also be given in the JSON body. |
Any trailing LIMIT/OFFSET in the SQL is replaced by the requested page. Responses carry
X-Page, X-Page-Size and X-Has-More (true when another page exists). A query that returns no rows answers
{"message": "No results returned"}.
Execute SQL¶
POST /execute_sql?format=json&page=1&page_size=5
{
"sql": "SELECT * FROM actor WHERE actor_id > :min AND first_name LIKE :name",
"params": {"min": 10, "name": "A%"},
"connection_name": "sakila-sqlite"
}
:namemarkers are bound parameters: values are sent to the database separately from the SQL. Markers inside string literals, comments and Postgres::casts are ignored. Every marker needs a value inparams.- Unless the server sets
SQL2API_ALLOW_WRITES=1, only a singleSELECT,WITH,SHOW,DESCRIBE,EXPLAINorVALUESstatement is accepted (403 otherwise; 400 for several statements).
Streaming exports¶
page_size is capped (SQL2API_MAX_PAGE_SIZE, default 1000) - there is normally no way to pull a full result
larger than that in one call. ?stream=true lifts that cap: the whole result is streamed straight from the
database cursor as it comes in, rather than built up in memory first, so an export far larger than fits in memory
can still be downloaded. It works on both POST /execute_sql and GET/POST /q/<name>:
curl -X POST 'http://127.0.0.1:5000/execute_sql?stream=true&format=csv' \
-H 'Content-Type: application/json' \
-d '{"sql": "SELECT * FROM actor", "connection_name": "sakila-sqlite"}' -o actor.csv
- Only
format=csv,tsvorndjsonare streamable (400 forjson/xml/yaml/xlsx- those formats all need the whole document structure in memory to write correctly, so paging still applies to them normally). page/page_sizeare rejected together withstream=true(400) - the whole point is that there is no page.- Always read-only, regardless of
SQL2API_ALLOW_WRITESor the calling key's own write permission - a large export has no business mutating data. A write statement gets the usual 403 from the SQL guard. - The response carries
Content-Disposition: attachmentwith a filename, so a browser hitting the URL directly downloads it rather than navigating in-page. - For a saved query, the run is still appended to
execution_historyonce the stream finishes, butcache_ttlis ignored - caching would mean building the whole body in memory first, exactly what streaming avoids. - If the connection or SQL itself is invalid, that surfaces as the usual JSON error before any data is sent. A failure partway through an already-started stream cannot change the response's status or body shape any more, though - the client just sees the download end early; check the server log for what actually happened.
- How much this bounds the server's memory, not just removing the cap, varies by database - MySQL, PostgreSQL and
ClickHouse stream from the server without buffering the whole result client-side first; SQLite, H2, the generic
jdbctype and DuckDB still bound this project's own memory to one batch at a time, but the underlying engine or driver may materialise more than that internally - see Connection pooling for the per-dialect detail and what it means for how long a pooled connection stays checked out. SQL2API_STREAM_MAX_ROWS, if set, caps how many rows a single export returns - unlikepage_size, nothing boundsstream=trueby default, since the whole point is not buffering the result to know its size up front. A malformed value is rejected at startup, the same asSQL2API_RATE_LIMIT. Past the cap, the export ends early (whatever HTTP headers and rows already went out stand; there's no way to retroactively mark an in-progress200as partial) and aWARNINGis logged naming the connection and the limit hit.GET /metrics'ssql2api_stream_exports_totalcounts a capped export understatus="truncated", distinct from"success"; a saved query'sexecution_historystill records it as"success"with the truncated row count - it did succeed, just not to completion.
Save a query¶
PATCH /save_sql_to_file
{
"filename": "actor_by_id",
"sql_query": "SELECT * FROM actor WHERE actor_id = :id",
"query_parameters": {"id": "int"},
"connection_name": "sakila-sqlite",
"author": "anantha",
"description": "Look up an actor",
"tags": ["example"]
}
filenamemay contain letters, digits, spaces,.,_and-. Saving to an existing name creates the next version.query_parametersdeclares the query's parameters and their rules. The definitions are checked when you save (400 with anerrorsmap if any is invalid), and every parameter declared must be used insql_query.connection_name(optional) is the default connection when a run does not name one.cache_ttl(optional, seconds) caches a response - see Response caching.- Response:
{"message": "...", "filename": "actor_by_id", "uuid": "...", "version": 2}.
Run a saved query¶
GET /q/actor_by_id?id=7&format=csv - query-string arguments other than format, page, page_size,
connection_name and version become parameters.
POST /q/actor_by_id - the JSON body may contain params, connection_name, version and format:
{"params": {"id": 7}, "connection_name": "sakila-sqlite", "version": 1}
- The latest version runs unless
versionis given. connection_namefrom the request wins over the saved default.- Each run is appended to that version's
execution_history(last 50 runs: time, connection, status, rows, duration, plusrequest_id/key_name- see Observability).
The older endpoints POST /execute_sql_from_file and POST /execute_sql_with_parameters_from_file do the same thing
with the query named in the body:
{
"filepath": "saved_sql/actor_by_id.json",
"connection_name": "sakila-sqlite",
"placeholders": {"id": 7},
"format": "csv"
}
filepath may be a bare name (actor_by_id), a path relative to the data folder, or an absolute path - but it must
resolve to a .json file inside saved_sql/.
Response caching¶
Opt-in, per saved query: set cache_ttl (seconds) when saving it. A cached response is only ever
served for that exact name, version, connection, resolved parameter values, format and page - anything else is a
separate entry. It is never used for a query whose SQL is a write (INSERT/UPDATE/DELETE/DDL), regardless of
cache_ttl: caching such a query would silently skip the write on every call after the first.
A fresh response carries ETag, Cache-Control: max-age=<cache_ttl> and X-Cache: MISS. A request within the TTL
gets the same body with X-Cache: HIT; send back If-None-Match: <ETag> to get 304 Not Modified with no body
instead. A cache hit is not appended to execution_history - nothing ran against the database. The cache is kept in
this one process's memory (see the note on /metrics' sql2api_pool_idle_connections for what that means for a
multi-process deployment) and is shared across every API key that can use the connection - it stores nothing an
authorized caller could not already see by running the query itself.
Parameter rules¶
query_parameters maps each parameter name to its type, or to an object of rules:
{
"rating": {"type": "str", "enum": ["G", "PG", "R"], "default": "PG", "description": "MPAA rating"},
"max_length": {"type": "int", "min": 1, "max": 600, "default": 120},
"title": {"type": "str", "required": false, "min_length": 2, "pattern": "[A-Za-z ]+"},
"id": "int"
}
| Rule | Applies to | Meaning |
|---|---|---|
type |
any | int, float, str or bool (aliases integer, number, string, boolean). Query-string text is converted; JSON values must already have the right type. Untyped parameters accept any single value. |
required |
any | Defaults to true, or to false when a default is given. An optional parameter with no value and no default is bound as NULL, so (:title IS NULL OR title LIKE :title) works. |
default |
any | Used when the request supplies nothing. It must itself satisfy the other rules. Cannot be combined with "required": true. |
enum |
any | The value must be one of these. |
min, max |
numbers | Inclusive bounds. |
min_length, max_length |
text | Length bounds. |
pattern |
text | A regular expression the whole value must match (at most 500 characters). |
description |
any | Shown in /docs. |
Requests that break a rule are rejected with 400 before anything reaches the database, with every problem listed:
{
"error": "Invalid parameters: rating must be one of: G, PG, R; max_length must be at most 600",
"errors": {"rating": "must be one of: G, PG, R", "max_length": "must be at most 600"}
}
Parameters used in the SQL but not declared still work: they are required and passed through as supplied.
Requests rejected this way are not recorded in the query's execution_history.
Text placeholders (legacy)¶
Saved SQL may also contain {name} placeholders, which are substituted as text before the query runs. Because the
value becomes part of the SQL it must be a number, a boolean, or a string made only of letters, digits, whitespace and
. , : @ % + / -; anything else is rejected. Prefer bound :name parameters.
List saved queries¶
GET /list_files?sort_by=name&sort_order=desc - sort_by is name (default) or modified, sort_order is asc
(default) or desc. Returns every query with its versions' metadata (not the SQL text).
Delete a saved query¶
DELETE /saved_sql/actor_by_id removes the whole query; DELETE /saved_sql/actor_by_id?version=2 removes one
version (the file goes with its last version).
View a saved query file¶
GET /view_file_content?filename=actor_by_id returns {"content": "<raw file text>"}. Only files in saved_sql/
can be read.
Connections¶
GET /connections lists connections; stored passwords are shown as ******** (values that are ${ENV_VAR}
references are shown as written). Each entry also carries a live usage object -
{"queries": ..., "errors": ..., "rows": ..., "avg_duration_ms": ...} - aggregated from the same in-process
counters /metrics renders (see Observability), so the admin UI's Connections tab can show
how much a connection has actually been used without a separate Prometheus query. avg_duration_ms is
null until at least one query has run against it since this process started; the numbers reset on restart
- they are a live view of this process, not a durable history (see a saved query's own
execution_history for that).
PATCH /connections adds or replaces connections. Sending the ******** mask back for an existing connection keeps
its stored password.
{
"connections": {
"reporting": {
"db": "postgres",
"host": "db.internal",
"port": 5432,
"database": "reports",
"user": "readonly",
"password": "${REPORTING_PASSWORD}",
"active": true
}
}
}
DELETE /connections/reporting removes one. See DATABASE_CONNECTION_CONFIGURATION.md
for the connection fields.
A ${VAR} password reference is expanded from the environment at connection time and never written to disk as
plaintext. A literal password is encrypted at rest when SQL2API_SECRET_KEY is set (see
Encryption at rest below); without that variable it is stored
as given, in db_connections.json on disk, not just masked in API responses. The server logs a startup
warning naming any connection whose password is still a literal string with no protection at all, so a
deployment that hasn't adopted either convention finds out - nothing blocks it, this is a nudge, not an
enforcement.
Encryption at rest for connection passwords¶
Set SQL2API_SECRET_KEY to a Fernet key (python -c "from cryptography.fernet import Fernet;
print(Fernet.generate_key().decode())") and every literal connection password - existing ones immediately at
startup, new ones the moment they're saved - is encrypted before it touches disk, decrypted only in memory at
the instant a connection is actually opened. Needs the cryptography package
(pip install "sql2api[encryption]", included in [all]); a clear error at startup names the missing package
if SQL2API_SECRET_KEY is set without it. A ${VAR} reference is untouched either way - it was never a
secret stored in the file to begin with.
An encrypted password is masked the same as a literal one in GET /connections and the audit log
(********) - the stored ciphertext itself is never returned to a client. Losing or rotating
SQL2API_SECRET_KEY fails clearly rather than quietly: a connection whose password can't be decrypted
returns a 500 naming the problem, and the server logs a startup warning if encrypted passwords exist on
disk but no key is configured to read them - the same "fail closed, say why" precedent expires_at and
rate_limit already follow for a key's own malformed data. There is no way to recover an encrypted password
without the key that encrypted it; keep SQL2API_SECRET_KEY itself somewhere safe, outside db_connections.json
and outside version control, the same way you would any other credential.
GET /connections/reporting/schema lists its tables and views for self-service query writing:
{
"tables": [
{"name": "orders", "type": "table", "columns": [
{"name": "id", "type": "integer", "nullable": false, "position": 1},
{"name": "customer_id", "type": "integer", "nullable": false, "position": 2}
]}
],
"truncated": false
}
truncated is true only if the connection has more than 5000 columns across all its tables and views combined,
in which case the list was cut off.
Authentication and permissions¶
SQL2API_API_KEY, if set, is a full-access admin key - unrestricted, exactly as before this section
existed. Scoped keys are additive, managed through /api_keys (admin only), and can only run queries: a
list of connection names they may use (or every connection), and whether they may write at all. A scoped
key can never do more than the server-wide settings already allow - allow_writes on a key can only narrow
SQL2API_ALLOW_WRITES, never widen it - and can never manage connections, saved queries or other API keys;
only the admin key can. Creating your first scoped key turns on authentication for the whole server
immediately, even without SQL2API_API_KEY set - and since only the admin key can manage the server, doing
that without also setting SQL2API_API_KEY locks configuration changes out until you do (the server logs a
warning at startup in that state).
A key's secret is never stored - only its SHA-256 hash, in api_keys.json (SQL2API_HOME). It is generated
by the server and returned exactly once, when the key is created; there is no way to recover it afterwards,
only to revoke it (DELETE /api_keys/<name>) and create a new one.
POST /api_keys creates a key:
{"name": "reporting", "connections": ["reporting-db"], "allow_writes": false}
{"name": "reporting", "key": "sk_...", "message": "Store this key now - it can't be shown again."}
connections may be omitted (or "*") for every connection, or an empty list to block all of them.
GET /api_keys lists keys (name, connections, allow_writes, active, created_at, created_from_role - never
the hash or secret). Each entry also carries a live usage object -
{"queries": ..., "errors": ..., "rows": ...} - the same live, in-process aggregation GET /connections
carries (see above), giving a key's activity alongside its grants; unlike a connection's, a key's usage
never includes avg_duration_ms - the underlying latency histograms aren't split by key, to keep /metrics'
bucketed output from growing with the number of keys (see Observability).
PATCH /api_keys/reporting changes connections, allow_writes or active (false
revokes it immediately) without rotating the secret. DELETE /api_keys/reporting removes it outright.
Creating several keys with the same grants repeatedly? See Permission roles
below for a reusable template - POST /api_keys with "role": "<name>" instead of these fields.
Per-saved-query access (external clients)¶
connections grants a key everything on a connection - every saved query on it, plus ad-hoc SQL if
allow_writes and the server allow it. That fits an internal caller, but not an external one who should
only ever reach a specific, curated list of saved queries and nothing else on the connection behind them.
A key's queries grant covers that case: a list of saved-query names it may run regardless of
connections, independent of and additive with whatever connections already allows - never a narrower
version of it. A key can have connections: [] (no connection access at all) and still run every query
named in queries, but it can never reach ad-hoc SQL through this grant, since queries only ever
authorizes the specific named saved query, not the connection behind it:
{"name": "acme-corp", "connections": [], "queries": ["monthly_revenue", "active_users"], "allow_writes": false}
queries may also be "*" for every saved query by name (still never ad-hoc SQL) - a middle tier between a
single-connection key and a full-access one, for a caller that should see the whole curated catalogue but
never write raw SQL. Omitted (or []) grants nothing extra beyond connections, unchanged from before this
field existed. /openapi.json and /docs reflect a key's actual reach: a queries-scoped key sees only
its own approved queries in the catalogue, not the full internal list.
Per-query write curation¶
A queries entry can be an object instead of a plain name, adding write access to that one query
specifically - on top of, never instead of, whatever allow_writes already grants:
{"name": "partner", "connections": [], "allow_writes": false,
"queries": ["read_orders", {"name": "submit_order", "allow_writes": true}]}
Here partner can run read_orders read-only and submit_order (a write) - a single curated write
endpoint - with no blanket write access, no connection access, and no ad-hoc SQL of any kind. A plain string
entry stays exactly what it always was: read access only. "*" can never carry write access, by design - a
key wanting write access to a specific query must enumerate its queries list explicitly rather than hiding
a write grant behind a wildcard picked for unrelated read access. Still subject to the usual ceiling: never
wider than server-wide SQL2API_ALLOW_WRITES, and the query's SQL still has to pass the normal guard (a
single statement, actually a write, and - if the key has allowed_write_ops
- one of the permitted keywords).
Key expiry¶
A key can carry an optional expires_at (YYYY-MM-DD) for time-boxed access - a trial integration, a
partner engagement with a known end date - that stops authenticating on its own once the date passes,
without anyone having to remember to come back and revoke it:
{"name": "trial-partner", "connections": ["reporting-db"], "expires_at": "2026-12-31"}
Valid through the end of that date (23:59:59), not from its start. Checked live on every request, the
same way active already is - there is no background sweep, so nothing to schedule or fail silently. Omit
it (or leave it unset) for a key that never expires; PATCH /api_keys/<name> with {"expires_at": null}
clears an existing expiry without rotating the secret, and PATCH without the field at all leaves whatever
expiry (or lack of one) the key already had untouched.
Last used¶
GET /api_keys reports last_used_at for a key once it has authenticated at least one request - useful
for noticing a stale key nobody has called in months (a candidate to revoke) or confirming a newly-issued
one actually got wired up on the other end. Updated at most once a minute per key regardless of how often
it's actually used, so a busy key doesn't turn every request into a disk write - read it as "roughly how
recently," not an exact timestamp. A key that has never been used has no last_used_at field at all.
Per-key rate limiting¶
SQL2API_RATE_LIMIT (see Rate limiting and CORS) applies server-wide, by client
IP, shared by every caller. Hand scoped keys to several external clients and they all draw from the same
budget - one noisy integration can exhaust it for everyone else. A key's own rate_limit gives it an
individual quota instead:
{"name": "acme-corp", "connections": [], "queries": ["monthly_revenue"], "rate_limit": "100/minute"}
Same N/period grammar as SQL2API_RATE_LIMIT (second, minute, hour or day). Checked in
addition to the server-wide limit, never instead of it - a key can never use its own quota to exceed the
ceiling every caller already sits under, and a per-key limit still applies even when
SQL2API_RATE_LIMIT is unset entirely, since throttling one specific external caller is a reasonable ask
on its own. Omitted (or null) means no limit of this key's own - PATCH /api_keys/<name> with an
explicit {"rate_limit": null} clears an existing one, the same pattern expires_at uses.
IP allowlisting¶
A key can also be pinned to allowed_ips, a list of IP addresses or CIDR ranges (IPv4 or IPv6, mixed
freely) it may authenticate from - real defense in depth for a key handed to an external party with known,
stable infrastructure, since even a leaked key then only works from an expected address:
{"name": "trial-partner", "connections": ["reporting-db"], "allowed_ips": ["203.0.113.5", "198.51.100.0/24"]}
Checked against the same client address SQL2API_TRUST_PROXY/ProxyFix already establish as trustworthy
for rate limiting, not re-derived here - set SQL2API_TRUST_PROXY correctly
behind a reverse proxy, or every caller looks like the proxy's own address. This restricts who may use a
key at all, independent of per-key rate limiting above, which restricts how
much a caller who is already allowed may do. A request from an address outside the list fails exactly like
a wrong key (401), not a distinct error - a caller learns nothing about why a key didn't work. Omitted
(or null) means no restriction - the admin key is never restricted by this at all. PATCH
/api_keys/<name> with an explicit {"allowed_ips": null} clears an existing restriction, the same pattern
expires_at and rate_limit use.
Write operation granularity¶
A key with allow_writes on can be narrowed further with allowed_write_ops, a list of the specific SQL
statement keywords it may actually perform - INSERT but not DELETE/DROP, for example - rather than
every write keyword being equally permitted once writes are on at all:
{"name": "ingest-bot", "connections": ["events-db"], "allow_writes": true, "allowed_write_ops": ["insert"]}
Only ever narrows write access, never widens it, and never restricts a read-only statement - a key with no
allow_writes still can't write regardless of this list. Checked in sqltools.validate_sql() against the
statement's own leading keyword (case-insensitive); a rejected statement gets 403 naming the operations
the key is permitted. Omitted (or null) means every write keyword is equally permitted, exactly today's
behaviour. PATCH /api_keys/<name> with an explicit {"allowed_write_ops": null} clears an existing
restriction, the same pattern expires_at/rate_limit/allowed_ips use.
Permission roles (templates)¶
Creating several keys with the same shape of grants - the same connections, the same curated queries, the
same rate limit - means repeating that shape by hand each time. A named role, managed through /roles
(admin only, stored separately from keys), is a reusable template for exactly that: connections,
allow_writes, queries, rate_limit, allowed_ips and allowed_write_ops, the same fields a key itself
carries (deliberately excluding expires_at, which is inherently per-key, not something a shared template
should dictate).
{"name": "reporting", "connections": ["reporting-db"], "allow_writes": false, "rate_limit": "200/hour"}
POST /api_keys with "role": "reporting" instead of specifying grants directly copies that role's fields
onto the new key once, at creation time:
{"name": "acme-corp", "role": "reporting"}
This is a template, not a live link - a key created from a role is a fully independent copy from that moment
on. authenticate() reads only the key's own stored entry on every request; the role is never consulted
again. Editing or deleting a role afterward has no effect whatsoever on a key already created from it -
there is no blast radius to updating a role once keys already exist from it, and no dangling reference to
worry about when deleting one. A key still records which role (if any) it was created from, in
created_from_role - purely informational, visible in GET /api_keys, never consulted by any permission
check.
role cannot be combined with any explicit grant field (connections, allow_writes, queries,
rate_limit, allowed_ips or allowed_write_ops) in the same POST /api_keys request - that combination
is rejected with 400, naming the conflicting fields. Create the key from the role, then PATCH it
afterward to customize it away from the template. expires_at is the one field that can still be set
alongside role, since it's per-key by nature rather than part of the shared template.
GET /roles lists roles; PATCH /roles/<name> updates one (the same explicit-null-to-clear convention as
PATCH /api_keys/<name> for rate_limit, allowed_ips and allowed_write_ops); DELETE /roles/<name>
removes it - again, with zero effect on any key already created from it.
Observability¶
Every response carries X-Request-Id (12 hex characters); log lines written while handling that request
carry the same ID and the name of the API key that made it (admin for SQL2API_API_KEY, a scoped key's
own name, or - when no key is configured at all), so a request - and who made it - can be traced through
the logs even under concurrent traffic. Plain text by default; SQL2API_JSON_LOGS=1 switches to one JSON
object per line (time, level, logger, request_id, key, message). In JSON mode, several log lines
also carry extra structured fields alongside message rather than only inside it - the per-query line
(connection, dialect, limit, offset, timeout, sql_hash), the streaming-start line (connection,
dialect, sql_hash), the slow-query warning (connection, dialect, duration_ms), and the per-request
access log line (method, path, status, duration_ms, and serialization_ms when the response went
through the paged JSON/CSV/TSV/XML/YAML/XLSX formatter) - so a log aggregator can filter or aggregate on
those directly instead of parsing the message text. sql_hash is a full SHA-256 hex digest of the SQL
text, logged alongside the full text (never instead of it) - useful for spotting "did this same query run
elsewhere/before" without a log aggregator having to store or search the SQL itself.
A query that takes at least SQL2API_SLOW_QUERY_THRESHOLD seconds (default 1; 0 disables it) is logged
as a WARNING with the connection, dialect and elapsed time.
A saved query's execution_history entries (see Save a query) also carry request_id,
key_name and serialization_ms, so a slow or failed run visible in the admin UI's History tab can be
traced back to the exact structured log line (and caller) that produced it, and so response-formatting time
can be told apart from duration_ms (query execution time) - useful for XLSX or other large-page exports,
where encoding cost can rival query time but was previously invisible, folded into "whatever's left over"
between total request latency and query latency.
GET /metrics (always public, like /health) serves Prometheus text exposition
format:
sql2api_requests_total- HTTP requests by method, endpoint, status and the calling key's name.sql2api_request_duration_seconds- the same latency, by method and endpoint only (not by key, to keep the bucketed output from growing with the number of keys).sql2api_queries_total- SQL queries by connection, dialect, status (success/error) and the calling key's name.sql2api_query_duration_seconds- the same latency, by connection and dialect only.sql2api_rows_returned_total- total rows actually returned, by connection, dialect and the calling key's name: the trimmed page for a paged query, or however many rows made it out of a streaming export before it finished or failed partway through (a partial count on failure is still counted - that data already left the server).sql2api_active_queries- a gauge of SQL queries currently executing right now, paged or mid-stream. For a streaming export this stays incremented for as long as the client keeps reading, not just for the initial query dispatch, since the underlying connection stays checked out the whole time.sql2api_serialization_duration_seconds- response body serialization latency (JSON/CSV/TSV/XML/YAML/ XLSX encoding), by output format, for paged responses only - streaming formats row by row as it goes, so there's no equivalent single span to measure there.sql2api_pool_idle_connections- idle pooled database connections currently held.sql2api_rate_limit_rejections_total- requests rejected by the rate limiter.
Metrics are kept in memory for this one process. This is correct for the image this project ships (a
single gunicorn worker - see the comment next to --workers 1 in the Dockerfile); running several worker
processes would need a shared backing store instead, which nothing here provides.
Seeing it: a built-in view, or a real dashboard¶
The admin UI's Metrics tab reads /metrics itself and renders it as stat tiles, a couple of bar charts
(requests by status, queries by connection) and a per-connection table (queries, errors, average latency,
rows) - a zero-setup live view for a deployment with no Prometheus/Grafana stack in front of it at all.
It's deliberately a snapshot, not a dashboard: the numbers are this process's own totals since it started,
with no history and no trends, the same limits above already describe. It never needs an
API key - /metrics is public - and reading it again just re-fetches the current numbers; there's no
polling or auto-refresh.
For real history, trends and alerting, scrape /metrics with Prometheus and import
documentation/grafana-dashboard.json
into Grafana (Dashboards → New → Import, then upload the file or paste its contents) - it builds on exactly
the metric names listed above, with panels for request/query rate and latency (p50/p95/p99), error rate,
rows returned, active queries, pool occupancy and rate-limit rejections. Each SQL2API process needs its own
scrape target; the dashboard doesn't aggregate across instances, matching the in-memory, per-process nature
of the metrics themselves.
Audit log¶
GET /audit_log (admin only) is a durable record of administrative changes - distinct from
Observability above, which covers live request/query traffic, not configuration changes.
Every create, update or delete of an API key, role, connection or saved query appends one entry, newest
first:
{
"entries": [
{"timestamp": "2026-09-24 10:03:11", "actor": "admin", "action": "update_key", "target": "acme-corp",
"changes": {"allow_writes": {"from": false, "to": true}}},
{"timestamp": "2026-09-24 10:01:47", "actor": "admin", "action": "create_connection", "target": "reporting",
"changes": {"db": "postgres", "host": "db.internal", "password": "********", "active": true}}
]
}
An update's changes is a diff of only the fields that actually changed ({"field": {"from": ..., "to":
...}}); a create or delete records a full snapshot of the entry instead, since there's no prior or
remaining state to diff against. A connection's password is never included as a value in either form -
masked as ******** in a snapshot (the same mask GET /connections already uses) and reported only as the
literal string "changed" in a diff, so the audit log itself never becomes a second place a real password
leaks from. An API key's entry never includes its secret or hash, the same fields GET /api_keys already
omits. Capped at the 500 most recent entries; older ones roll off, the same way a saved query's
execution_history is capped per version.
The admin UI's Audit Log tab filters this client-side over what it already fetched - an action dropdown
(populated from whatever actions actually appear) and a search box matching actor, target or timestamp -
and renders a snapshot's changes (a create/delete) with any unset field (null, "", []) omitted rather
than always listing every field, so a key or connection with few grants set doesn't read as a long, mostly
empty list. A diff (an update) is unaffected by this - it never had unset fields in it to begin with, only
whatever actually changed.
Rate limiting and CORS¶
Both are off unless the server enables them (SQL2API_RATE_LIMIT, SQL2API_CORS_ORIGINS).
- Rate limit. When enabled, every response carries
X-RateLimit-Limit(the quota) andX-RateLimit-Remaining. A client over its limit receives 429 with aRetry-Afterheader (seconds) and{"error": "Rate limit exceeded", "retry_after": 12}./healthis never limited. - Per-key rate limit. An API key can also carry its own
rate_limit(same grammar, e.g."100/minute"- see Per-key rate limiting), checked in addition to the server-wide limit above, never instead of it - a response carries both header pairs when both apply (X-RateLimit-Limit/X-RateLimit-Remainingfor the server-wide one,X-RateLimit-Key-Limit/X-RateLimit-Key-Remainingfor the key's own), and a rejection from the key's own limit reads{"error": "Rate limit exceeded for this API key", ...}- distinguishable from the server-wide rejection's plain"Rate limit exceeded". - CORS. For listed origins the server answers preflight (
OPTIONS) requests and addsAccess-Control-Allow-Originto responses, exposingX-Page,X-Page-Size,X-Has-More,X-RateLimit-*andRetry-Afterto the page's JavaScript. Allowed methods areGET, POST, PATCH, DELETE, OPTIONS; allowed request headers areContent-TypeandX-API-Key. Credentials (cookies) are not used.
Admin UI¶
/ui is a small, self-contained admin page (no build step, no external dependency) for managing
connections, saved queries and API keys, running ad-hoc SQL, reviewing the audit log, and viewing a live
snapshot of /metrics - a client of the API above, adding no server-side logic of its own. Loading the page
needs no API key; the requests it makes are gated exactly like any other client, so a scoped (non-admin) key
sees the same "only the admin key" message on the API Keys and Audit Log tabs as it does on Connections and
Saved Queries (the Metrics tab is the one exception - /metrics is public, so it works with no key at all).
It shares its API-key storage with /docs (the same browser-tab-only sessionStorage entry), so entering
the key on one page covers both.
Both the Connections and API Keys tabs have a "Usage" column showing each row's live activity (queries run,
failures, and - for a connection - average latency), sourced from the usage object both endpoints now
carry; a row with no activity since the server started reads "No activity yet" rather than showing zeros.
The API Keys tab creates, edits and revokes scoped keys: its "All
connections" checkbox toggles between the "*" wildcard and a specific set of connections, and a freshly
created key's secret is shown once, in place, with a copy button - the same one-time-only reveal the API
itself enforces, since the page never receives the secret again after that response. Its "Create from" field
lists any permission roles that exist; picking one dims the grant fields below
(they'll be copied from the role instead) and creates the key with {"name": ..., "role": ...} - only
expires_at stays editable alongside it.
The Roles tab manages roles themselves - the same connections/queries/write-access/rate-limit/allowed-IPs
form as the API Keys tab, minus expires_at and active, which don't apply to a template. Its "New key from
this" button jumps straight to the API Keys tab's "New API key" drawer with that role pre-selected.
A saved query's History tab shows execution_history with a "Caller" and "Request ID" column alongside the
existing time/connection/rows/duration/error ones, and a status filter (all/success/failed) plus a search box
over connection, caller, request ID and error text - useful once a version has accumulated more than a
handful of runs and you're looking for the failures, or everything one particular key did. Filtering happens
client-side, over the run history the page already has, so it applies instantly with no extra request.
Any JSON result with at least one numeric column gets a "Chart" toggle next to "Copy as TSV" in the Run SQL and saved-query Run tabs: a quick bar chart, off by default, of the columns on screen. It charts the current page only - up to 50 rows of it - with a visible reminder that it isn't the full result, since a chart of page 3 of 50 can look complete without being one. Label and value columns are pickable from dropdowns (value defaults to the first all-numeric column, label to the first column that isn't); rendered as inline SVG with no charting library, the same no-dependency approach the SQL editor's own syntax highlighting already uses.
Both the Run SQL tab and the New saved query drawer have a Schema panel next to their SQL editor,
backed by GET /connections/<name>/schema: it lists the selected connection's tables,
expands to show a table's columns (with type and nullability as a tooltip), and clicking a table or column
inserts its name at the cursor. It updates automatically when the connection changes, and a connection
whose schema isn't available (a jdbc connection, or one the caller isn't permitted to use) shows that
message in the panel itself rather than the page's error banner, since browsing the schema is optional, not
the action the user took.
Interactive documentation¶
/docs (Swagger UI, backed by /openapi.json) documents the generic API and also lists every saved query as its own
endpoint, generated from its latest version: its parameters with types, defaults, ranges and descriptions, and
whether a connection must be named. The SQL text is never included.
The generic part is public. When the server sets SQL2API_API_KEY, the saved-query part is only included for
requests that carry the key - paste it into the box at the top of /docs (kept in that browser tab only) or send
X-API-Key to /openapi.json.
Since /openapi.json is a standard OpenAPI 3.0 document, Postman and Insomnia can both import it directly by
URL (Postman: Import → Link) to get a ready-made collection of every endpoint, including saved queries once
you've supplied a key - no separate export step.
The API catalogue¶
GET /catalog answers a different question than /openapi.json: not just how to call a saved query
(parameters, types, connection) but under what terms - whether its response can be cached, whether the
calling key specifically can write through it, and what rate limit governs the calling key itself. That
information already exists (cache_ttl on the saved query, a key's own rate_limit, per-query write
curation - see Per-query write curation) but was otherwise only visible on
admin-only screens a scoped key can never reach:
{
"queries": [
{"name": "top_rented_films", "version": 1, "description": "Films ranked by number of rentals",
"tags": ["reporting", "films"], "connection_name": "rental_db", "parameters": {},
"cache_ttl": 60, "can_write": false}
],
"caller": {
"name": "acme-corp", "admin": false, "allow_writes": false, "allowed_write_ops": null,
"rate_limit": "200/hour", "server_rate_limit": null
}
}
queries is scoped exactly like /openapi.json's saved-query list - a query this caller cannot reach
through /q/<name> is never listed here either, so the catalogue never shows a caller something it can't
actually use. cache_ttl is null when the query isn't cached; can_write reflects this specific caller
(a query with no per-query write curation for them still reads false even if their key has blanket
allow_writes, since blanket access is already visible on their own key). caller.rate_limit is this key's
own additional limit (null if it has none of its own); caller.server_rate_limit is SQL2API_RATE_LIMIT,
checked in addition to it, never instead of it. Unlike /openapi.json, /catalog is never public - it
requires authentication like any other functional endpoint, since the whole point is answering "what can I
use," which needs a resolved caller to mean anything.
Errors¶
Errors are returned as {"error": "..."}; failed queries also include "detail" with the database's message.
| Status | Meaning |
|---|---|
| 400 | Missing or invalid input (SQL, paging, format, filename, parameters, several statements). Parameter-rule violations add an errors map keyed by parameter name. |
| 401 | Missing or wrong X-API-Key (only when a key is configured) |
| 429 | Rate limit exceeded; wait Retry-After seconds |
| 403 | Inactive connection, write statement while writes are disabled, a file outside saved_sql/, a connection this API key isn't scoped to, or a management action a scoped (non-admin) key can't perform |
| 404 | Unknown connection, saved query, version or file |
| 500 | The database rejected the query or could not be reached |
| 504 | The query exceeded its time limit and was cancelled (the response includes "timeout") |