API reference

askDBLM API

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:

sh
export ASKDBLM_URL=https://acme.askdblm.com/api/v1
export ASKDBLM_KEY=dblm_3f9c2a7b1e04_…        # the key you copied

3. Check the key:

sh
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:

sh
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:

http
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 region is /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.

sh
curl -H "Authorization: Bearer $ASKDBLM_KEY" "$ASKDBLM_URL/me"
json
{
  "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.

json
{
  "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.

sh
curl -H "Authorization: Bearer $ASKDBLM_KEY" -H "Content-Type: application/json" \
     "$ASKDBLM_URL/fassads/top_genres/run" -d '{"args": {"n": 3}}'
json
{
  "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.

sh
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"}'
json
{
  "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 202 and sends Retry-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 NULL is null. 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 summary from the rows. Use it for display. Read tables for 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:

json
"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:

js
// 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.
sh
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, pdf and png are 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-Disposition header with a suggested filename (dblm-report-20260930-074326.pdf). HTML, PDF and PNG are marked inline and CSV is marked attachment.
  • 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_failed and 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:

  1. The API starts the work and waits up to 85 seconds for it to finish.

  2. If the work finishes in time, you get the answer as the response.

  3. If it doesn't, you get 202 Accepted, with a Location header pointing at the job and Retry-After: 2:

    json
    {
      "job": {
        "id": "run_5591974838b4c3620c50a497",
        "status": "running",
        "created_at": "2026-09-30T08:09:55Z",
        "url": "/api/v1/jobs/run_5591974838b4c3620c50a497"
      }
    }
    
  4. Call GET on the Location every Retry-After seconds until it stops answering 202. 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:

json
{"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 /query and nlq fassads use session_id. Saved-query fassads ignore it.

8. Errors

Every error has the same shape:

json
{ "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).

python
# 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:

python
# 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.

js
// 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:

js
// 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:

sh
#!/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 code by 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.