KDB knowledge

HTTP API overview

How to talk to kdb serve over HTTP - the address, choosing the database, JSON answers, errors, safety rules and limits.

kdb serve starts a small web server. Programs send it HTTP requests (the messages a browser or curl sends) and get JSON answers (text like {"name": "value"} that every language can read).

Tutorial: chapter 8, HTTP API.

All routes

Method and pathWhat it doesHelp
GET /healthIs the server alive?GET /health
GET /exec?query=SQLRun SQL that only readsGET /exec
POST /execRun any SQLPOST /exec
GET /exp, POST /expRun a SELECT, answer as a CSV fileGET /exp
GET /v/<name>?param=valueRun a saved query with its parameters (CREATE ENDPOINT)endpoints
POST /write, POST /api/v2/writeLoad rows in the InfluxDB line formatline protocol
POST /impLoad a CSV file into a tablePOST /imp
DELETE /exec/<id>Stop a running statementDELETE /exec/<id>
GET /api/infoTables, columns, sizes, damage, last backupGET /api/info
GET /api/diskWhat a database takes on disk, part by partGET /api/disk
GET /api/revCommitted revision of a table and of each symbol: poll it, read again only on a changeGET /api/rev
GET /api/changesEvery committed change of a table after an LSN, in order (server-sent events or JSON)GET /api/changes
GET /api/subscribeA live query: its answer, then the rows each commit changedGET /api/subscribe
POST /api/checkRead every file and look for damagePOST /api/check
POST /api/backup, GET /api/backupsMake a backup, list backupsPOST /api/backup
GET /api/trigger-queueQueues of deferred triggers, and a wait for their jobsGET /api/trigger-queue
GET /api/statsMemory, processor, running statements, open databasesGET /api/stats
GET /api/settings, POST /api/settings, POST /api/restartThe data folder and other saved settings; restart in placeGET /api/settings
GET /api/activityRecent server events for the console's activity panelactivity
GET /api/auditThe audit logGET /api/audit
GET /api/timers, POST /api/timers, POST /api/timers/<name>/...SQL that runs on a schedule (admins)timers
GET /api/pins, POST /api/pins, DELETE /api/pinsStatements whose results are kept ready (admins)pinned queries
GET /api/databases, POST /api/databases, DELETE /api/databases/<name>List, create and drop databasesdatabases
POST /session, DELETE /session/<id>A transaction over many requestssessions
POST /cursor, GET /cursor/<id>, DELETE /cursor/<id>Read a big result a part at a timecursors
POST /api/login, POST /api/logout, GET /api/meLog in and out, who am Ilogins
POST /api/feedback, GET /api/feedback, POST /api/feedback/<id>Send comments and suggestions; admins read and mark themcomments and suggestions
GET /The web consoleGET /
GET /helpThis help pageGET /help
GET /skill.mdThe Claude skill fileGET /skill.md

Every GET route also answers HEAD: the same answer without the body.

Choosing the database

One server can serve many databases. Each request picks one:

Syntax:

/exec?db=<name>            (a URL parameter, or a form field db=)
X-KDB-Database: <name>     (a header)
WhereMeaningDefault
db=<name>The database of this request. Wins when both are given.-
X-KDB-Database: <name>The same as a header (an extra line of the request). Handy when a program always uses one database.-
neitherThe server's default database: --db <name> of kdb serve, else default (made when it is first used).default

HTTP: The same database with db= and with the header (sample data: sample)

GET /exec?db=demo&compat=0&query=SELECT count(*) AS securities FROM securities
###
POST /exec?compat=0
X-KDB-Database: demo

SELECT symbol, name FROM securities ORDER BY symbol LIMIT 2

Response:

GET /exec?db=demo&compat=0&query=SELECT count(*) AS securities FROM securities

HTTP 200 (application/json)

