The askDBLM API lets your own software use your askDBLM instance. It can run the fassads your team has saved, ask questions in plain English, and return the answers as data, chart suggestions or finished reports.
This API answers questions; it does not change your instance. To set up and maintain an instance from code (connections, indexing, knowledge, fassads, settings), use the configuration API with a personal access token. Neither API can write to your databases: every query either one runs is read-only.
Base URL: https://<your-instance>.askdblm.com/api/v1
A self-hosted instance serves the same API at its own address, followed by
/api/v1. The examples in this guide use https://acme.askdblm.com/api/v1
and a sample music-store database.
A machine-readable description of the API is in
public-api.yaml (OpenAPI 3). Import it into Postman,
Insomnia or a client generator.
1. Quickstart
1. Create a key. In your instance's control panel, open API keys and choose Create key. Only owners of the instance see this page. Name the key after what will use it, choose what it may do, and copy it. The key is shown only once.
2. Keep it out of your code. Put the address and the key in environment variables, or in your secret manager:
export ASKDBLM_URL=https://acme.askdblm.com/api/v1
export ASKDBLM_KEY=dblm_3f9c2a7b1e04_… # the key you copied
3. Check the key:
curl -H "Authorization: Bearer $ASKDBLM_KEY" "$ASKDBLM_URL/me"
The answer says which fassads the key may run and which modules it may ask.
4. Run a fassad, or ask a question:
curl -H "Authorization: Bearer $ASKDBLM_KEY" -H "Content-Type: application/json" \
"$ASKDBLM_URL/fassads/top_genres/run" -d '{"args": {"n": 3}}'
curl -H "Authorization: Bearer $ASKDBLM_KEY" -H "Content-Type: application/json" \
"$ASKDBLM_URL/query" -d '{"question": "Which 3 countries have the most customers?", "module": "chinook"}'
Both return the same kind of answer: the rows, the query that produced them and a suggested chart. A question also gets a written summary. See Answers.
2. Keys and authentication
Send the key with every request:
Authorization: Bearer dblm_3f9c2a7b1e04_…
X-API-Key: dblm_3f9c2a7b1e04_… works too, for clients that cannot set an
Authorization header.
What a key looks like
A key has the form dblm_<id>_<secret>. The id, for example 3f9c2a7b1e04,
is not secret: the control panel lists the key as dblm_3f9c2a7b1e04_…, and
/me returns the id. The whole string is the secret. askDBLM stores only a
hash of it, so nobody can show a key again after it is created. If a key is
lost, create a new one and revoke the old one.
What a key may do
An owner sets a key's scope when creating it, and can change it later without changing the key:
| Setting | Choices |
|---|---|
| Fassads it may run | None, the ones you pick, or all (including fassads created later) |
| It may ask questions of its own | Whether the key may use POST /query |
| Modules | Which modules its questions may go to: the ones you pick, or any module (including ones added later) |
| Expires | Never, or after 30 days, 90 days or 1 year |
The key can do nothing outside its scope. A fassad outside the scope is
reported exactly like one that does not exist (404), so a key cannot find out
what else your instance holds.
A change of scope applies from the key's next request. A revoked key is refused
from its next request (401 invalid_key). An expired key is refused with
401 key_expired.
Keys belong to the instance, not to the person who created them. A key keeps working when that person leaves your organization, so revoke keys that are no longer in use.
3. Endpoints
| Method | Path | Does |
|---|---|---|
GET |
/me |
Describes the calling key and what it may do |
GET |
/fassads |
Lists the fassads the key may run, with their parameters |
GET |
/fassads/{name} |
Shows one of them |
POST |
/fassads/{name}/run |
Runs a fassad |
POST |
/query |
Asks a question |
GET |
/jobs/{id} |
Collects an answer that took too long to return directly |
Conventions
- Request bodies are JSON, UTF-8 encoded, at most 1 MB. Send
Content-Type: application/json. - A body that contains a field the endpoint does not define is refused with
400 invalid_request. A misspelt option therefore fails loudly instead of being silently ignored. - Responses are JSON unless you ask for a report. Answers and
errors carry
Cache-Control: no-store. - Errors share one shape:
{"error": {"code": "…", "message": "…"}}. See Errors. - URL-encode names in paths: a fassad called
sales by regionis/fassads/sales%20by%20region/run.
GET /me
Describes the calling key. Use it to check that a key works, and to see what it can reach.
curl -H "Authorization: Bearer $ASKDBLM_KEY" "$ASKDBLM_URL/me"
{
"key": {
"id": "3f9c2a7b1e04",
"name": "reporting-job",
"prefix": "dblm_3f9c2a7b1e04",
"created_at": "2026-09-30T07:42:48Z",
"expires_at": "2026-12-29T07:42:48Z"
},
"can": {
"fassads": ["top_genres"],
"query": true,
"targets": ["module:chinook"]
},
"limits": {
"requests_per_minute": 60,
"burst": 30,
"running_per_key": 8
}
}
| Field | Meaning |
|---|---|
key.expires_at |
When the key stops working. Absent if it never expires. |
can.fassads |
Fassads that exist now and that this key may run. A key allowed "all" lists every current fassad. |
can.query |
Whether the key may use POST /query. |
can.targets |
The modules the key's questions may go to, as module:<name>. A key allowed any module lists every current module. |
limits |
This key's rate limits. |
GET /fassads
Lists the fassads this key may run, with each one's parameters. The list says how to call a fassad. It never shows the fassad's query or which connection it runs against.
{
"items": [
{
"name": "top_genres",
"mode": "sql",
"params": [
{ "name": "n", "kind": "int", "required": true }
],
"status": "ok"
}
],
"total": 1
}
| Field | Meaning |
|---|---|
mode |
sql: a saved query with parameters. It runs directly, with no AI model involved, and returns the same columns every time. nlq: a saved question template. Its parameters fill in the question, which is then answered like POST /query. |
params[].kind |
string, int, float or bool. A saved query checks each argument against its kind before it runs. |
params[].required |
Whether you must pass it. |
params[].default |
The value used when you leave the parameter out. Present only when the parameter has one. |
status |
Whether the fassad still fits the schema it runs against. ok: checked against the current schema. stale: something it reads has changed, so it may still run but return something different. broken: it fails as written. unchecked: not checked yet. A fassad is checked when it is saved, when its database is re-indexed, and when it runs. |
status_reason |
Why, when the status is not ok: for example Column orders.total no longer exists. |
GET /fassads/{name}
Returns one fassad in the same shape as an item of the list. It answers
404 not_found when the fassad does not exist or is outside the key's scope.
POST /fassads/{name}/run
Runs a fassad.
curl -H "Authorization: Bearer $ASKDBLM_KEY" -H "Content-Type: application/json" \
"$ASKDBLM_URL/fassads/top_genres/run" -d '{"args": {"n": 3}}'
{
"fassad": "top_genres",
"mode": "sql",
"tables": [
{
"source": "chinook",
"query": "SELECT g.Name AS genre, COUNT(*) AS tracks FROM Track t JOIN Genre g ON g.GenreId = t.GenreId GROUP BY g.Name ORDER BY tracks DESC LIMIT ?",
"columns": ["genre", "tracks"],
"rows": [["Rock", 1297], ["Latin", 579], ["Metal", 374]],
"row_count": 3,
"chart": { "type": "bar", "x_column": "genre", "y_column": "tracks", "title": "tracks by genre" },
"visual": { "type": "bar", "title": "tracks by genre", "x": { "column": "genre" }, "y": [{ "column": "tracks" }] }
}
],
"duration_ms": 1
}
| Body field | Type | Default | Meaning |
|---|---|---|---|
args |
object | {} |
Parameter values by name. Strings, numbers and booleans are all accepted, so {"n": 3} and {"n": "3"} are the same. null, or leaving a parameter out, uses its default. Arguments the fassad does not declare are ignored. |
chart |
boolean | true |
Suggest a chart for each table. |
format |
string | "json" |
json, html, pdf, png or csv. See Reports. |
session_id |
string | none | Continues a conversation. Used only by nlq fassads. See Conversations. |
async |
boolean | false |
Return 202 at once and collect the answer from /jobs/{id}. |
A fassad always runs against the connection it was saved with. A run cannot be pointed at a different connection or module.
In a saved query (sql), each argument is bound as a value, never pasted into
the query text, so an argument cannot change what the query does. In a question
template (nlq), arguments become part of the question text.
POST /query
Asks a module a question in plain English. A module is a group of tables, possibly from several databases, that an editor has set up for questions. Questions are always asked of a module, never of a connection or database directly. The key needs permission to ask questions, and the module has to be inside its scope.
curl -H "Authorization: Bearer $ASKDBLM_KEY" -H "Content-Type: application/json" \
"$ASKDBLM_URL/query" \
-d '{"question": "Which 3 countries have the most customers?", "module": "chinook", "session_id": "support-chat-42"}'
{
"question": "Which 3 countries have the most customers?",
"summary": "The top 3 countries with the most customers are:\n\n* **USA:** 13 customers (leads by a wide margin)\n* **Canada:** 8 customers\n* **France:** 5 customers",
"tables": [
{
"source": "chinook",
"query": "SELECT Country, COUNT(*) AS CustomerCount FROM Customer GROUP BY Country ORDER BY CustomerCount DESC LIMIT 3",
"columns": ["Country", "CustomerCount"],
"rows": [["USA", 13], ["Canada", 8], ["France", 5]],
"row_count": 3,
"summary": "The top 3 countries with the most customers are: …",
"chart": { "type": "bar", "x_column": "Country", "y_column": "CustomerCount", "title": "Top 3 Countries by Customers" },
"visual": { "type": "bar", "title": "Top 3 Countries by Customers", "x": { "column": "Country" }, "y": [{ "column": "CustomerCount" }] }
}
],
"session_id": "support-chat-42",
"duration_ms": 16616
}
| Body field | Type | Meaning |
|---|---|---|
question |
string | Required. Up to 4,000 characters. |
module |
string | The module to ask. Required unless the key allows exactly one module. |
session_id, chart, format, async |
As for running a fassad. |
Where the question goes. Name the module with module. You may leave it
out when the key allows exactly one module; that module is used. A module
outside the key's scope is refused with 403 not_allowed.
Answering a question takes a few seconds to a minute or more, depending on the question. See Long-running requests.
GET /jobs/{id}
Collects an answer that was not ready when its request returned. The request
answered 202 with this address in its Location header.
- While the work is still running, this endpoint answers
202and sendsRetry-After: 2. - Once the work is done, it returns exactly what the original request would have returned: the same status, headers and body. That is the answer, a report, or an error.
You can collect a finished answer more than once for an hour. Only the key
that started the job can see it. 404 job_not_found means the id is unknown,
belongs to another key, finished more than an hour ago, or was lost because
the instance restarted. In any of those cases, send the request again.
4. Answers
A question and a fassad run both return an answer:
| Field | Type | Meaning |
|---|---|---|
fassad |
string | Set for a fassad run: the fassad's name. |
mode |
string | Set for a fassad run: sql or nlq. |
question |
string | The question that was answered. For an nlq fassad, this is the question its arguments produced. |
summary |
string | A written answer in Markdown. Absent when there is nothing to summarise, and for saved-query fassads, which do not use the model. |
tables |
array | The data. Never null. |
session_id |
string | The conversation id you sent, if any. |
duration_ms |
integer | How long the work took on the instance. |
Each table:
| Field | Type | Meaning |
|---|---|---|
source |
string | Where the rows came from: a connection, connection/database, or a module. |
query |
string | Exactly what ran: SQL, a MongoDB pipeline or an API call, depending on the database. For a table merged from several databases, the query that joined their results. |
columns |
string array | Column names, in order. |
rows |
array of arrays | One array per row, in columns order. |
row_count |
integer | How many rows there are. |
summary |
string | A written summary of this table alone, when the answer has one. |
chart |
object | A suggested chart in version 1's terms: one measure over x. See below. Absent when the rows are not chartable. |
visual |
object | The whole of the suggested chart: every measure, a split, and the types chart has no word for. See below. Absent when the rows are not chartable. |
error |
string | Set when this source failed. The other tables still answer. |
steps |
array | Set when a module question read several databases that one query cannot reach. Each step is {"source", "query"}: what ran on that database. query is then how dblm joined their results. |
Working with the data
- Value types. Values keep their JSON types: numbers are numbers, text is
text, and
NULLisnull. Dates and timestamps arrive as strings. Decimal columns can carry floating-point noise (105.92999999999999), so round them for display. - Several tables. A question can be answered from several databases at
once, and then each source gets its own table. Check each table's
error: a failed source does not fail the whole answer. - Summary versus data. An AI model writes
summaryfrom the rows. Use it for display. Readtablesfor anything you calculate, store or act on. - No row cap. Results are not truncated, so a large result makes a large response. Ask for what you need: say "top 10" in a question, or give fassads a limit parameter.
Charts
When the rows can be drawn, a table suggests a chart twice: visual is the
whole of it, and chart the same chart as version 1 of the API described it,
one measure over x. Tokens per hour for each person, as one line a person:
"visual": {
"type": "line",
"title": "Tokens by hour",
"x": { "column": "hour" },
"y": [{ "column": "tokens" }],
"split": { "column": "email" }
}
| Field | Meaning |
|---|---|
type |
line, area, bar, pie, scatter, heatmap or number. |
title |
A title for the chart. |
x |
{"column", "grain"}: the x-axis, a pie's slices, or a heatmap's columns. Absent for a number. Whether it holds times, numbers or categories is read from its values. grain groups a time axis into steps: minute, hour, day, week (from Monday), month, quarter or year, for lines, areas, bars and heatmaps. |
y |
The measures, one to six, each {"column", "label", "format", "as", "axis"}. "as": "line" draws a measure of a bar chart as a line; "axis": "right" puts a measure on a second axis. |
split |
{"column", "limit"}: one series for each value of a column (each person, each model), the limit largest by total named, six at most, and the rest summed as Other; values that do not add up, such as averages, leave the rest out. With a split, y has one measure. For a heatmap, its rows. |
stack |
normal or percent: bars or areas stacked, or stacked to 100%. |
horizontal |
true: bars across rather than up, for long labels. |
sort, limit |
For bars and pies over categories: value or label order rather than the rows' own, and the top limit with one Other for the rest. |
format |
How values are written, unless a measure has its own: number, compact, percent (a fraction: 0.25 is 25%), currency:XXX (an ISO 4217 code, as in currency:USD), duration:s, duration:ms or bytes. |
compare |
For a number: what its latest value is compared with, previous (the row before the last, by x) or a column, combined as the number is when it has an aggregate. |
aggregate |
With a grain: how the rows in one step are combined, sum (the default), avg, min or max. For a number, every row combined: their sum, average, lowest or highest; such a number has no x. |
Rows that repeat x beside a column of a few values, such as an hour for each
of several people, come with a split by that column, so they draw as one
series each rather than one line zigzagging between them. New types and
fields may be added to visual: draw what you recognise, and fall back to
chart for the rest.
chart keeps the fields it always had, and is absent when the chart has no
one-measure form (a heatmap or a number):
| Field | Meaning |
|---|---|
type |
bar, line, pie or scatter. |
x_column |
The column for categories, or for the x-axis. |
y_column |
The numeric column to plot: the first measure. |
label_column |
For pie: the column that names each slice. When absent, use x_column. |
title |
A title for the chart. |
A table can come back without a chart. There is none when the table has fewer
than two rows or no numeric column, or when nothing in it is worth drawing.
Send "chart": false to skip the suggestion.
For a question, the model names the chart in the same reply as the query it
writes, so a chart costs no extra call, and the answer's summary is written
knowing what the chart shows. askDBLM checks the chart against the rows that
came back (a column the query did not return, a measure that is not a number)
and reads one off the rows alone when it does not fit. Saved-query fassads
choose charts from the rows alone, without the model.
Draw every row. When x holds dates or times, sort the rows by it and plot it
on a time axis: the rows come in the query's order, which need not be time
order. A query that sorts on a time it formatted itself, such as
2026-10-08 12:30 AM, sorts it as text. With a grain, first combine the
rows that fall in one step, in the clock the times are written in, and for
each value of the split apart: their values are then the step's sum,
average, lowest or highest, not the rows' own, so say so beside the chart.
The suggestion names columns rather than carrying the data again. Here is one way to draw it with Chart.js, in a web page that gets the answer from your own server:
// chart.js: turn one answer table into a Chart.js configuration.
export function chartConfig(table) {
const { chart, columns, rows } = table;
if (!chart) return null;
const col = (name) => rows.map((row) => row[columns.indexOf(name)]);
const title = { display: Boolean(chart.title), text: chart.title };
if (chart.type === "pie") {
return {
type: "pie",
data: {
labels: col(chart.label_column || chart.x_column),
datasets: [{ data: col(chart.y_column) }],
},
options: { plugins: { title } },
};
}
if (chart.type === "scatter") {
const xs = col(chart.x_column);
return {
type: "scatter",
data: { datasets: [{ label: chart.y_column, data: col(chart.y_column).map((y, i) => ({ x: xs[i], y })) }] },
options: { plugins: { title } },
};
}
return {
type: chart.type, // "bar" or "line"
data: { labels: col(chart.x_column), datasets: [{ label: chart.y_column, data: col(chart.y_column) }] },
options: { plugins: { title } },
};
}
// In the page: new Chart(canvas, chartConfig(answer.tables[0]));
5. Reports
Add "format" to a question or a fassad run to get a finished report instead
of JSON:
format |
Content-Type | What you get |
|---|---|---|
html |
text/html; charset=utf-8 |
A page the model designs from the answer's tables and summary to fit the question, with its charts drawn in it. |
pdf |
application/pdf |
That same model-designed page, printed to PDF. |
png |
image/png |
That same model-designed page, captured as one tall image 1,200 pixels wide. |
csv |
text/csv; charset=utf-8 |
The rows alone. Each table has a header row and a leading source column, and tables are separated by a blank line. The file starts with a UTF-8 byte-order mark so spreadsheet apps read it as UTF-8. |
curl -H "Authorization: Bearer $ASKDBLM_KEY" -H "Content-Type: application/json" \
"$ASKDBLM_URL/query" -o revenue.pdf \
-d '{"question": "Monthly revenue in 2024", "format": "pdf"}'
- Model-designed.
html,pdfandpngare composed by the model from the answer's tables and summary, so each report is laid out to fit its question rather than filled into a fixed template. That means a report costs a model call on top of the question, and needs an LLM configured on the instance. A question whose answer fails still returns the JSON error, not a report. - Filename. Reports carry a
Content-Dispositionheader with a suggested filename (dblm-report-20260930-074326.pdf). HTML, PDF and PNG are markedinlineand CSV is markedattachment. - askDBLM's charts, from every row. The model decides where charts go and
askDBLM draws them, the same charts as in an answer's
visual: each table's own chart, or another view the model describes. Its headline numbers are computed by askDBLM from every row too. The model designs the page from the summary and the first 200 rows of each table, so figures it writes into the text come from those. - No script. A report runs no code but askDBLM's own chart script, and nothing in it reaches another site but its fonts. The HTML report is the page drawn and saved with its charts as SVG and no script at all, so it opens the same anywhere; they are drawn at a desktop's width and scale with the page.
- CSV. The one format that needs no model call: it is the answer's rows, rendered directly.
- Drawn in a browser. A headless Chromium inside the instance image draws
the charts, prints the PDF and captures the PNG. If the report cannot be
designed or printed, the request fails with
report_failedand you get the JSON error, not the file. An instance without a browser still makes HTML reports: the page then carries its rows and the chart script, and draws its charts when opened. - PNG is one image. Unlike a paginated PDF, the PNG is a single capture of the whole page, so a long report makes a very tall image.
6. Long-running requests
A question may take up to two minutes. Many networks, proxies and HTTP clients give up well before that, so the API never holds a request open for longer than about 85 seconds:
-
The API starts the work and waits up to 85 seconds for it to finish.
-
If the work finishes in time, you get the answer as the response.
-
If it doesn't, you get
202 Accepted, with aLocationheader pointing at the job andRetry-After: 2:json { "job": { "id": "run_5591974838b4c3620c50a497", "status": "running", "created_at": "2026-09-30T08:09:55Z", "url": "/api/v1/jobs/run_5591974838b4c3620c50a497" } } -
Call
GETon theLocationeveryRetry-Afterseconds until it stops answering202. It then returns what the original request would have returned.
The Location is a path on the same host. Resolve it against the URL you
called.
Ask for a job up front with "async": true. You get the 202 at once,
which suits batch jobs and workers that should not hold a connection open.
Set your HTTP client's timeout to at least 100 seconds for requests that wait. The API answers within about 85 seconds, and the extra margin covers sending a large answer.
Never send a question again because it is slow. Sending it again starts a
second run, and it counts toward your allowance a second time. Follow the
202 and poll the job instead.
A key may have 8 runs in progress at once. Beyond that, a new request is
refused with 429 too_many_runs until one finishes.
7. Conversations
Pass a session_id to ask follow-up questions that build on earlier ones:
{"question": "Which 3 countries have the most customers?", "session_id": "support-chat-42"}
{"question": "For the top one, show total sales by year", "session_id": "support-chat-42"}
The second question understands "the top one" because both questions share a session.
- You choose the id. It is 1 to 64 characters: letters, digits,
.,_,-and:. Use one id per conversation, for example one per end-user chat, and a new id when the subject changes. - Ask one question at a time in a session. Wait for each answer before sending the next, so the follow-up sees it.
- Sessions are private to a key. Another key that uses the same id gets a separate conversation, so no key can see or continue another key's. People who use your instance's control panel can see API conversations in its history.
- Without
session_id, each question stands alone. Leave it out for one-off questions and scheduled reports. Context can help a follow-up, but it can also steer an unrelated question. - Which calls use it.
POST /queryandnlqfassads usesession_id. Saved-query fassads ignore it.
8. Errors
Every error has the same shape:
{ "error": { "code": "not_allowed", "message": "not allowed for this API key: this key may not ask module \"payroll\"" } }
Build your error handling on code. The message explains the failure for a
person and may change wording.
| Status | code |
Meaning | What to do |
|---|---|---|---|
| 400 | invalid_request |
The body is not valid JSON, has a field the endpoint does not define, or has a bad format, session_id or argument type. Also returned when the body is over 1 MB. |
Fix the request. |
| 400 | invalid_query |
The question is missing or too long. | Fix the request. |
| 400 | module_required |
No module was named when the key allows several. | Name a module from can.targets in /me. |
| 400 | invalid_fassad |
A required argument is missing, or an argument does not fit its kind. | Fix the arguments. See GET /fassads/{name}. |
| 400 | invalid_connection_name, invalid_database_name |
A name in the request is malformed. | Fix the name. |
| 401 | unauthorized |
No key was sent. | Send Authorization: Bearer <key>. |
| 401 | invalid_key |
The key is unknown, mistyped or revoked. | Check the key. If it was revoked, ask an owner for a new one. |
| 401 | key_expired |
The key has passed its expiry date. | Ask an owner for a new key, or to extend this one. |
| 403 | not_allowed |
The request is outside the key's scope: the key may not ask questions, or may not ask that module. | Use a module from /me, or ask an owner to widen the scope. |
| 403 | not_read_only |
The saved fassad would change data, so it will not run. | Ask an owner to fix the fassad. |
| 404 | not_found |
No such fassad for this key, or no such module or endpoint. | Check the name against /me and /fassads. |
| 404 | job_not_found |
The job is unknown, expired, belongs to another key, or was lost in a restart. | Send the original request again. |
| 405 | method_not_allowed |
Wrong HTTP method for this path. | See Endpoints. |
| 429 | rate_limited |
More than the key's request rate. | Wait Retry-After seconds, then retry. |
| 429 | too_many_runs |
The key already has 8 runs in progress. | Wait for one to finish, or Retry-After, then retry. |
| 429 | quota_exhausted |
Your instance has used its monthly question allowance. | Do not retry. It resets next month, or an owner can change plan. |
| 502 | query_failed |
The database or the model could not answer this question, for example because the database was unreachable or the question could not be turned into a working query. The message says why. | Rephrase the question, or check the connection. Do not retry in a loop. |
| 503 | llm_unavailable |
The instance's AI model is not available right now. | Retry later. If it persists, contact your administrator. |
| 503 | busy, unavailable |
The instance cannot take the request right now. | Wait Retry-After if it is present, then retry. |
| 504 | timeout |
The question took longer than two minutes. | Ask something narrower. |
| 500 | internal_error, report_failed |
Something went wrong on the instance. | Try again later. If it persists, contact your administrator. |
Which requests are safe to retry. rate_limited, too_many_runs, busy
and unavailable are refused before any work starts, so sending the same
request again is safe. For any other failure of a question, sending it again
runs it again. Only retry a question once you have changed something.
Edge errors. A response that is not JSON, such as an HTML error page, did not come from your instance. It came from the network in front of it. Check the URL, and see Troubleshooting.
9. Limits
| Limit | Value |
|---|---|
| Requests per key | 60 a minute, in bursts of up to 30. Polling /jobs/{id} does not count. |
| Runs in progress per key | 8 |
| Question length | 4,000 characters |
| Request body | 1 MB |
| Time to answer a question | 2 minutes |
Longest a request waits before returning 202 |
85 seconds |
| How long a finished job is kept | 1 hour |
| Questions per month (hosted instances) | Your plan's allowance, shared with questions asked in the control panel. The Free plan includes 100 a month. |
What counts as a question. Each POST /query counts once, and so does
each run of an nlq fassad. The chart, summary and follow-up work for that
question are included in that one count, and asking for a report doesn't
add to it. Saved-query (sql) fassads do not use the model, so they do not
count. /me, /fassads and polling jobs don't count either.
When a limit is hit, the response is 429 with a Retry-After header that
says how many seconds to wait. The one exception is the monthly allowance,
which does not reset until next month.
10. Using the API well
Keep keys on your servers. Store keys in environment variables or a secret manager, never in source control. Never put a key in a web page, a mobile app or anything else you ship to users, because anyone who can read a key can use it. The API sends no CORS headers, so browsers will not let a page on another site call it anyway. If a web page needs data, have your server call the API and pass the result on.
Give each integration its own key, with the smallest scope that works. A nightly report needs one fassad, not every fassad and every module. Give keys an expiry where you can. To rotate a key, create the new one, deploy it, then revoke the old one. Revoke a key at once if it may have leaked.
Treat the key's scope as your data boundary. The API has no idea who your
end users are. If your product lets its users type questions, they can reach
anything the key can reach, however the question is worded. Asking in the
question to "only show customer 42" filters nothing. For data that differs per
user, use a saved-query (sql) fassad and pass the user's identifier as an
argument: saved-query arguments are bound as values and cannot change what
the query does. For open-ended questions, scope the key to data that every
one of those users may see.
Your server identifies the user; askDBLM never does. askDBLM does not see your end users and does not sign them in: your integration server does. When you build a fassad to answer for a specific user, the server holding the key authenticates and authorizes that user itself, then passes their id or details as the fassad argument. Take that id from the identity your server established, never from a value the browser or app sent.
Use fassads for anything you repeat. Dashboards, scheduled reports and
product features should call fassads. A saved query returns the same columns
every time, answers as fast as your database does, and does not use your
question allowance. Use POST /query for questions nobody has saved yet.
Handle slow answers properly. Set your HTTP timeout to at least 100
seconds, or send "async": true. On 202, poll the Location. Never send a
question again just because it is slow.
Retry only what is safe, and respect Retry-After. See
which requests are safe to retry. Don't retry 4xx errors
without changing the request.
Use the data, not the prose. summary is written by an AI model, so show
it to people but don't parse it. Read values from tables. Check each
table's error, and treat chart as optional.
Check what ran when it matters. tables[].query is exactly what ran
against your database. For decisions that matter, keep it with the result, so
you can check an answer that looks wrong.
Ask for small results. Nothing is truncated for you. Put a limit in your question or your fassad, and page through large data with fassad parameters.
Send only documented fields, and accept new ones. The API refuses fields it does not define in requests. It may add fields to responses, so ignore fields you don't recognise.
Name your integration in User-Agent, for example
acme-reporting/1.0. Some HTTP libraries send no User-Agent or a generic
one, and the network in front of your instance may refuse such requests
before they reach it.
Mind what you cache. Answers are sent with Cache-Control: no-store. If
your application caches them, treat the cache as a copy of your data.
Owners can see every call. Each run is recorded in your instance's audit log under the key's name: the query that ran, the row count, how long it took, and the address it came from. The API keys page shows when each key was last used, and from where.
11. Client examples
These clients handle the parts that are easy to get wrong: waiting on 202
jobs, retrying only what is safe, and telling JSON answers from reports. Each
reads ASKDBLM_URL and ASKDBLM_KEY from the environment.
Python
Uses only the standard library (Python 3.8 or later).
# askdblm.py: a small askDBLM API client, standard library only.
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
# Refused before any work started, so sending the same request again is safe.
RETRYABLE = {"rate_limited", "too_many_runs", "busy", "unavailable"}
class AskDBLMError(Exception):
def __init__(self, status, code, message):
super().__init__(f"{status} {code}: {message}")
self.status, self.code, self.message = status, code, message
class AskDBLM:
def __init__(self, base_url=None, key=None, user_agent="my-integration/1.0"):
self.base_url = (base_url or os.environ["ASKDBLM_URL"]).rstrip("/")
self.key = key or os.environ["ASKDBLM_KEY"]
self.user_agent = user_agent
def me(self):
return self.request("GET", "/me")
def fassads(self):
return self.request("GET", "/fassads")["items"]
def run(self, name, args=None, async_=False, **options):
"""Run a fassad. Options: chart, format, session_id."""
path = "/fassads/" + urllib.parse.quote(name, safe="") + "/run"
return self.request("POST", path, {"args": args or {}, **options, "async": async_})
def ask(self, question, async_=False, **options):
"""Ask a module a question. Options: module, session_id, chart, format."""
return self.request("POST", "/query", {"question": question, **options, "async": async_})
def request(self, method, path, body=None):
"""Returns a dict for JSON answers, bytes for reports (html, pdf, png, csv)."""
url = self.base_url + path
retries = 0
deadline = time.monotonic() + 300 # no answer takes longer than this
while True:
status, headers, raw = self._send(method, url, body)
if status == 202:
# Still running: poll the job. Never send the question again.
url = urllib.parse.urljoin(url, headers["Location"])
method, body = "GET", None
elif status >= 400:
error = _error(raw)
if error.get("code") in RETRYABLE and retries < 5:
retries += 1
else:
message = error.get("message") or raw[:200].decode("utf-8", "replace")
raise AskDBLMError(status, error.get("code", ""), message)
elif headers.get_content_type() == "application/json":
return json.loads(raw)
else:
return raw
if time.monotonic() > deadline:
raise AskDBLMError(0, "gave_up", "no answer after 5 minutes")
time.sleep(int(headers.get("Retry-After") or 2))
def _send(self, method, url, body):
req = urllib.request.Request(
url,
method=method,
data=None if body is None else json.dumps(body).encode(),
headers={
"Authorization": "Bearer " + self.key,
"Content-Type": "application/json",
"User-Agent": self.user_agent,
},
)
try:
with urllib.request.urlopen(req, timeout=100) as res:
return res.status, res.headers, res.read()
except urllib.error.HTTPError as err:
return err.code, err.headers, err.read()
def _error(raw):
try:
return json.loads(raw).get("error", {})
except ValueError: # not JSON: an error page from the network, not the API
return {}
Using it:
# example.py
from askdblm import AskDBLM, AskDBLMError
api = AskDBLM(user_agent="acme-reporting/1.0")
# A saved query: fast, exact, and outside the question allowance.
top = api.run("top_genres", {"n": 3})
for genre, tracks in top["tables"][0]["rows"]:
print(genre, tracks)
# A question, then a follow-up in the same conversation.
answer = api.ask("Which 3 countries have the most customers?", session_id="support-chat-42")
print(answer["summary"])
follow_up = api.ask("For the top one, show total sales by year", session_id="support-chat-42")
# A report, saved as a file.
with open("top-genres.pdf", "wb") as f:
f.write(api.run("top_genres", {"n": 10}, format="pdf"))
# Errors carry the API's code.
try:
api.run("not_in_my_scope")
except AskDBLMError as e:
print(e.status, e.code) # 404 not_found
async is a reserved word in Python, so this client takes async_=True.
You get the same answer either way. With async_=True the client polls the
job instead of holding a connection open, which suits long questions in batch
jobs.
The same calls map directly onto requests:
requests.post(url, json=body, headers=..., timeout=100). Keep the 202 loop.
JavaScript (Node.js 18 or later)
Uses the built-in fetch, with no dependencies. Run it on your server, not in
a browser.
// askdblm.mjs: a small askDBLM API client for Node.js 18+.
const RETRYABLE = new Set(["rate_limited", "too_many_runs", "busy", "unavailable"]);
export class AskDBLMError extends Error {
constructor(status, code, message) {
super(`${status} ${code}: ${message}`);
this.status = status;
this.code = code;
}
}
export class AskDBLM {
constructor({ baseUrl = process.env.ASKDBLM_URL, key = process.env.ASKDBLM_KEY, userAgent = "my-integration/1.0" } = {}) {
this.baseUrl = baseUrl.replace(/\/+$/, "");
this.key = key;
this.userAgent = userAgent;
}
me() {
return this.request("GET", "/me");
}
async fassads() {
return (await this.request("GET", "/fassads")).items;
}
// options: chart, format, session_id, async
run(name, args = {}, options = {}) {
return this.request("POST", `/fassads/${encodeURIComponent(name)}/run`, { args, ...options });
}
// options: module, session_id, chart, format, async
ask(question, options = {}) {
return this.request("POST", "/query", { question, ...options });
}
// Resolves to an object for JSON answers, a Buffer for reports (html, pdf, png, csv).
async request(method, path, body) {
let url = this.baseUrl + path;
let retries = 0;
const deadline = Date.now() + 300_000; // no answer takes longer than this
for (;;) {
const res = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${this.key}`,
"Content-Type": "application/json",
"User-Agent": this.userAgent,
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(100_000),
});
if (res.status === 202) {
// Still running: poll the job. Never send the question again.
await res.body?.cancel();
url = new URL(res.headers.get("Location"), url).href;
method = "GET";
body = undefined;
} else if (!res.ok) {
const text = await res.text();
let error = {};
try {
error = JSON.parse(text).error ?? {};
} catch {
// not JSON: an error page from the network, not the API
}
if (!(RETRYABLE.has(error.code) && retries++ < 5)) {
throw new AskDBLMError(res.status, error.code ?? "", error.message ?? text.slice(0, 200));
}
} else if ((res.headers.get("Content-Type") ?? "").startsWith("application/json")) {
return res.json();
} else {
return Buffer.from(await res.arrayBuffer());
}
if (Date.now() > deadline) throw new AskDBLMError(0, "gave_up", "no answer after 5 minutes");
const wait = Number(res.headers.get("Retry-After") ?? 2);
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
}
}
}
Using it:
// example.mjs
import { writeFile } from "node:fs/promises";
import { AskDBLM, AskDBLMError } from "./askdblm.mjs";
const api = new AskDBLM({ userAgent: "acme-reporting/1.0" });
const top = await api.run("top_genres", { n: 3 });
console.log(top.tables[0].rows);
const answer = await api.ask("Which 3 countries have the most customers?", { session_id: "support-chat-42" });
console.log(answer.summary);
await writeFile("top-genres.csv", await api.run("top_genres", { n: 10 }, { format: "csv" }));
try {
await api.run("not_in_my_scope");
} catch (e) {
if (e instanceof AskDBLMError) console.log(e.status, e.code); // 404 not_found
}
Shell
curl and jq, for scripts and cron jobs. This one asks for a job up front
and polls it:
#!/bin/sh
# ask.sh "question": asks as a job, waits for the answer, prints the summary.
set -eu
: "${ASKDBLM_URL:?}" "${ASKDBLM_KEY:?}"
status=$(curl -sS -o answer.json -w '%{http_code}' \
-H "Authorization: Bearer $ASKDBLM_KEY" -H "Content-Type: application/json" \
"$ASKDBLM_URL/query" -d "$(jq -n --arg q "$1" '{question: $q, async: true}')")
[ "$status" = 202 ] || { cat answer.json >&2; exit 1; }
job=$(jq -r '.job.id' answer.json)
while :; do
sleep 2
status=$(curl -sS -o answer.json -w '%{http_code}' \
-H "Authorization: Bearer $ASKDBLM_KEY" "$ASKDBLM_URL/jobs/$job")
[ "$status" = 202 ] || break
done
[ "$status" = 200 ] || { cat answer.json >&2; exit 1; }
jq -r '.summary // empty' answer.json
jq -n --arg builds the body, so a question containing quotes still produces
valid JSON.
12. Troubleshooting
401 invalid_key with a key that used to work. The key was revoked, or
it was copied incompletely. A key is one unbroken string that starts with
dblm_. Check for a missing end, or a line break picked up in copying.
404 not_found for a fassad I can see in the control panel. The fassad
is outside the key's scope. GET /me lists what the key may run. An owner can
widen the key's scope on the API keys page without issuing a new key.
403 not_allowed when asking a question. The key may not ask questions at
all (can.query is false in /me), or not about that module. Use a module
from can.targets.
400 invalid_request: unknown field …. The body has a field this endpoint
does not define. A common case is connection on a question or a fassad run:
a question is asked of a module, and a fassad always runs against what it was
saved with.
400 module_required. The key may ask more than one module, so the API
cannot guess. Name one with module.
The request hangs, then fails with a network timeout. Your client gave up
before the API answered. Raise the timeout to at least 100 seconds, or use
"async": true, and handle 202.
An HTML page, or a 403/5xx that isn't JSON. The response came from the
network in front of your instance, not from the API. Check the URL
(https://<your-instance>.askdblm.com/api/v1/…) and make sure your client
sends a User-Agent.
502 query_failed. The message says what failed. If the database could
not be reached, ask an owner to check the connection in the control panel. If
the question could not be answered, rephrase it: name the things you mean,
such as "invoices" rather than "sales", and add the period or the grouping
you want.
A follow-up question ignores the earlier one. Both questions must use the
same session_id and the same key, and the first must have finished before
the second was sent.
429 quota_exhausted. Your instance has used this month's question
allowance. Saved-query fassads keep working; they don't use the allowance.
13. Compatibility
- Version 1 lives under
/api/v1. Changes within version 1 only add things: new endpoints, new optional request fields, new response fields and new error codes. Anything that would break a working client goes into a new version under a new path. - Clients should ignore response fields they don't recognise. They should
treat an unknown error
codeby its HTTP status.
Changes
| Date | Change |
|---|---|
| 2026-09-30 | First release: /me, fassads, questions, reports and jobs. |
| 2026-10-08 | Reports chart every row of their tables, not only the rows the model is shown. |
| 2026-10-08 | Tables gain visual, the whole suggested chart: several measures, a split into one series per value, stacks, a second axis, and the area, heatmap and number types. chart is unchanged. |
| 2026-10-08 | A question's chart is named by the model in the same reply as its query, not by a call after it, and its summary is written knowing what the chart shows. |
| 2026-10-08 | visual may group a time axis into steps: x.grain, with aggregate saying how a step's rows are combined. |
| 2026-10-08 | Reports draw askDBLM's own charts from every row, with headline numbers computed from every row; the model places them and writes no chart code. The HTML report is static, with its charts as SVG and no script, and no report reaches another site but its fonts. A number may combine every row (aggregate). PNG reports are PNG images: they were JPEG. |