{
  "query": "SELECT count(*) AS securities FROM securities",
  "results": [
    {
      "statement": "SELECT count(*) AS securities FROM securities",
      "columns": [
        {"name": "securities", "type": "LONG"}
      ],
      "dataset": [
        [5]
      ],
      "count": 1,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 26, "execute_us": 66}
    }
  ],
  "timings": {"parse_us": 26, "execute_us": 66}
}

POST /exec?compat=0

HTTP 200 (application/json)

{
  "query": "SELECT symbol, name FROM securities ORDER BY symbol LIMIT 2",
  "results": [
    {
      "statement": "SELECT symbol, name FROM securities ORDER BY symbol LIMIT 2",
      "columns": [
        {"name": "symbol", "type": "SYMBOL"},
        {"name": "name", "type": "STRING"}
      ],
      "dataset": [
        ["ACME", "Acme Investment Company"],
        ["CITY", "City Bank Limited"]
      ],
      "count": 2,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 51, "execute_us": 163}
    }
  ],
  "timings": {"parse_us": 51, "execute_us": 163}
}

HTTP: A database that does not exist

GET /exec?db=no_such_db&query=SELECT 1

Response:

HTTP 404 (application/json)

{"error": "database 'no_such_db' does not exist; create it with CREATE DATABASE no_such_db"}

Answers and errors

Every answer (except CSV files, the console page and 204) is JSON with Content-Type: application/json; charset=utf-8.

A status code is the number at the start of every HTTP answer. 200 means OK. 400 and more means an error.

StatusMeaning
200OK
204OK, no body (the line protocol answers this)
304Not modified: the If-None-Match tag of /exec, or the browser's copy of a console or help file, is still current. No body.
400The request or the SQL is wrong
401A login or token is needed, or it is wrong (logins)
403Refused: a cross-site request, a role that is too weak, or a host name that is not allowed
404No such route, database, cursor or session
405Wrong method: for example GET /exec with SQL that changes data, or PUT anywhere
408The request did not arrive within 30 seconds, or a statement ran past the statement timeout
409The database already exists, or is in use; or a retried request whose first answer was too big to keep
422An Idempotency-Key used again for a different request
413The body is bigger than 64 MB, or a statement passed a memory limit
429Too many failed logins, too many statements at once, or over a rate limit: wait (see the Retry-After header)
431The headers are bigger than 64 KB
500Trouble on the server: a damaged file, a database locked by another program
501A chunked body (Transfer-Encoding: chunked): send Content-Length instead
503Busy: another transaction held the lock too long, or too many sessions. Also every new request while the server is stopping, with Retry-After: 2 (stopping).
507The disk is full: nothing of the request was saved. It works again once space is freed. /write answers {"code": "disk full", ...}.

An error answer has an error text. /exec adds query, the results of the statements that ran before the error, and line and column when KDB knows the place:

HTTP: An error with its position (sample data: sample; shows an error)

POST /exec?compat=0

SELECT clsoe FROM ochl_day

Response:

HTTP 400 (application/json)

{
  "query": "SELECT clsoe FROM ochl_day",
  "results": [],
  "saved": 0,
  "rolled_back": false,
  "error": "line 1, column 8: unknown column 'clsoe' (table 'ochl_day' has: time symbol open high low close volume)",
  "sqlstate": "42703",
  "line": 1,
  "column": 8
}

HTTP: A route that does not exist

GET /nothing-here

Response:

HTTP 404 (application/json)

{"error": "not found"}

HTTP: A method the route does not have

PUT /exec

SELECT 1

Response:

HTTP 405 (application/json)

{"error": "method PUT is not allowed here"}

GET reads, POST changes

GET /exec and GET /exp only run SQL that reads: SELECT, SHOW ..., EXPLAIN, CHECK TABLE, USE. SQL that changes something must be sent with POST. The answer to a GET that changes data is 405.

Why: a link or a picture on any web page can make your browser send a GET. So a bad page must not be able to change your data with a GET.

HTTP: DELETE with GET is refused (sample data: sample)

GET /exec?query=DELETE FROM brokers WHERE id = 1

Response:

HTTP 405 (application/json)

{"error": "this SQL changes data, so it must be sent with POST /exec (GET /exec only reads)"}

More in GET /exec.

Retrying safely: Idempotency-Key

A client that sends a write and loses the answer (a timeout, a dropped connection) cannot know whether the write happened. Sent again, it might write twice. Give the request an Idempotency-Key header, any text you choose (up to 255 characters), and send the retry with the same key: KDB answers it with the first answer and does not run it again.

HTTP: The same key twice: one row, the second answer is the first (sample data: sample)

POST /exec?compat=0

CREATE TABLE idem_orders (id INT PRIMARY KEY, note STRING)
###
POST /exec?compat=0
Idempotency-Key: order-1001

INSERT INTO idem_orders VALUES (1, 'first')
###
POST /exec?compat=0
Idempotency-Key: order-1001

INSERT INTO idem_orders VALUES (1, 'first')
###
GET /exec?query=SELECT count(*) FROM idem_orders

Response:

POST /exec?compat=0

HTTP 200 (application/json)

{
  "query": "CREATE TABLE idem_orders (id INT PRIMARY KEY, note STRING)",
  "results": [
    {
      "statement": "CREATE TABLE idem_orders (\n  id INT PRIMARY KEY,\n  note STRING\n)",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "table 'idem_orders' created",
      "timings": {"parse_us": 23, "execute_us": 4278}
    }
  ],
  "timings": {"parse_us": 23, "execute_us": 4278}
}

POST /exec?compat=0

HTTP 200 (application/json)

{
  "query": "INSERT INTO idem_orders VALUES (1, 'first')",
  "results": [
    {
      "statement": "INSERT INTO idem_orders VALUES (1, 'first')",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 1,
      "message": "1 row(s) inserted",
      "timings": {"parse_us": 18, "execute_us": 28}
    }
  ],
  "timings": {"parse_us": 18, "execute_us": 28}
}

POST /exec?compat=0

HTTP 200 (application/json)

{
  "query": "INSERT INTO idem_orders VALUES (1, 'first')",
  "results": [
    {
      "statement": "INSERT INTO idem_orders VALUES (1, 'first')",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 1,
      "message": "1 row(s) inserted",
      "timings": {"parse_us": 18, "execute_us": 28}
    }
  ],
  "timings": {"parse_us": 18, "execute_us": 28}
}

GET /exec?query=SELECT count(*) FROM idem_orders

HTTP 200 (application/json)

{
  "query": "SELECT count(*) FROM idem_orders",
  "results": [
    {
      "statement": "SELECT count(*) FROM idem_orders",
      "columns": [
        {"name": "count(*)", "type": "LONG"}
      ],
      "dataset": [
        [1]
      ],
      "count": 1,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 81, "execute_us": 342}
    }
  ],
  "columns": [
    {"name": "count(*)", "type": "LONG"}
  ],
  "dataset": [
    [1]
  ],
  "count": 1,
  "timings": {"parse_us": 81, "execute_us": 342}
}
CaseAnswer
the same key and the same request againthe first answer, with the header Idempotent-Replayed: true
the same key while the first request still runswaits for it, then its answer
the same key with a different request (another body or query string)422: a new request needs a new key
the first answer was over 1 MB (a big SELECT)409: it was done already, the answer was not kept
the first answer was 500 or more (busy, stopping)not kept: the retry runs for real

Logins, tokens and cookies in short

ServerWhat a request needs
kdb serve on 127.0.0.1, no --token, no usersNothing. Only programs on the same computer can connect.
started with --token <secret>Authorization: Bearer <secret>, or the cookie kdb_auth that POST /api/session sets
the data folder has usersa user name and password (HTTP Basic), or a login token from POST /api/login (as Authorization: Bearer <token> or the cookie kdb_login)

Everything about this: logins, tokens and roles and server security.

Safety rules

The server refuses requests that a web page in your browser could send behind your back:

RuleAnswer
The browser says the request comes from another site (header Sec-Fetch-Site: cross-site or same-site)403 cross-site requests are not allowed
Without a login: an Origin header that is not this server403 cross-origin requests are not allowed
Without --token: a Host that is not localhost or 127.x.x.x (stops "DNS rebinding")403 host name '...' is not allowed without an access token
kdb serve --bind 0.0.0.0 without --token and without usersthe server does not start

Programs like curl, Python, Go, Node and C# do not send these headers, so they are not affected.

HTTP: A request a browser marks as cross-site

GET /exec?query=SELECT 1
Sec-Fetch-Site: cross-site

Response:

HTTP 403 (application/json)

{"error": "cross-site requests are not allowed"}

HTTP: An Origin header from another site

GET /exec?query=SELECT 1
Origin: http://example.com

Response:

HTTP 403 (application/json)

{"error": "cross-origin requests are not allowed"}

CORS

CORS is how a server allows JavaScript on other web sites to call it. KDB sends no CORS headers, and OPTIONS requests get 405. So JavaScript in a page of another site cannot call KDB. This is on purpose.

Call KDB from your backend (your Go, Node, Python or C# server), not from browser code. The web console works because it is served by KDB itself.

HTTP: A browser's CORS check (OPTIONS) is not answered

OPTIONS /exec

Response:

HTTP 405 (application/json)

{"error": "method OPTIONS is not allowed here"}

Limits

LimitValueWhen it is passed
Request body64 MB; a gzip or deflate body (Content-Encoding: gzip) is unpacked first and counts unpacked413 request body is too large (... once decompressed)
Request headers64 KB431 request headers are too large
Time to send one request30 seconds408
Idle keep-alive connectionanswers say Keep-Alive: timeout=30; the server closes it after 35 seconds, so a request sent right at 30 seconds still finds it open-
Chunked request bodiesnot supported501
Rows per statement (limit=)/exec: 10,000 by default; /exp: 1,000,000; at most 1,000,000"truncated": true
SQL echoed back in querythe first 4,096 bytesthe rest is cut
HTTP threadsone poller thread, and a pool of workers: one per CPU thread, at least 8 (kdb serve --http-threads N)more requests wait for a free worker
Open sessions (POST /session)1,000; idle ones close after 10 minutes503
Open cursors (POST /cursor)16 per database; idle ones close after 10 minuteserror

Compressed answers

A client that sends Accept-Encoding: gzip gets answers of 2 KB or more compressed: JSON from /exec, CSV from /exp, and the console's files. The answer then has Content-Encoding: gzip. Browsers, Go's net/http, Python requests, Node's fetch and curl --compressed send the header and unpack the answer by themselves, so nothing changes in your code - only fewer bytes travel (a 500-bar chart: 31 KB becomes about 7 KB).

kdb serve --http-gzipWho gets compressed answers
remote (the default)clients beyond this computer and its private networks (the internet, another site). A client on the same computer (127.x), on the same LAN (10.x, 172.16-31.x, 192.168.x) or in another container on this computer (Docker's bridge, 172.17.x) gets plain answers: there the bytes arrive in less time than compressing them takes (a 10,000-row answer: 5-7 ms to compress, under a millisecond to send).
onevery client that asks
offnobody

API answers have Cache-Control: no-store, so no browser or proxy keeps a copy. The server prints one line per request, for example 2026-09-11 23:04:12 GET /health 200 0 ms.

More limits: Limits.

Clients

Any HTTP client works. The same query from five languages (all send the SQL as the body of POST /exec):

curl -s "http://127.0.0.1:9900/exec?compat=0" --data-binary "SELECT symbol, close FROM ochl_day LIMIT 2"
$r = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:9900/exec?compat=0" `
  -ContentType "text/plain; charset=utf-8" -Body "SELECT symbol, close FROM ochl_day LIMIT 2"
$r.results[0].dataset | ForEach-Object { $_ -join " | " }
import requests

r = requests.post("http://127.0.0.1:9900/exec", params={"compat": 0},
                  data="SELECT symbol, close FROM ochl_day LIMIT 2".encode("utf-8"),
                  headers={"Content-Type": "text/plain; charset=utf-8"})
body = r.json()
if r.status_code != 200:
    raise RuntimeError(body["error"])
print(body["results"][0]["dataset"])
const res = await fetch("http://127.0.0.1:9900/exec?compat=0", {
  method: "POST",
  headers: { "Content-Type": "text/plain; charset=utf-8" },
  body: "SELECT symbol, close FROM ochl_day LIMIT 2",
});
const body = await res.json();
if (!res.ok) throw new Error(body.error);
console.log(body.results[0].dataset);
using var http = new HttpClient();
var content = new StringContent("SELECT symbol, close FROM ochl_day LIMIT 2", Encoding.UTF8, "text/plain");
var res = await http.PostAsync("http://127.0.0.1:9900/exec?compat=0", content);
var json = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!res.IsSuccessStatusCode) throw new Exception(json.GetProperty("error").GetString());
Console.WriteLine(json.GetProperty("results")[0].GetProperty("dataset"));

On Windows PowerShell, write curl.exe, not curl (curl alone is another command there). These snippets are not run by the help build; chapter 8 has tested clients with their real output.

GET /

Syntax:

GET /

The web console: a page for running SQL in the browser. Its files (/index.html, /app.js, /logic.js, /style.css, ...) are the web folder next to the program, not part of kdb.exe, so it needs no internet and a new console needs no new program (where kdb finds it).

AnswerMeaning
Content-Type: text/html; charset=utf-8an HTML page
Content-Security-Policy, X-Frame-Options: DENY, Referrer-Policy: no-referrerthe page loads nothing from other sites and cannot be put inside another site's page

HTTP: Is the console there? (HEAD: the headers only)

HEAD /

Response:

HTTP 200 (text/html)

GET /help, GET /help/

Syntax:

GET /help
GET /help/

This help, served by kdb serve. Open http://127.0.0.1:9900/help/ in a browser. In the web console, the Help menu and F1 open it too (web console).

The help is not inside the kdb program. It is a folder of pages: index.html with the search, one page per topic, and help.css, help.js, help-index.js, search-text.js. kdb serve sends the files of that folder. It looks for the folder here, and the first one found wins:

WhereExample
help_dir in kdb.conf"help_dir": "D:/kdb/help"
the KDB_HELP_DIR environment variableKDB_HELP_DIR=/usr/local/share/kdb/help
a help folder next to the programC:\Programs\kdb\help\
../share/kdb/help from the program folder/usr/local/bin/kdb uses /usr/local/share/kdb/help
in a KDB source folder, for a kdb run from build/bindocs/help/site/
AnswerMeaning
GET /help302 to /help/, so the links between pages work
GET /help/, GET /help/<file>the home page, or one file of the folder
Content-Security-Policy, X-Frame-Options: SAMEORIGINthe pages load nothing from other sites; only KDB's own console may show them inside a frame
curl -s http://127.0.0.1:9900/help/ -o kdb-help.html

HTTP: The help home page

HEAD /help/

Response:

HTTP 200 (text/html)

GET /skill.md

Syntax:

GET /skill.md

The Claude skill: one markdown file that tells an AI assistant in another project how to use KDB. It is skill/skill.md next to the program (.claude/skills/kdb/SKILL.md in the source), the same file kdb skill prints.

AnswerMeaning
Content-Type: text/markdown; charset=utf-8the skill as plain markdown
mkdir -p ~/.claude/skills/kdb
curl -s http://127.0.0.1:9900/skill.md -o ~/.claude/skills/kdb/SKILL.md

HTTP: Is the skill there? (HEAD: the headers only)

HEAD /skill.md

Response:

HTTP 200 (text/markdown)