The configuration API lets a script do anything you can do in your instance's control panel. That covers adding and testing connections, indexing databases, writing knowledge, building fassads and modules, pinning widgets, writing email templates and sending broadcasts, changing settings, and managing API keys and people. It is the same API the control panel is built on, opened to your own scripts through personal access tokens.
To let software ask questions and run fassads, use the askDBLM API with an API key instead. It is simpler, and an API key cannot change anything.
Base URL: https://<your-instance>.askdblm.com/api/admin
A self-hosted instance serves the same API at its own address, followed by
/api/admin. The examples in this guide use https://acme.askdblm.com and a
sample music-store database.
1. Quickstart
1. Create a token. In your instance's control panel, open Access tokens and choose Create token. Everybody who can sign in has this page. Name the token after what will use it, pick a role no higher than your own, choose when it expires, and copy it. The token is shown only once.
2. Keep it out of your code:
export ASKDBLM_ADMIN=https://acme.askdblm.com/api/admin
export ASKDBLM_TOKEN=dblm_pat_16ad2d04f287_… # the token you copied
3. Check it:
curl -H "Authorization: Bearer $ASKDBLM_TOKEN" "$ASKDBLM_ADMIN/whoami"
{
"mode": "sso",
"uses_sessions": true,
"authenticated": true,
"user": "[email protected]",
"role": "owner",
"token": { "id": "16ad2d04f287", "name": "setup-script", "prefix": "dblm_pat_16ad2d04f287", "role": "owner", "expires_at": "2026-12-29T14:57:53Z" }
}
role is what the token may do right now. token.role is the most it was
created to do.
4. Change something. This adds a connection:
curl -H "Authorization: Bearer $ASKDBLM_TOKEN" -H "Content-Type: application/json" \
"$ASKDBLM_ADMIN/connections" \
-d '{"name": "warehouse", "driver": "postgres", "host": "db.acme.internal", "username": "readonly", "password": "…"}'
2. Personal access tokens
A personal access token is you, for a script. Send it with every request:
Authorization: Bearer dblm_pat_16ad2d04f287_…
It acts as you. Everything a token does is done in your name. It carries
the role you gave it, and never more than you hold at the time of each
request. If an owner lowers your role, your owner tokens act at your new role
from their next request. If you lose access to the instance, your tokens stop
working with it (401 access_revoked). In a hosted instance that follows your
membership in askDBLM, and takes effect within a few minutes.
It always expires, after 30 days, 90 days or a year. There is no "never". To keep a script running, create its next token before the current one expires, and revoke the old one once the script has switched.
It is shown once. askDBLM keeps a hash of the token, not the token. The
Access tokens page lists your tokens by name and prefix
(dblm_pat_16ad2d04f287_…), with when each was last used and from where.
Revoke a token there and it is refused from its next request. Owners see, and
may revoke, everybody's tokens.
It cannot make tokens. Tokens are only ever created by a signed-in person, on the Access tokens page. A token that leaked cannot mint itself a replacement.
Everything it changes is recorded. Each change a token makes (every
POST, PUT, PATCH and DELETE, including ones that are refused) goes into
the instance's audit log under your name, with the operation token. Owners
see it on the Audit page.
Tokens and API keys are different credentials.
| Personal access token | API key | |
|---|---|---|
| For | Setting up and maintaining the instance | Asking questions and running fassads from your product |
| API | /api/admin (this guide) |
/api/v1 (askDBLM API) |
| Belongs to | A person, and acts as them | The instance |
| May do | Whatever its role allows | Only the fassads and questions its scope names |
| Ends | At expiry, on revocation, or when its owner loses access | At expiry, or on revocation |
| Created by | Anybody, for themselves | Owners |
Neither works on the other's API.
3. Roles
Every request needs a role, the lesser of the token's and yours. Reading most things needs only viewer. Changing things needs the role shown here:
| Resource | Read | Change | Notes |
|---|---|---|---|
| Identity and health | any | n/a | whoami answers even without a token |
| Connections | viewer | owner | Testing and discovering a connection count as changes |
| Databases and indexing | viewer | editor | |
| Jobs | viewer | viewer | Anybody may cancel a job |
| Knowledge | viewer | editor | |
| Fassads | viewer | editor | Running a fassad here needs editor. To let software run fassads, use an API key. |
| Modules | viewer | editor | |
| Questions and history | viewer | viewer | Asking is what the product is for |
| Widgets | viewer | viewer | Each person has their own board |
| Settings | owner | owner | |
| Email settings | owner | owner | The Resend key and who email comes from |
| Email templates | viewer | editor | Talking to the agent, restoring a version and sending a test count as changes; anyone may preview |
| Brand guide | viewer | editor | Working one out from files counts as a change |
| Broadcasts | owner | owner | Recipient lists hold people's addresses and data |
| Do-not-email list | owner | owner | |
| Email database | owner | owner | Forgetting a person. The database itself is read through its connection. |
| WhatsApp database | owner | owner | Forgetting a person. The database itself is read through its connection. |
| WhatsApp settings | owner | owner | The access token, the app secret and whose number messages come from |
| WhatsApp templates | viewer | editor | Checking, importing, submitting, sending a test and talking to the agent count as changes |
| WhatsApp broadcasts | owner | owner | Lists hold people's numbers and data. Sending one by hand is the exception: an editor takes one number at a time. |
| Do-not-message list | owner | owner | |
| Survey settings | owner | owner | The link base and the default closed message |
| Survey forms | viewer | editor | The agent, restoring a version and closing a form count as changes; minting one person's link needs owner; anyone may preview |
| Survey broadcasts | owner | owner | Lists hold people's answers |
| Survey responses | owner | owner | A person's own answers |
| Survey database | owner | owner | Forgetting a person. The database itself is read through its connection. |
| API keys | owner | owner | |
| People | owner | owner | Self-hosted instances only |
| Audit log | owner | n/a | |
| Access tokens | viewer | viewer | Each person manages their own; owners manage all |
A request above the role you have is refused with 403 insufficient_role,
and the message says which role it needed.
4. Conventions
Requests. Send JSON with Content-Type: application/json. Fields the
endpoint does not use are ignored. A body that is not valid JSON is refused
with 400 invalid_request.
Names in paths. URL-encode them. Names of connections, fassads and modules
cannot contain /, and a path containing an encoded slash (%2F) is refused.
Lists come wrapped: {"items": [...], "total": 3}. Some also say how they
are sorted ("sort": "name", "order": "asc").
Errors share one shape. Build your handling on code:
{ "error": { "code": "insufficient_role", "message": "this needs the owner role; you have editor" } }
| Status | code |
Meaning |
|---|---|---|
| 400 | invalid_request |
The body is not valid JSON |
| 400 | invalid_connection_name, invalid_database_name, invalid_connection, invalid_dsn, invalid_knowledge, invalid_query, module_required, invalid_fassad, invalid_module, invalid_widget, invalid_settings, invalid_email_settings, invalid_template, invalid_brand, invalid_whatsapp_settings, invalid_whatsapp_template, invalid_broadcast, invalid_recipients, invalid_form, invalid_survey_settings, invalid_response, invalid_answers, invalid_api_key, invalid_operator, invalid_role, invalid_token_request |
What you sent does not fit; the message says what |
| 400 | request_failed |
The instance could not do it, for example it could not connect; the message says why |
| 401 | unauthorized |
No token was sent |
| 401 | invalid_token |
The token is unknown, mistyped or revoked |
| 401 | token_expired |
The token has passed its expiry |
| 401 | access_revoked |
The token's owner no longer has access |
| 403 | insufficient_role |
The request needs a higher role |
| 403 | not_read_only |
A fassad's query would change data, so it will not run |
| 403 | tokens_cannot_create_tokens |
Tokens are created in the control panel |
| 404 | not_found |
No such thing, or no such endpoint |
| 404 | survey_not_found |
No such survey form, broadcast or response |
| 405 | method_not_allowed |
Wrong HTTP method for this path |
| 409 | jobs_running |
Background work on the same target is still running |
| 409 | connection_managed |
The connection is one askDBLM keeps, to the email database: it cannot be changed or removed |
| 409 | job_finished |
The job being cancelled has already finished |
| 409 | managed_by_portal |
People in a hosted instance are managed in askDBLM |
| 409 | template_exists |
An email template already has that name, or a WhatsApp template that name and language |
| 409 | form_exists |
A survey form already has that name |
| 409 | form_closed |
The survey form is closed, so it takes no new answers |
| 409 | survey_already_answered |
The person has already finished a survey that allows one answer each |
| 409 | survey_form_in_use |
The form has links out or a draft/open broadcast, so it cannot be renamed or deleted |
| 409 | template_in_review |
Meta is reviewing the WhatsApp template, or it is already submitted; change it once Meta decides |
| 409 | template_not_approved |
Meta sends only approved WhatsApp templates |
| 409 | agent_busy |
The agent is still answering another message about this template |
| 422 | draft_failed |
The model could not write a working email template; nothing was changed |
| 422 | brand_failed |
The model could not work out a brand guide from the files |
| 422 | email_send_failed |
Resend refused the email; the message gives Resend's reason |
| 422 | whatsapp_failed |
Meta refused the template or the message, or could not be reached; the message gives Meta's reason |
| 409 | broadcast_not_ready |
The broadcast is not in a state for this, the confirmed recipient count is not the current one, or a WhatsApp send's consent was not confirmed |
| 409 | hand_row_not_held |
The person is not the sender's to settle: not held by them, or the 15 minutes are up |
| 429 | rate_limited |
Over the token's request rate (wait Retry-After seconds), or over the instance's 20 test emails or 20 test WhatsApp messages an hour |
| 429 | quota_exhausted |
The instance's monthly question allowance is used up |
| 429 | hand_limit_reached |
The sender has used their allowance of messages sent by hand in the last 24 hours |
| 502 | query_failed |
The database or the model could not answer |
| 503 | llm_unavailable |
The instance's AI model is not available |
| 503 | portal_unavailable |
askDBLM could not be reached to confirm the token's owner |
| 503 | email_unavailable |
Email is not set up: no Resend key or From address, or the instance's public address is not known |
| 503 | whatsapp_unavailable |
WhatsApp is not set up: no access token, account ID or phone number ID; or the WhatsApp database could not be made (the message says what to run) |
| 503 | survey_unavailable |
The survey database could not be made (the message says what to run) |
| 503 | unavailable, knowledge_store_unavailable |
The instance cannot do this right now |
Rate limit. A token may make 120 requests a minute, in bursts of up to 60.
Beyond that the answer is 429 rate_limited with a Retry-After header.
Secrets are write-only. Connection passwords, header values, the model's
API key, the Resend key and the WhatsApp access token and app secret can be
set, but no endpoint ever returns them. Responses say only whether one is set:
password_configured, header_names, api_key_configured,
resend_api_key_configured, access_token_configured,
app_secret_configured.
Background jobs. Indexing a database and generating knowledge take a while,
so those requests return 202 Accepted with a job. Poll
GET /jobs/{id} until its status is completed, failed or
cancelled:
{
"id": "job_960cd73e5511c368",
"type": "index",
"status": "running",
"connection": "chinook",
"database": "",
"target": "chinook",
"progress": 40,
"phase": "introspecting",
"message": "",
"skipped": false,
"created_at": "2026-09-30T14:58:11Z",
"started_at": "2026-09-30T14:58:11Z"
}
A job has an error when it failed, and finished_at once it is over. Only one
job at a time may work on a target. Starting another on the same target is
refused with 409 jobs_running.
Identity. Each response carries an X-Request-ID header.
5. Reference
Paths below are relative to the base URL, https://<your-instance>.askdblm.com/api/admin.
Identity and health
| Method and path | Role | Does |
|---|---|---|
GET /whoami |
none | Says who the token acts as: user, role (what it may do now), and token (id, name, prefix, role, expires_at). Without a valid token it answers 200 with "authenticated": false. |
GET /health |
viewer | {"status": "ok", "version": "…"} |
GET /version |
viewer | {"version": "…"} |
GET /overview |
viewer | Counts for a dashboard: connections, indexed_databases, knowledge_ready, knowledge_dirty, active_jobs, recent_jobs (up to 10 jobs) |
Connections
A connection is how the instance reaches one of your databases, or a REST API described by an OpenAPI document.
| Method and path | Role | Does |
|---|---|---|
GET /connections |
viewer | Lists connections |
POST /connections |
owner | Adds a connection. 201 with the connection. Saving under an existing name replaces that connection. |
GET /connections/{name} |
viewer | Shows one |
PUT /connections/{name} |
owner | Changes one (see below). Refused with 409 connection_managed for the email database's. |
DELETE /connections/{name} |
owner | Removes it, with its index and knowledge. 204. Refused with 409 jobs_running while it is being indexed, and with 409 connection_managed for the email database's. |
POST /connections/{name}/test |
owner | Connects, and reports how it went |
POST /connections/-/test |
owner | Tests settings sent in the body, without saving anything |
POST /connections/{name}/discover |
owner | Lists the databases on the server: {"items": [{"name", "already_indexed", "table_count"}]} |
A connection with "managed": "email" is the one askDBLM keeps to the
instance's email database. It can be tested, indexed and
given knowledge like any other, and added to a module; it cannot be changed or
removed, and adding a connection under its name is refused.
Body for adding, changing and testing:
| Field | Type | Meaning |
|---|---|---|
name |
string | Required when adding. The path names the connection when changing. |
driver |
string | Required when adding: postgres, mysql, clickhouse, mongo, sqlite or openapi |
host, port |
string, integer | The server, as an address or name. An IPv6 address works with or without brackets. The port defaults to 5432 for postgres, 3306 for mysql, 27017 for mongo and 9000 for clickhouse. |
username, password |
string | Credentials. The password is never returned. |
ssl_mode |
string | postgres only |
path |
string | sqlite: the database file |
spec |
string | openapi: the document's URL or path |
headers |
object | openapi: headers to send, such as an Authorization. The values are never returned. |
dsn |
string | A full connection string instead of the fields above. It must not name a database: databases are chosen when indexing. |
ssh |
object | An SSH server to connect through. See below. |
Through an SSH server. A database that is reachable only from another
server, such as a bastion or the database's own machine, can be reached
through an SSH server there. host and port are then the database's address
as the SSH server reaches it: 127.0.0.1 for a database on the SSH server
itself. Available for postgres, mysql, mongo and clickhouse.
{"name": "lab", "driver": "postgres", "host": "127.0.0.1", "port": 5432,
"username": "readonly", "password": "…",
"ssh": {"host": "bastion.example.com", "port": 22, "username": "tunnel", "password": "…"}}
ssh field |
Meaning |
|---|---|
host, port |
The SSH server. The port defaults to 22. |
username |
Who to sign in to the SSH server as |
password |
Signs in by password. Never returned. |
private_key, passphrase |
Signs in by key instead: the whole private key, in PEM or OpenSSH form, and its passphrase if it is encrypted. Never returned. |
host_key |
Optional. The SSH server's public key (ssh-ed25519 AAAA…) or its fingerprint (SHA256:…). |
The SSH server's host key is pinned when the connection is saved, and every
connection checks the server against it, so a different server answering at
that address is refused. Left out, host_key pins the key the server shows
when you save: compare the fingerprint in the answer with the server's own
(ssh-keygen -lf on its host key). A fingerprint you give is checked against
the server's key before it is pinned. Through a tunnel, postgres cannot use
ssl_mode verify-full, and mongo needs one server's address rather than a
mongodb+srv one.
Changing a connection:
- An omitted
driverkeeps the current one. - An omitted or empty
passwordkeeps the stored password, together with any host, port, username or SSL mode you leave out. - If you send a new
password, sendhost,portandusernamewith it. headersset tonullkeeps the current headers, and{}clears them.- An omitted
sshkeeps the SSH server, andnullstops connecting through one. Ansshobject for the same server and user keeps the stored password and key you leave out, and the pinned host key. To pin a new key after the server's has changed, send its fingerprint ashost_key. - For sqlite and openapi, send
pathorspecagain.
A connection as returned: name, driver, host, port, username,
ssl_mode, path, spec, header_names, password_configured,
dsn_redacted (the connection string with its secrets masked) and, for one
through an SSH server, ssh: host, port, username,
password_configured, private_key_configured and host_key, the pinned
key's fingerprint.
A test answers 200 either way, with
{"ok": true, "driver": "postgres", "latency_ms": 42}. A failed test has
"ok": false, an error_code (invalid_connection, timeout, auth,
refused or connection_test_failed) and a message. Through an SSH server,
the code can also be ssh_unreachable, ssh_auth (the SSH server turned the
sign-in down), ssh_host_key (its key is not the pinned one), ssh_forward
(signed in, but the SSH server could not reach the database) or ssh_config.
A test of unsaved settings that go through an SSH server also has
ssh_host_key, the fingerprint saving them would pin.
Databases and indexing
Indexing reads a database's schema so questions can be answered from it.
{conn} is a connection; {conn}/{db} is one database on it. A sqlite or
openapi connection is indexed as a whole, by {conn} alone.
| Method and path | Role | Does |
|---|---|---|
GET /databases |
viewer | Every database the instance knows: connection, database, driver, table_count, column_count, indexed_at, index_status (Indexed, Indexing, Failed, Skipped or Not indexed) and knowledge_status (Ready, Dirty, Missing or Generating) |
GET /databases/{conn}/schema, GET /databases/{conn}/{db}/schema |
viewer | The indexed schema: source, driver, and tables with their columns, keys and indexes |
POST /databases/{conn}/index |
editor | Indexes databases on the connection. 202 with {"items": [job, …]}, one job per database. |
POST /databases/{conn}/{db}/index |
editor | Indexes one database. 202 with {"items": [job]}. |
POST /databases/{conn}[/{db}]/refresh |
editor | Re-reads a schema after it changed. Same answer as indexing. |
DELETE /databases/{conn}[/{db}]/index |
editor | Forgets an index, with its knowledge. 204. |
The body of POST /databases/{conn}/index is optional:
| Field | Type | Meaning |
|---|---|---|
databases |
string array | Which databases to index. Omitted or empty: every database on the server. |
skip_existing |
boolean | With every database, skip those already indexed |
If a database on the connection is busy, nothing is started and the request is
refused with 409 jobs_running.
Jobs
| Method and path | Role | Does |
|---|---|---|
GET /jobs |
viewer | Recent jobs, newest first: {"items": [...], "total": n} |
GET /jobs/{id} |
viewer | One job. See Background jobs for its fields. |
POST /jobs/{id}/cancel |
viewer | Cancels a queued or running job and returns it. A finished job answers 409 job_finished. |
A job's type is index, refresh, generate (knowledge),
module_generate (module knowledge) or broadcast (sending a
broadcast, with target broadcast:<id>). Jobs that were
running when the instance restarted end as failed. Cancelling a broadcast's
job pauses the broadcast.
Knowledge
Knowledge is what the instance knows about a database beyond its schema: descriptions of tables and columns, relationships, business rules, example questions and a glossary. It makes answers better.
| Method and path | Role | Does |
|---|---|---|
GET /knowledge |
viewer | One entry per database: connection, database, target, description, table_count, generated_by (auto or manual), status (Ready, Dirty or Generating), is_dirty, updated_at |
GET /knowledge/{conn}[/{db}] |
viewer | The whole document |
PUT /knowledge/{conn}[/{db}] |
editor | Replaces the whole document with the one you send |
DELETE /knowledge/{conn}[/{db}] |
editor | Deletes it. 204. |
POST /knowledge/{conn}[/{db}]/generate |
editor | Has the model write it from the schema. 202 with a single job, which can take several minutes. Items marked pinned are kept. |
The document:
{
"conn_name": "chinook",
"description": "A digital music store.",
"tables": [
{
"schema_name": "", "table_name": "Invoice",
"summary": "One row per customer purchase.", "description": "",
"pinned": true,
"columns": [ { "column_name": "Total", "description": "Amount charged, in USD." } ]
}
],
"relationships": [ { "description": "Invoice.CustomerId references Customer.CustomerId." } ],
"business_rules": [ { "text": "Revenue means the sum of Invoice.Total.", "pinned": true } ],
"example_queries": [ { "question": "Revenue by country", "query": "SELECT BillingCountry, SUM(Total) FROM Invoice GROUP BY 1" } ],
"glossary": [ { "term": "Track", "definition": "One song." } ],
"generated_by": "manual",
"is_dirty": false,
"updated_at": "2026-09-30T15:02:44Z"
}
A database's knowledge is Dirty once a refresh finds its schema changed
since the knowledge was written: tables, columns, types, keys or comments.
Generate it again so answers use what changed.
Every item can be pinned, so that generating knowledge again keeps it.
PUT replaces the document entirely. Read it, change it, and send all of it
back. The instance sets conn_name, generated_by (manual), is_dirty
and updated_at itself. A table needs a table_name, a column a
column_name, a rule its text, an example its question and a glossary entry
its term. Otherwise the answer is 400 invalid_knowledge.
Fassads
A fassad is a saved query with parameters (sql), or a saved question template
(nlq).
| Method and path | Role | Does |
|---|---|---|
GET /fassads |
viewer | Lists them: name, mode, connection, module, param_count, created_at, health |
POST /fassads |
editor | Creates one, then checks it (below). 201 with the fassad. |
GET /fassads/{name} |
viewer | Shows one, including its body and health |
PUT /fassads/{name} |
editor | Replaces it with the one you send, then checks it |
DELETE /fassads/{name} |
editor | Deletes it. 204. |
POST /fassads/{name}/run |
editor | Runs it (below) |
POST /fassads/{name}/check |
editor | Checks an sql fassad against its database again, now. Answers with the fassad. |
A fassad:
| Field | Type | Meaning |
|---|---|---|
name |
string | Required when creating |
mode |
string | Required: sql (a saved query, run as written) or nlq (a question template the model answers) |
connection |
string | Where it runs: a connection, or connection/database. Required for sql. |
module |
string | For nlq only, instead of a connection: a module to ask |
body |
string | Required. The query or the question, with {param} placeholders. |
params |
array | Each {"name", "kind", "required", "default"}. kind is string, int, float or bool. |
A saved query must only read. One that would change data is saved, but refused
when it runs (403 not_read_only).
A fassad that answers for one user takes that user's id as a parameter. askDBLM
has no users of its own, so the server that runs the fassad, the one holding
the credentials, authenticates and authorizes the user, then supplies the id in
args, where it is bound as a value and cannot change the query. Never let a
browser or app choose the id.
Health says whether a fassad still fits the schema it runs against:
| Field | Meaning |
|---|---|
status |
ok: checked against the current schema. stale: something it reads changed, so it may still run but return something different. broken: it fails as written. unchecked: not checked yet. |
reason |
Why, when the status is not ok: Column Customer.SupportRepId no longer exists |
detail |
The database's own error, when there is one |
deps |
The tables it reads, with the columns of them it names, as of its last good check |
checked_at, last_run_at, last_run_error |
When it was last checked and run, and how that run failed |
An sql fassad is checked by its database, which plans the query without
returning any rows: when it is saved, when its database is re-indexed, and by
/check. A check that passes takes the current schema as the new yardstick,
so /check is also how a stale fassad is marked as reviewed. An nlq fassad
writes a new query each run, so what it reads is the query its last run wrote;
a re-index that changes those tables makes it stale until it next runs.
Deleting its connection, its index or its module makes a fassad broken.
Running a fassad takes {"args": {"n": "5"}}. Values are strings here. It
answers with name, mode, target, duration_ms, health after the run,
warnings (for nlq, when the knowledge it used is missing or older than the
schema) and either:
- for
sql:rendered_sqlandresult(columns,rows); - for
nlq:questionandquery, an answer.
For software that runs fassads, the askDBLM API is the better fit. It needs only an API key, accepts typed arguments, and returns reports.
Modules
A module is a hand-picked group of tables that can span databases, so a question can join across them.
| Method and path | Role | Does |
|---|---|---|
GET /modules |
viewer | Lists them: name, table_count, conn_count, created_at |
POST /modules |
editor | Creates an empty module: {"name": "sales"}. 201. |
GET /modules/{name} |
viewer | Shows it: tables, hidden_columns, connections, created_at, updated_at |
DELETE /modules/{name} |
editor | Deletes it. 204. |
POST /modules/{name}/tables |
editor | Adds tables: {"connection": "shop", "database": "main", "tables": ["orders", "customers"]}. The tables their foreign keys point to are added too. Answers with what was selected and the parents auto_added that the module did not have. |
DELETE /modules/{name}/tables |
editor | Removes tables, with the same body. Answers with what was removed, retained_as_parent (still needed by a table that stayed), dropped_parents, any warnings, and the module as view. |
PUT /modules/{name}/hidden-columns |
editor | Hides columns of one table from the model: {"connection", "database", "schema", "table", "hidden": ["ssn", "email"]}. The list is complete: [] shows every column again. |
GET /modules/{name}/knowledge |
viewer | The module's own knowledge: how its databases relate |
POST /modules/{name}/knowledge/generate |
editor | Has the model write it. 202 with a single job. The module must span at least two databases. |
- Naming tables. A table is named as it is in its database. Where two
schemas of one database each have a table of that name, name it as
schema.table; a bare name that fits both is refused with400 invalid_module. - Foreign-key parents. A table added because a selected table points to it cannot be removed on its own; it goes when nothing needs it. Adding it yourself makes it a selection, so it stays when the table that pulled it in goes.
- Hidden columns go with their table when it is removed. A hidden column renamed or dropped in the database hides nothing: saving the table's list lets it go, whether or not you send it again.
- A missing database. When a connection a module reads is deleted, or one
of its databases is no longer indexed, questions to the module fail with
400 invalid_modulenaming that database, and fassads that ask the module are marked broken. Index the database again or remove its tables. Removing other tables meanwhile keeps that database's foreign-key parents as they were, and says so inwarnings.
Questions and history
| Method and path | Role | Does |
|---|---|---|
POST /query |
viewer | Asks a module a question and answers it |
GET /sessions |
viewer | Recent conversations, newest first: id, title, turns, started_at, updated_at, targets |
GET /sessions/{id} |
viewer | One conversation's questions, oldest first: {"id", "items": [...], "total"} |
POST /query takes a question and the module to ask: questions are asked
of modules, never of a connection or database directly. It also takes an
optional session_id for follow-up questions, and chart: true to suggest a
chart. It answers with question, summary,
results (one per database, each with the query that ran, its result, and
any chart, visual or error), targets and duration_ms. visual is the
whole suggested chart and chart its one-measure form, as in the
askDBLM API. A question may take up to two minutes.
A module question over databases that one query cannot reach has a single
result with steps: what ran on each database, as source, driver and
query. Its query is then how dblm joined their results.
Questions here count toward your plan's monthly allowance, like any other. For product features, prefer the askDBLM API, which adds reports and long-question handling.
Widgets
A widget is a saved query pinned to a board with a chart. Each person has their own board; a token works on its owner's.
| Method and path | Role | Does |
|---|---|---|
GET /widgets |
viewer | Your board. Widgets whose interval has passed are refreshed first. |
POST /widgets |
viewer | Pins a widget. 201. |
GET /widgets/{id} |
viewer | Shows one |
PUT /widgets/{id} |
viewer | Changes one |
DELETE /widgets/{id} |
viewer | Removes it. 204. |
POST /widgets/{id}/refresh |
viewer | Runs its query now |
A widget:
| Field | Type | Meaning |
|---|---|---|
title |
string | Defaults to the question |
question |
string | The question the query came from, for display |
connection |
string | Required when pinning: a connection or connection/database. For a merged answer, only its label, as the answer's source gave it. |
sql |
string | Required, also when changing. The read-only query to run; for a merged answer, the join of the steps' results. |
steps |
array | For an answer merged from several databases: the answer's steps, each {"source", "driver", "query"}, up to 16 |
chart |
object | {"type", "x_column", "y_column", "label_column", "title"}: a chart in version 1's terms |
visual |
object | The whole chart, as the askDBLM API describes it; wins over chart when both are sent |
interval_seconds |
integer | How often to refresh: at least 30. Defaults to 300. |
x, y, w, h |
integer | Its place on the board's grid |
When changing a widget, title, question and sql are always replaced by
what you send. The other fields keep their current values when omitted. A
widget carries the result of its last run, with last_run_at: data holds
its first 500 rows, for the table, and row_count how many the run returned.
A failed refresh keeps the last good data and sets last_error.
Its chart. A widget returns its chart as both visual and chart. One
sent as visual is checked (400 invalid_widget says what is wrong with it)
and kept as sent. One sent as chart alone is made whole and fitted to the
rows at the next refresh: rows that repeat x beside a column of a few values
get a split by that column, so they draw as one series each. A chart sent
back as the widget returned it, as its visual or its chart, changes
nothing, so a change of title or place keeps every measure. Saving a chart
that draws columns the saved rows were not kept for refreshes the widget
before it answers, re-running its query, so the tile draws every row.
A chart may group time: x.grain (minute to year) with aggregate
(sum, avg, min or max), for lines, areas, bars and heatmaps whose x
holds moments. A number with an aggregate combines every row instead, and
has no x.
The chart draws every row. When a run returns more rows than data keeps,
chart_data carries the columns the chart draws, for all of them: x, the
split, then the measures, as {"columns": [...], "rows": [...], "total": n}.
Past 50,000 rows it is reduced, and reduced says how: a chart that groups
time keeps one row for each step (and each value of its split), its measures
combined as aggregate says ("group": exactly what it draws, from every
row; x is the step's start), and a number that combines every row keeps that
one row; a line, area or bar chart without a split
keeps its shape ("shape": the rows in order, cut into slices, each keeping
its first and last row and every measure's lowest and highest, so no peak or
dip is lost; skipped counts rows with no number to draw); and any other
chart keeps the first 50,000 ("first"), since a split's Other sums every
row. There is no chart_data when data holds every row.
A merged answer (one whose result has steps, from a module spanning
databases one query cannot reach) is pinned with its steps. A refresh then
does what the answer did, without the model: it runs each step on its
database, loads the results into an in-memory database, and runs sql there.
Every step and the join must be read-only, and a refresh reads each step's
rows again, so a step that reads a large table makes a heavier widget. When
changing a merged widget, steps left out keep their value.
Settings
Which AI model the instance uses.
| Method and path | Role | Does |
|---|---|---|
GET /settings |
owner | provider, model, base_url, api_key_configured, and effective: what is actually in use once defaults apply |
PUT /settings |
owner | Changes them |
Every field of the body is optional. A field you leave out keeps its value, and an empty string clears it:
| Field | Meaning |
|---|---|
provider |
anthropic, gemini or ollama |
model |
The model's name |
base_url |
Where to reach the provider: an http or https URL |
api_key |
The provider's key. It is never returned. |
The answer may carry warnings, for example a provider with no key. On hosted
plans the model is provided for you, so most instances never need this
endpoint.
Email settings
How the instance sends email: through your own Resend account, from a domain you have verified there.
| Method and path | Role | Does |
|---|---|---|
GET /email-settings |
owner | resend_api_key_configured, from, reply_to, public_url, requests_per_second, for reports webhook_configured, webhook_connected, webhook_url and webhook_endpoint, and the email database as database |
PUT /email-settings |
owner | Changes them |
POST /email-settings/check |
owner | Asks Resend whether the key works and whether the sender's domain is verified |
POST /email-settings/webhook |
owner | Connects bounce and complaint reports: creates the webhook in your Resend account |
DELETE /email-settings/webhook |
owner | Disconnects them |
Every field of the body is optional. A field you leave out keeps its value, and an empty string clears it:
| Field | Meaning |
|---|---|
resend_api_key |
Your Resend API key. It is never returned. |
from |
Who email comes from: Name <address> or an address, on a domain verified in Resend |
reply_to |
Where replies go, when not to from |
public_url |
The instance's address as recipients reach it, like https://data.example.com, which unsubscribe links point to. Leave it empty to use the address the broadcast is sent from. |
requests_per_second |
Calls to Resend a second, 100 emails each: 1 to 100, 0 for the default of 5. Resend allows 10 a second per account unless you ask for more, shared with anything else using the account. |
webhook_secret |
The signing secret (whsec_...) of a webhook you set up in Resend yourself, for bounce and complaint reports. It is never returned; the answer says webhook_configured. |
The check answers with ok, a message to show, and, when the key may list
them, the account's domains (name, status) and from_verified. A key
restricted to sending works but cannot list domains, so it answers
"restricted": true instead. An instance can also take its key from the
DBLM_RESEND_API_KEY environment variable; then
resend_api_key_configured is true while resend_api_key_in_config is
false.
Email templates
An email template is a React Email component: the email's design, with variables filled in for each recipient. Most are written by the agent, the instance's AI model, in a conversation: describe the email, then ask for changes until it is right. Every change is kept as a version you can go back to. The agent follows the instance's brand guide. You can also write or edit the code yourself.
| Method and path | Role | Does |
|---|---|---|
GET /email-templates |
viewer | Lists them |
POST /email-templates |
editor | Creates one, after checking it (below). 201 with the template. |
GET /email-templates/{name} |
viewer | Shows one, including its source |
PUT /email-templates/{name} |
editor | Changes it. A field you leave out keeps its value. |
DELETE /email-templates/{name} |
editor | Deletes it. 204. |
POST /email-templates/draft |
editor | The AI model writes a template, or changes one (below). Nothing is saved. |
POST /email-templates/preview |
viewer | Renders a template that is not saved |
POST /email-templates/{name}/preview |
viewer | Renders a saved template, with values you give |
POST /email-templates/{name}/test |
editor | Sends it to up to five addresses |
POST /email-templates/chat |
editor | A new template from a first message to the agent, which names it (below). 201. |
POST /email-templates/{name}/chat |
editor | A message to the agent about a template: a change, saved as the next version, or a question, answered |
GET /email-templates/{name}/history |
viewer | The template's conversation and versions, oldest first |
GET /email-templates/{name}/history/{seq} |
viewer | One step of it, with the template's source after it |
POST /email-templates/{name}/history/{seq}/restore |
editor | Goes back to that version, saved as a new one |
A template:
| Field | Type | Meaning |
|---|---|---|
name |
string | Required when creating. Not draft, preview or chat, up to 100 characters. |
description |
string | What it is for |
source |
string | Required when creating. The component's TSX, up to 100 KB. |
subject |
string | Required. The subject line, up to 200 characters, with {{variable}} placeholders. |
variables |
array | Worked out when it is saved (below): {"name", "sample"} for each |
created_by, created_at, updated_by, updated_at |
Who made and last changed it, and when |
{
"name": "welcome",
"description": "Sent to new customers",
"subject": "Welcome to Northwind, {{firstName}}",
"source": "import { Html, Text, Link } from \"react-email\";\n\nexport default function Welcome({ firstName, unsubscribeUrl }: { firstName?: string; unsubscribeUrl?: string }) {\n return (\n <Html>\n <Text>Hi {firstName || \"there\"}, welcome to Northwind.</Text>\n <Link href={unsubscribeUrl}>Unsubscribe</Link>\n </Html>\n );\n}\n\nWelcome.PreviewProps = { firstName: \"Ana\" };\n"
}
What a template may be. The source is one module whose default export is
the component. It may import only from react-email (or the older
@react-email/components) and react, and the components available are
Body, Button, CodeInline, Column, Container, Font, Head,
Heading, Hr, Html, Img, Link, Markdown, Preview, Row,
Section and Text. Style with inline style objects. A template runs in
the instance with no network access, so it cannot fetch anything.
Variables are the props the template reads. Each one's value comes from a
column of the recipient list, so every variable is text. Give the component
PreviewProps with a sample of each: they fill the preview. The template can
use a variable in logic, for example {firstName || "there"} for a recipient
with no first name. askDBLM passes two props of its own to every email:
email, the recipient's address, and unsubscribeUrl. Link to
unsubscribeUrl from every template you will send to a list.
Saving checks the template. It is compiled, run and rendered with its
samples. One that does not compile, throws, or uses a {{placeholder}} that is
not one of its variables is refused with 400 invalid_template, and the
message says why, with the line number for a compile error. The answer to a
create or change carries warnings too, for example an email over 100 KB,
which Gmail cuts short, or a template with no unsubscribe link.
A preview takes {"source", "subject", "values"} (only values for a
saved template; each value replaces that variable's sample) and answers with
the rendered subject, html and text, its size_bytes, the template's
variables and any warnings.
A draft takes {"instruction": "A welcome email with their first name and plan"}.
To change a template, send its source and subject too, with an instruction
such as "Make the button green". It answers with source, subject, the
model's notes and a preview. A draft that does not work is sent back to the
model once with the reason. Each draft counts as one question against the
instance's monthly allowance (429 quota_exhausted when it is used up).
Talking to the agent. Send {"message": "A welcome email with their first name and a button to sign in"} to POST /email-templates/chat for a new
template, then {"message": "Make the button green"} to
POST /email-templates/{name}/chat for each change. The agent sees the
conversation so far, the template as it is now and the brand guide. A message
that asks for a change saves the template as the next version; a question
("Which details does it use?") is answered and changes nothing. Either way
the answer is a step:
| Field | Meaning |
|---|---|
template |
The template as it is now |
entry |
The step in its history (below), with the agent's reply and suggestions |
preview |
The rendered email, when the step changed it |
warnings |
What saving noticed, as for a template saved by hand |
A turn takes up to a couple of minutes and finishes even if the caller hangs
up: its result is saved, and shows in the history. The agent answers one
message per template at a time; a second is refused with 409 agent_busy.
A change that does not produce a working template is sent back to the model
once with the reason, and 422 draft_failed means nothing was changed. Each
message counts as one question against the instance's monthly allowance.
The history is {"items": [...], "working": {...}}, where working (when
present) is the message the agent is still answering, with by and
started_at. Each item is a step:
| Field | Meaning |
|---|---|
seq |
Its number, from 1. A step that changed the email is that version. |
kind |
chat (a turn with the agent), edit (a change saved by hand), created (made by hand) or restore |
message, reply, suggestions |
For a turn: what was asked, what the agent answered, and up to three next requests it offers |
changed |
Whether the step changed the email. A question leaves it as it was. |
restored_from |
For a restore: the version it went back to |
subject, by, at |
The subject after the step, who took it, and when |
Saving a new source or subject by hand is a step too; a new name or
description is not. A template keeps its last 200 steps, and deleting it
deletes them.
A test takes {"to": ["[email protected]"], "values": {"firstName": "Ana"}}
and sends each address its own email, with [Test] before the subject, from
the email settings' sender. It answers
{"sent": [{"to", "id"}]} with Resend's id for each. An instance may send 20
test emails an hour.
Brand guide
How your email should look and sound: your colors, type, buttons, layout, imagery and voice. The agent follows it in every template it writes, unless a message asks for something else. Rather than write one from nothing, upload screenshots of your website, pages of your brand book or exports from your design tool, and the instance's AI model works it out. Check what it found, then save it.
| Method and path | Role | Does |
|---|---|---|
GET /email-brand |
viewer | The guide, with sources, updated_by and updated_at. "set": false while there is none. |
PUT /email-brand |
editor | Saves {"guide": {...}, "sources": ["home.png"]} |
DELETE /email-brand |
editor | Removes it. 204. |
POST /email-brand/analyze |
editor | The AI model works out a guide from files and notes (below). Nothing is saved. |
A guide. Every field is optional, but a guide may not be empty:
| Field | Meaning |
|---|---|
name |
The brand's name, as it writes it |
summary |
The look and feel, in a sentence or two |
colors |
Up to 12 of {"name", "hex", "usage"}: a name like Primary, a hex code like #1A73E8, and where it goes |
heading_font, body_font |
CSS font stacks, like Georgia, 'Times New Roman', serif. End them in fonts every email client has. |
typography, buttons, layout, imagery, tone |
How each is done, in a sentence or two |
rules |
Up to 20 things always or never to do |
logo_url |
The logo, at an https:// address an email can load. The agent puts it at the top of every email. |
A color that is not a hex code, a font stack with anything but font names and
commas, or a logo not at an https:// address is refused with
400 invalid_brand.
Working one out. POST /email-brand/analyze takes
{"files": [{"name", "type", "data"}], "notes": "..."}: up to 6 files,
2.5 MB in all, each with its media type and its bytes in base64. The model
reads PNG, JPEG and WebP images and PDF files as they are, and SVG, CSS, HTML,
Markdown and plain text as text. Notes are optional: who the business is, what
to stress, the address of the logo. It answers with a proposed guide and the
sources it came from, in a minute or so, and counts as one question against
the instance's monthly allowance. Files it cannot use, or a model that cannot
read images, are refused with 400 invalid_brand; models run through Ollama
read images but not PDF files.
Broadcasts
A broadcast sends an email template to a list of recipients: a CSV file whose columns fill the template's variables. It starts as a draft, which you can change and review, and sending it runs as a job.
| Method and path | Role | Does |
|---|---|---|
GET /broadcasts |
owner | Lists them, newest first, with counts |
POST /broadcasts |
owner | Creates a draft: {"name", "template"}, both optional. 201. |
GET /broadcasts/{id} |
owner | One broadcast, its counts and, for a draft, its review |
PUT /broadcasts/{id} |
owner | Changes a draft: name, template, email_column, user_id_column, column_map, from, reply_to |
DELETE /broadcasts/{id} |
owner | Deletes one that is not sending. 204. |
PUT /broadcasts/{id}/recipients |
owner | Replaces a draft's recipient list. The body is the CSV, sent as text/csv; ?file_name= names it. |
GET /broadcasts/{id}/recipients |
owner | Its recipients, 100 at a time: ?status= (pending, sent, failed, skipped, unknown, or a delivery report: delivered, delayed, soft_bounced, bounced, complained), &offset=, &limit= (up to 500) |
GET /broadcasts/{id}/results.csv |
owner | What became of each recipient, with their row from the list, as CSV |
POST /broadcasts/{id}/send |
owner | Sends a draft: {"confirm_recipients": 205} |
POST /broadcasts/{id}/pause |
owner | Pauses a sending broadcast |
POST /broadcasts/{id}/resume |
owner | Resumes a paused one where it stopped |
POST /broadcasts/{id}/cancel |
owner | Stops it for good. The recipients not yet sent to are not sent to. |
The recipient list is UTF-8 CSV with a header row, separated by commas,
semicolons or tabs, up to 10 MB, 50,000 rows and 50 columns. One column holds
the addresses: one called email (or e-mail, to, and the like) is chosen,
or the first whose cells are mostly addresses; email_column changes it.
Each template variable is filled from the column of the same name, matched
ignoring case, spaces and punctuation, so First Name fills firstName.
column_map changes that: {"firstName": "given_name"}, or "" for no
column, in which case the variable is empty and the template's fallback shows.
A row is skipped when it has no address (no_address), one that is not
valid (invalid_address), one already on the list (duplicate), or one on the
do-not-email list (suppressed).
The user ID column, when the list has one, holds each recipient's ID in
your own records. It links every email sent to that person in the
email database, so questions can join the emails to your
users. A column called user_id, customer_id, account_id, member_id,
contact_id, external_id, uid or id (ignoring case, spaces and
punctuation) is chosen; user_id_column changes it, "" for none. The review
warns when some recipients have none. Each recipient then has a user_id.
A broadcast:
| Field | Meaning |
|---|---|
id |
bc_ and 16 hex digits |
name, template |
Its name, and the template it sends |
template_id |
The template's id, which renaming it does not change |
file_name, columns, email_column, user_id_column, column_map |
The recipient list and how its columns are used |
from, reply_to |
The sender, when not the email settings' |
status |
draft, sending, paused, completed or cancelled |
pause_reason |
Why it paused or stopped, as a sentence to show |
subject |
Once sent, the subject it was sent with |
counts |
total, pending, sent, failed, skipped, unknown, and skipped_by_reason; and from Resend's reports, delivered, bounced and complained |
created_by, created_at, started_at, finished_at |
Who and when |
purge_after, purged |
When its recipient list is deleted (30 days after it ends), and whether it has been. Its counts stay. |
The review of a draft says what would go out. ready is true when
nothing stops it; otherwise problems say what to fix: no template, no list,
no address column, nobody to send to, no Resend key or From, a template that
no longer renders, or a template with no unsubscribe link. warnings are worth
reading first: a variable with no column, empty cells, skipped rows. It also
holds the template's variables, each with its column and how many
recipients have it empty, the unused_columns, the sender (from,
reply_to), and samples: the first three recipients' emails, rendered
(to, subject, html, text, size_bytes).
Sending takes the number of recipients it will go to,
counts.pending, as confirm_recipients; any other number is refused with
409 broadcast_not_ready, so a list that changed since you looked is not sent
by mistake. The template is copied at that moment: changing it afterwards
changes nothing about the broadcast. Emails go out in batches of up to 100, at
the email settings' requests_per_second, each its own
email to one recipient.
A send pauses with a pause_reason when Resend's daily or monthly limit is
used up, when Resend refuses the key or the sender, or when Resend cannot be
reached, and when the instance restarts. Resume it once the cause is fixed: it
goes on where it stopped, and nobody gets an email twice. A recipient is
unknown when Resend may or may not have sent their email and the instance
could not tell within a day; they are not sent to again.
Unsubscribing. Every email carries the List-Unsubscribe and
List-Unsubscribe-Post headers Gmail and Yahoo require from bulk senders, and
the template's unsubscribeUrl is a link to a page on the instance, at
/u/..., which asks before unsubscribing; a mail client's one-click
unsubscribe needs no page. Unsubscribing puts the address on the
do-not-email list. The links point at public_url in
the email settings, or, when that is empty, at the address the broadcast was
sent from, which must be one recipients can reach: an instance reached at
localhost or a private address is refused with 503 email_unavailable until
public_url is set.
Bounces and complaints. Resend can report what became of each email
after it was sent. POST /email-settings/webhook sets that up: it creates a
webhook in your Resend account that calls the instance at /webhooks/resend
(at public_url, or the address you call from, which must be reachable from
the internet), asking for email.delivered, email.delivery_delayed,
email.bounced and email.complained, and keeps its signing secret. Every
report is checked against that secret and refused when it is more than five
minutes old. A key that may only send cannot create webhooks: add one in
Resend yourself, with that address and those events, and send its signing
secret as webhook_secret in the email settings. Sending a
webhook_secret replaces a webhook askDBLM made, which is removed from
Resend. webhook_endpoint says where askDBLM's webhook reports to; when it
is not webhook_url, the public address changed since, and connecting again
points the webhook at the new one.
Each recipient then shows a delivery: delayed, delivered,
soft_bounced (a temporary bounce), bounced or complained (marked as
spam), with a delivery_note saying why for a bounce, and the results CSV
gains both columns. An address that bounces for good, from any email the
Resend account sends, and an address that marks one of this instance's emails
as spam, go on the do-not-email list, with the reason
bounced or complained.
Do-not-email list
Addresses the instance never emails. Every broadcast skips them.
| Method and path | Role | Does |
|---|---|---|
GET /email-suppressions |
owner | The list, newest first: ?search=, &offset=, &limit=. Each: email, reason (unsubscribed, bounced, complained or manual), created_at. |
POST /email-suppressions |
owner | Adds addresses: {"emails": ["[email protected]"]}. Answers {"added": 1}; ones already on it are left as they are. |
DELETE /email-suppressions/{email} |
owner | Takes an address off. 204. |
Email database
Everything Email keeps is in a database of its own: the templates and their
history, the brand guide, the broadcasts and their recipient lists, the
do-not-email list, and a record of every email sent and whom it went to. On an
instance whose state is in Postgres it is a second database on the same
server, named after the state database with _email added (dblm_email on a
hosted instance); otherwise it is the SQLite file ~/.dblm/email.db.
WhatsApp keeps a database of its own beside it.
askDBLM offers it to the query side as a connection it keeps,
called email, read only: on Postgres it logs in as a role that may only
read, on SQLite it opens the file read only. The connection is made the first
time Email is used. Index it, generate knowledge for it and add it to a
module, and questions about who was sent what go through
questions, the public API and fassads like any
other. A module can hold your own databases too, so one question can join the
emails to your customers. It comes with knowledge already written, pinned so
that generating knowledge keeps it, and indexing it replaces addresses in the
sample rows with [email protected] and the like and leaves out the cells
of uploaded lists, so neither reaches the model. It is in no module until you
add it; an API key allowed every module can then ask about the
people you email.
| Table | Holds |
|---|---|
email_sends |
One row for every email sent, kept for good: the broadcast and template (with their names when it was sent), the contact, the address, sent_at, and Resend's delivery report with delivery_at. A template sent again adds rows. |
email_contacts |
The people emailed: a user_id when a list gave one, else an address. A person known by address takes the user ID the first time a list gives one with that address; two users who share an address stay two contacts. |
email_templates, email_template_history, email_brand |
Templates, their versions, the brand guide |
email_broadcasts, email_recipients |
Broadcasts and their lists; a list is deleted 30 days after its broadcast ends, the sends stay |
email_suppressions |
The do-not-email list |
GET /email-settings reports it as database: backend (postgres or
sqlite), location (the database or the file), connection (once made),
ready, and a problem when something stops it. On a Postgres server where
the state user may not create databases or roles, the problem says what an
administrator runs once; or set DBLM_EMAIL_DSN to a database made by hand
and DBLM_EMAIL_READER_DSN to a read-only login for it.
| Method and path | Role | Does |
|---|---|---|
POST /email-contacts/forget |
owner | Removes a person, for an erasure request: {"email": "[email protected]"} or {"user_id": "4812"}. Deletes their contact, every email sent to them and their rows in recipient lists not yet deleted. Answers {"contacts": 1, "sends": 3, "recipients": 1}. It does not touch the do-not-email list: an address on it stays on it, so a person who unsubscribed is still never emailed. |
WhatsApp settings
How the instance sends WhatsApp messages: through your own WhatsApp Business account on Meta's Cloud API, from your own number. Meta bills the account for each message.
| Method and path | Role | Does |
|---|---|---|
GET /whatsapp-settings |
owner | access_token_configured, app_id, app_secret_configured, waba_id, phone_number_id, default_country, and ready once a token and both IDs are set |
PUT /whatsapp-settings |
owner | Changes them |
POST /whatsapp-settings/check |
owner | Asks Meta about the number, the account and, with the app's ID and secret, the token |
POST /whatsapp-settings/webhook |
owner | Connects Meta's reports: {"mode": "app"} or {"mode": "override"} |
DELETE /whatsapp-settings/webhook |
owner | Disconnects them |
Every field of the body is optional. A field you leave out keeps its value, and an empty string clears it:
| Field | Meaning |
|---|---|
access_token |
A system user's access token, made with no expiry, with the whatsapp_business_messaging and whatsapp_business_management permissions. It is never returned. |
waba_id |
The WhatsApp Business Account ID, where the templates live |
phone_number_id |
The phone number ID messages go from (not the phone number itself) |
app_id |
The Meta app the token belongs to |
app_secret |
That app's secret. With app_id it lets the check read the token's permissions and expiry; delivery reports will be checked with it. It is never returned. |
default_country |
Two letters, like GB or IN: a phone number written without its country code is read as this country's. The check sets it from the business number when it is empty. |
stop_words |
Replies that put a person on the do-not-message list, beyond the defaults (default_stop_words: STOP, UNSUBSCRIBE and others), up to 50 |
messages_per_second |
How fast broadcasts send: 1 to 1000, 0 for the default of 20. Meta takes 80 a second from a number (20 from one shared with the WhatsApp Business app, 1,000 from one Meta has upgraded). |
The IDs are in the app's dashboard under WhatsApp, API Setup; the app ID and
secret under App settings, Basic. An instance can also take the token from
DBLM_WHATSAPP_TOKEN and the secret from DBLM_WHATSAPP_APP_SECRET; then
access_token_configured is true while access_token_in_config is false.
The check answers with ok, a message to show, problems that stop sending,
notes that do not, the account_name, and the number:
display_phone_number, verified_name, quality_rating (GREEN, YELLOW,
RED or NA), messaging_limit (Meta's tier, such as TIER_2K),
daily_limit (people the business may message in a rolling 24 hours, shared
by every number in its Meta business portfolio; 0 with "unlimited": true),
throughput and country. With the app's ID and secret it adds the token:
valid, expires_at (null for a token that does not expire), scopes and
the permissions it is missing. A token Meta refuses is "ok": false with
Meta's reason, not an error.
WhatsApp templates
A WhatsApp message template is Meta's, not the instance's: a fixed shape (a header, a body, a footer, buttons) with variables Meta fills in for each message, and Meta reviews every template before it can be sent. The instance keeps a copy and follows its status. A template starts here as a draft, checked against Meta's rules as it is written; submitting sends it to Meta for review, usually decided within a day. Templates your account already has, made in WhatsApp Manager, are imported.
A template is named by its name and its language, as in Meta's account, so a
template's path is /whatsapp-templates/{name}/{language}.
| Method and path | Role | Does |
|---|---|---|
GET /whatsapp-templates |
viewer | Lists them |
POST /whatsapp-templates |
editor | Creates a draft, after checking it (below). 201 with the template and warnings. |
POST /whatsapp-templates/check |
editor | Checks a template against Meta's rules and works out its variables, without saving it |
POST /whatsapp-templates/sync |
editor | Imports the account's templates, and brings the ones kept here up to date |
GET /whatsapp-templates/{name}/{language} |
viewer | Shows one |
PUT /whatsapp-templates/{name}/{language} |
editor | Changes it. A field you leave out keeps its value. A change to a submitted template goes to Meta (below). |
DELETE /whatsapp-templates/{name}/{language} |
editor | Deletes it, from the account too once submitted. 204. |
POST /whatsapp-templates/{name}/{language}/submit |
editor | Sends a draft to Meta for review |
POST /whatsapp-templates/{name}/{language}/test |
editor | Sends an approved template to up to five numbers |
POST /whatsapp-templates/chat |
editor | A new draft from a first message to the agent, which names it (below). 201. |
POST /whatsapp-templates/{name}/{language}/chat |
editor | A message to the agent about the template |
GET /whatsapp-templates/{name}/{language}/history |
viewer | Its conversation with the agent and its versions |
GET /whatsapp-templates/{name}/{language}/history/{seq} |
viewer | One version, with the template's content after it |
POST /whatsapp-templates/{name}/{language}/history/{seq}/restore |
editor | Puts a draft back as that version, saved as a new one |
A template:
| Field | Type | Meaning |
|---|---|---|
name |
string | Required. Lowercase letters, digits and underscores, like trial_reminder (Meta's rule) |
language |
string | Required. A code Meta takes for templates: en, en_US, pt_BR, ... |
category |
string | Required. MARKETING, UTILITY, or AUTHENTICATION for a one-time code. Meta may file a utility template as marketing, and the template then says so. |
description |
string | What it is for. Kept here only; Meta never sees it. |
parameter_format |
string | NAMED ({{first_name}}), the default, or POSITIONAL ({{1}}), which older imported templates use |
header |
object | null; {"format": "TEXT", "text": "..."} with at most one variable; {"format": "IMAGE"}, "VIDEO" or "DOCUMENT" with example, the https:// address of a sample Meta reviews the template with (a placeholder when you give none, below); or {"format": "LOCATION", "location": {"latitude", "longitude", "name", "address"}}, a place on a map, given with each message (a placeholder when you give none). |
body |
string | Required. Up to 1,024 characters, with WhatsApp's *bold*, _italic_ and ~struck~ |
footer |
string | Up to 60 characters, no variables |
buttons |
array | Up to 10: {"type": "QUICK_REPLY", "text", "opt_out"}, {"type": "URL", "text", "url"} (a variable may end the address), {"type": "PHONE_NUMBER", "text", "phone_number"}, {"type": "COPY_CODE"}. Labels are up to 25 characters; quick replies go together, before or after the rest. |
variables |
array | {"name", "sample", "part"} for each variable, worked out from the text. Send {"name", "sample"} to set samples; Meta reviews the template with them. |
auth |
object | A one-time code's: security_recommendation (Meta's line asking people not to share the code), code_expiration_minutes (1 to 90, a footer saying when it expires; 0 for none) and button_text (the copy button's label, "Copy code" when empty). null for any other template. |
offer |
object | A limited-time offer's: text (up to 16 characters, over the body, like "Expiring offer!"), expires (count down to when it ends) and hours (how long each message's offer lasts, 1 to 720, 24 by default). null removes it. |
cards |
array | A carousel's 2 to 10 cards, each {"header", "body", "buttons"}: a picture or a video header with its example, up to 160 characters of text, and one or two buttons. [] removes them. |
status |
string | DRAFT until submitted, then Meta's: PENDING, APPROVED, REJECTED, PAUSED, DISABLED, IN_APPEAL, or DELETED when the account no longer has it |
status_reason |
string | Why Meta rejected it, when it said, such as INVALID_FORMAT |
quality |
string | Meta's rating of an approved template: GREEN, YELLOW, RED or UNKNOWN |
meta_id |
string | Meta's ID, once submitted or imported |
unsupported |
string | Why the instance shows the template but cannot send or change it: a flow, catalog or other button made for something outside the template, a one-tap or zero-tap code button, a carousel whose cards have variables |
created_by, created_at, updated_by, updated_at, submitted_at, synced_at |
Who made and last changed it, when it went to Meta, and when Meta was last asked about it |
{
"name": "trial_reminder",
"language": "en_GB",
"category": "UTILITY",
"body": "Hi {{first_name}}, your trial of the {{plan}} plan ends on {{end_date}}. Reply if you need help.",
"footer": "Northwind",
"buttons": [{"type": "URL", "text": "Upgrade", "url": "https://northwind.example.com/upgrade/{{account_id}}"}],
"variables": [{"name": "first_name", "sample": "Ana"}, {"name": "plan", "sample": "Pro"},
{"name": "end_date", "sample": "12 October"}, {"name": "account_id", "sample": "4812"}]
}
Kinds. A template is one of four kinds, all written, checked, submitted and sent the same way:
| Kind | Made with | Rules |
|---|---|---|
| A message | header, body, footer, buttons |
Most templates: updates, reminders, receipts, alerts, offers, news. The header is text, a picture, a video, a document or a place. |
| A one-time code | "category": "AUTHENTICATION" and auth |
Meta writes its message in the template's language, "{{1}} is your verification code.", with a button that copies the code; the instance holds it in English for the preview. No header, footer or buttons of your own; its one variable, body.1, is the code. It goes to one person when they ask for it, so it is sent as a test or by your own system, never broadcast. |
| A limited-time offer | "category": "MARKETING" and offer |
A line over the body and, when it expires, a countdown to when each message's offer ends. Its header is a picture, a video or none; its body is up to 600 characters; no footer; its buttons are a copy code (first, when it counts down) and one link. |
| A carousel | "category": "MARKETING" and cards |
The body, then 2 to 10 cards people swipe through. Every card has the same kind of header (all pictures or all videos) and the same kinds of buttons in the same order (quick replies, links without variables, phone numbers); a card's text has no variables. The template has no header, footer or buttons of its own. |
A one-time code, a limited-time offer and a carousel have no opt-out button: people stop them by replying STOP, which the instance records.
Placeholders, for trying a template out. A picture, video or document
header saved without a sample gets a public placeholder, and the answer warns
until you give your own: https://placehold.co/1200x628/png?text=Sample+image
(a PNG), https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4
(MDN's public-domain clip, an MP4 of H.264 and AAC) and
https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf
(a PDF). A place header saved without a place gets Times Square. Meta reviews
the template with the sample, and a test message sends it when media_url
gives none.
{
"name": "new_in",
"language": "en",
"category": "MARKETING",
"body": "Picked for you, {{first_name}}: this week's favourites.",
"variables": [{"name": "first_name", "sample": "Ana"}],
"cards": [
{"header": {"format": "IMAGE", "example": "https://cdn.example.com/tote.png"}, "body": "The everyday tote, in three colours.",
"buttons": [{"type": "URL", "text": "Shop", "url": "https://shop.example.com/tote"}, {"type": "QUICK_REPLY", "text": "More like this"}]},
{"header": {"format": "IMAGE", "example": "https://cdn.example.com/jacket.png"}, "body": "A rain jacket that packs into its pocket.",
"buttons": [{"type": "URL", "text": "Shop", "url": "https://shop.example.com/jacket"}, {"type": "QUICK_REPLY", "text": "More like this"}]}
]
}
Variables. In a named template a variable is its own name, lowercase
letters and underscores. In a positional one it is where it is: header.1,
body.1, body.2, and button.0 for the value at the end of the first
button's address. A copy-code button's code is the variable copy_code
(button.N when positional). Every variable needs a sample.
Saving checks the template against Meta's rules, so a mistake shows in a
second rather than in Meta's review. It is refused with
400 invalid_whatsapp_template, every problem in the message, for: a name
Meta does not take, a language it does not know, a header over 60 characters
or with more than one variable, a body that is empty, over 1,024 characters,
starts or ends with a variable or has two variables side by side, a variable
written wrongly or used twice, a footer with a variable, too many buttons or
quick replies split around other buttons, a link not at https:// or with its
variable anywhere but the end, a missing sample, or a sample on more than one
line, and for breaking a kind's rules above. The answer carries warnings
too, such as too many variables for the text around them, which Meta also
refuses, or a placeholder still in a header or a card. POST /whatsapp-templates/check
takes the same body and answers with the template (its variables worked out),
problems and warnings, saving nothing.
Opting out. A marketing template must let people stop: it needs a quick
reply marked "opt_out": true, such as "Stop promotions", whose taps come
back to the instance marked as opt-outs. A draft without one is saved with a
warning and refused when submitted.
Submitting answers with the template, now PENDING with its meta_id, and
warnings, for example when Meta filed it under another category. Meta's
refusal is 422 whatsapp_failed with its reason, for example a template of
that name and language already in the account (import it instead). The
instance asks Meta about templates under review every 5 minutes, and about the
others hourly, so status follows Meta's decision; an import asks at once.
Changing a template. A draft changes here only. A submitted one keeps its
name and language, and a change to what Meta reviews goes to Meta at once and
back to PENDING. Meta allows that once a day and ten times in 30 days for an
approved template, never to another category, and not at all while it is
reviewing (409 template_in_review). A new description changes nothing at
Meta.
Pictures, videos and documents go to Meta with the template: submitting
it, or changing one Meta has, fetches each sample from its address and
uploads it through the Meta app the token belongs to, so the
settings need app_id (503 whatsapp_unavailable
without it). The instance fetches only a public https:// address, never one
on its own network, and only a file Meta takes: a JPEG or PNG picture up to
5 MB, an MP4 or 3GPP video up to 16 MB, a PDF up to 16 MB. A sample it cannot
fetch or Meta would not take is 400 invalid_whatsapp_template, naming the
header or the card.
Writing with the agent. Send {"message": "A reminder three days before a trial ends, with their first name and plan", "language": "en_GB"} to
POST /whatsapp-templates/chat for a new draft (language defaults to en),
then {"message": "Make it shorter"} to
POST /whatsapp-templates/{name}/{language}/chat for each change. The agent
writes every kind of template, for whatever business and purpose the message
names, and assumes no business, brand or product it does not name (it writes
a variable such as {{business_name}} instead). It writes to Meta's rules,
chooses the category honestly and says why, gives every variable a sample,
and, when you give no picture, video, document or place, uses the
placeholders above and says so. Of the brand guide it takes
the tone of voice alone, for when you do not say how a template should sound.
A marketing message always gets its opt-out quick reply. A template the agent writes that
breaks Meta's rules goes back to it once with the problems; 422 draft_failed
means nothing was changed.
A message that asks for a change saves a draft as its next version. A
template Meta has is not changed by a message, since saving it sends it to
Meta for review again: the change comes back as a proposal (the template's
message, as content below), kept in the history, to save with PUT when you
choose. Its category stays, as Meta keeps it. A question changes nothing. The
agent does not work on a template Meta is reviewing (409 template_in_review).
Either way the answer is:
| Field | Meaning |
|---|---|
template |
The template as it is now |
step |
The step in its history (below), with the agent's reply and suggestions, and any proposal |
warnings |
What Meta's rules say about the template saved or proposed |
A turn takes up to a minute or two and finishes even if the caller hangs up;
the agent answers one message per template at a time (409 agent_busy), and
each message counts as one question against the instance's monthly allowance.
The history is {"items": [...], "working": {...}}, as for
email templates, where working is a message the agent is
still answering. Each step has seq, kind (chat, edit, created or
restore), message, reply, suggestions, changed (whether it changed
the template), restored_from, proposal, by and at. One step asked for
by its seq carries content: category, parameter_format, header,
body, footer, buttons, variables, auth, offer and cards after it. A change saved by hand is
a step too; a new description is not. Restoring works on a draft; for a
template Meta has, save the version's content with PUT, which sends it to
Meta. A template keeps its last 200 steps, and deleting it deletes them.
An import answers {"imported", "updated", "gone", "conflicts"}: templates
added, templates whose content or status changed, templates submitted from here
that the account no longer has (now DELETED), and drafts here with the name
and language of a template in the account, which are left as they are.
A test takes {"to": ["+44 7400 123456"], "values": {"first_name": "Ana"}, "media_url": "https://..."} and sends each number the template, with its
samples where values gives none. A number written without its country code is
read in default_country. media_url is the header's picture, video or
document, for a template with one; without it the header's sample is sent. A
one-time code sends its sample code, and a limited-time offer counts down
from the moment it is sent. Only an approved template can be sent
(409 template_not_approved). It answers {"sent": [{"to", "message_id", "status"}]}, where status is accepted, or held_for_quality_assessment
when Meta is pacing the template. An instance may send 20 test messages an
hour, and Meta charges for them as for any message.
WhatsApp broadcasts
A WhatsApp broadcast sends an approved template to a list of phone numbers: a CSV file whose rows are the people, one column their number and the others filling the template's variables. Each person gets their own message from your number, and Meta charges your account for each one it delivers.
| Method and path | Role | Does |
|---|---|---|
GET /whatsapp-broadcasts |
owner | Lists them, newest first, each with its counts |
POST /whatsapp-broadcasts |
owner | Creates a draft. Every field is optional. 201. |
GET /whatsapp-broadcasts/{id} |
owner | Shows one, with its counts, Meta's daily limit and, for a draft, its review |
PUT /whatsapp-broadcasts/{id} |
owner | Changes a draft (below). Answers like GET. |
DELETE /whatsapp-broadcasts/{id} |
owner | Deletes one that is not sending, with its list. 204. |
PUT /whatsapp-broadcasts/{id}/recipients |
owner | Replaces a draft's list: the body is the CSV file (Content-Type: text/csv), and ?file_name= names it |
GET /whatsapp-broadcasts/{id}/recipients |
owner | The recipients, ?status= (pending, sending, sent, failed, skipped, unknown), ?offset=, ?limit= (up to 500) |
GET /whatsapp-broadcasts/{id}/results.csv |
owner | What became of each recipient, with their row's cells, safe to open in a spreadsheet |
POST /whatsapp-broadcasts/{id}/send |
owner | Sends it: {"confirm_recipients": N, "confirm_consent": true} |
POST /whatsapp-broadcasts/{id}/pause, /resume, /cancel |
owner | Controls a send |
POST /whatsapp-broadcasts/{id}/resend-unknown |
owner | Sends the messages whose outcome is unknown again (below) |
GET /whatsapp-hand |
editor | The broadcasts being sent by hand, newest first |
POST /whatsapp-broadcasts/{id}/hand/next |
editor | The next person to message by hand, or the one already held |
POST /whatsapp-broadcasts/{id}/hand/{seq} |
editor | Records what became of a person's message, then hands out the next |
GET /whatsapp-broadcasts/{id}/hand/media |
editor | The message's picture, video or document, to attach |
A draft takes name, mode ("api", the default, to send through Meta,
or "hand" to send by hand — see Sending by hand),
template and language (an approved template's,
as in /whatsapp-templates/{name}/{language}, but not a one-time code's),
media_url (the header's picture, video or document at https://, for a
template with one; every message gets the same; a carousel's cards go with
the template's own), phone_column, consent_column, user_id_column,
column_map ({"first_name": "First Name"}) and fallbacks
({"first_name": "there"}, used where a cell is empty, since Meta refuses an
empty value). In column_map and fallbacks, "" removes a variable's
entry; a variable with no column takes its fallback in every message.
The list. A CSV file in UTF-8, with or without a byte-order mark,
separated by commas, semicolons or tabs, up to 10 MB, 50,000 rows and 50
columns. Uploading it guesses the columns: the numbers (phone, mobile,
whatsapp and the like, else a column of numbers), the consent (opt_in,
consent, whatsapp_opt_in), the user ID, and each variable's column by its
name, ignoring case and punctuation. Numbers are read as people write them;
one without its country code is read in the settings'
default_country, and every number is sent with + and its country code. A
row is skipped, with its reason, when it has no_number, an
invalid_number, a duplicate of an earlier row's number, a number on the
do-not-message list (suppressed), or, with a
consent column, a cell that does not say yes (no_consent; yes, true, 1, y,
x and a date count as yes).
The review is the draft's review: ready, problems that stop it from
being sent and warnings worth reading, will_send (the messages the send
would make), skips_at_send (rows skipped as the send starts, because they
depend on the template and the mapping: country_not_delivered for a US
number with a marketing template, which WhatsApp does not deliver there, and
empty_variable for an empty cell with no fallback), the template, each
variable's column, fallback and empty cells, the unused_columns, the
recipients' countries, the first three messages' samples
({"row", "to", "values"}), and the verified_name the consent names. The
broadcast's limit is Meta's daily messaging limit: daily_limit people in
a rolling 24 hours, shared by every number in your Meta business portfolio,
how many this instance messaged in the last 24 hours, and the days the
list takes at least.
Sending needs confirm_recipients to be the review's will_send and
confirm_consent to be true: your word that everyone on the list agreed to
receive WhatsApp messages from your business, which Meta requires. Who
confirmed it, and when, is kept as consent_by and consent_at, and in the
audit log. The send then sends one message a request, paced across the
instance at messages_per_second, and every message carries
biz_opaque_callback_data of <broadcast>:<row>. Each recipient becomes:
status |
Meaning |
|---|---|
sent |
Meta took it: message_id is its wamid. delivery is accepted, or held while Meta paces the template. |
failed |
Meta refused it for this person, with its error_code and error: not on WhatsApp (131026), Meta's cap on marketing to one person (131049), or the person turned off your marketing in WhatsApp (131050), which also puts the number on the do-not-message list |
unknown |
Meta did not answer, so whether it went is not known. It is not sent again on its own. |
skipped |
Never sent, with its reason |
Meta refusing a message for coming too fast, or failing for a moment, sends it
again a little later. The broadcast's status is sending, then
completed; or:
waiting, withwait_forandresume_at, when Meta's daily limit is reached (limit: it goes on once the 24-hour window has moved on) or Meta paused the template for low quality (template: it goes on when Meta lifts the pause, after 3 hours the first time and 6 the second). It goes on by itself;resumetries at once.paused, withpause_reason, for what needs a person: the token refused, the account restricted or without a payment method, the template gone, disabled or against policy, many messages refused in a row, or a restart.resumegoes on where it stopped.
Unknown messages may be sent again with resend-unknown from a paused,
waiting or completed broadcast, once they are two hours old (Meta's report on
one may come late): some people may get the message twice, and Meta charges
again. A 409 broadcast_not_ready says when none is old enough.
A finished broadcast's list is deleted 30 days after it ends (purged); its
counts are kept, and so is a record of every message Meta took, with its
reports and replies, in the WhatsApp database's
whatsapp_sends.
Sending by hand
Some numbers are not on the WhatsApp Business API, or you would rather send
from your own phone. A broadcast made with mode "hand" is worked through
by people instead: the panel steps a sender through the list one person at a
time, and each message goes out from that sender's own WhatsApp, through a
link that opens it with the person's number and the template's words already
filled in.
GET /whatsapp-hand lists the broadcasts being worked by hand, newest first.
The sub-paths below need only an editor — a sender takes one number at a
time, so no more than one person's message is ever in their hands — where the
broadcasts themselves, and the list, are the owner's.
A hand broadcast is made and sent as any other is: a draft, its CSV with the
list, then POST /whatsapp-broadcasts/{id}/send with
confirm_recipients and confirm_consent; its status becomes sending.
Nothing goes to Meta and no job runs, so its review leaves out the token,
the account and the verified name it would otherwise need, and the view
carries no Meta daily limit — the sender's own allowance stands in (below).
cancel finishes it early.
POST /whatsapp-broadcasts/{id}/hand/next hands a sender their next person:
row (their line in the list), to (their number), digits (the number as
the links want it), text (the message as the template fills it), links
(app, web or wa_me, to open it on this device) and, for a template with
a picture, video or document, media and media_url. Calling it again hands
back the person already held rather than a second; a person stays held for 15
minutes, so a restart or a long gap hands them to someone else.
POST /whatsapp-broadcasts/{id}/hand/{seq} records what became of a
person's message, then hands out the next. Its body is {"outcome": ...}:
outcome |
What became of it |
|---|---|
sent |
it went: sent, and a record of it is added to the WhatsApp database's whatsapp_sends |
skipped |
put by without a message: skipped, reason by_hand |
not_on_whatsapp |
the number is not on WhatsApp: skipped, reason not_on_whatsapp |
stopped |
they asked not to be messaged: skipped, reason asked_to_stop, and the number is put on the do-not-message list |
When nothing is left, the reply is done. A 409 hand_row_not_held means the
person is not this sender's to settle, and a 409 broadcast_not_ready that
the broadcast is not a hand broadcast that is sending.
GET /whatsapp-broadcasts/{id}/hand/media fetches that picture, video or
document as the file itself, to save and attach in WhatsApp; a 400 invalid_broadcast says the template carries none.
A sender may send hand_daily_limit messages by hand in any 24 hours — 100
unless the deployment sets it, and 0 for no limit. That is the instance's
configuration, not a setting the API changes. Each reply's sent is how many
that sender has sent in the last 24 hours, daily_limit their allowance and
unlimited when there is none; limit_at is when a full count frees up. A
429 hand_limit_reached says the allowance is spent.
WhatsApp reports
Meta reports what became of each message, what people send back, and changes
to templates and the account, to /webhooks/whatsapp on the instance. Each
call is signed with the Meta app's secret (X-Hub-Signature-256), and one
that is not is refused (401); an instance with no app secret takes none
(404). A call is answered 200 once it is recorded, otherwise Meta sends it
again, for up to 7 days; a call that arrives twice changes nothing twice.
Connecting. POST /whatsapp-settings/webhook sets the address in Meta. It
needs the app's secret, and the instance's public address (public_url in the
email settings) at https://, which Meta requires. Meta
checks the address as it connects, with the verify token the instance made.
mode |
For | What comes |
|---|---|---|
app |
A Meta app made for askDBLM (needs app_id) |
Everything: the address becomes the app's own webhook, and the app is subscribed to the account |
override |
An app shared with other tools | This account's message reports only. Meta sends template and account events to the app's own webhook whatever, so template statuses come from the instance's checks of Meta instead (every 5 minutes while one is under review, hourly otherwise). |
The settings then show webhook_mode, webhook_endpoint (where Meta
reports to; connect again if the public address moved) and
webhook_verify_token, with webhook_url, for setting it up by hand in the
app's dashboard instead. DELETE /whatsapp-settings/webhook takes the address
back out of Meta.
What a report does:
- A message's status lands on its recipient:
deliverybecomessent,deliveredorread(withdelivery_at), ranked so a late or repeated report never takes back a later one; the counts gaindelivered(read included) andread. A message whose send had no answer (unknown) is found by the<broadcast>:<row>it carried and becomessent. A failure after the send makes the recipientfailedwith Meta'serror_codeanderror, such as 131049 or 131026; 131050 also puts the number on the do-not-message list. The recipient keeps Meta'spricing_categoryand the person'sbsuid(business-scoped user ID) when Meta gives them. - A reply to a broadcast's message sets the recipient's
replied_at, and the counts gainreplied. A reply that is the message's answer is matched by it; one without is matched to the latest message sent to the number in the three days before. - A stop: a tap on a marketing template's opt-out button, a reply that is a stop word (the whole reply, ignoring case and punctuation), or the person turning off your marketing in WhatsApp's settings, puts them on the do-not-message list. Turning it back on in WhatsApp takes off an entry made that way, and no other.
- A template's status (
APPROVED,REJECTEDwith its reason,PAUSED,DISABLED...), quality and, once Meta has changed it, category, are brought up to date; a change made in WhatsApp Manager is read again. A broadcast waiting for a paused template goes on once it is approved again. - The account's messaging limit or number quality changing is read again at once.
Do-not-message list
Numbers no WhatsApp broadcast sends to: skipped when a list is read, and again
as each message is about to go. A number is added when Meta answers a send
with 131050 (the person turned off your marketing in WhatsApp), and, with
reports connected, when a person taps the opt-out button,
replies a stop word, or stops your marketing in WhatsApp. Owners add and remove
numbers by hand. A person Meta knows only by their business-scoped user ID,
with no number the instance has sent to, is kept by that ID. It is separate from the email
do-not-email list: they are separate consents.
| Method and path | Role | Does |
|---|---|---|
GET /whatsapp-suppressions |
owner | The list, newest first: ?search= (digits of a number), ?offset=, ?limit=. Each has phone (or a business-scoped user ID), reason (manual, stopped_marketing from a send, button, keyword, whatsapp_setting), broadcast_id, created_by and created_at. |
POST /whatsapp-suppressions |
owner | {"phones": ["+44 7400 123456", "07400 654321"]}, up to 10,000, read like a list's numbers. Answers {"added": N}; numbers already on it are left as they are. |
DELETE /whatsapp-suppressions/{phone} |
owner | Takes a number off (URL-encode the +). 204. |
WhatsApp database
Everything WhatsApp keeps is in a database of its own, apart from the
email database: the templates and their history, the
broadcasts and their recipient lists, the do-not-message list, and a record of
every message sent and whom it went to. On an instance whose state is in
Postgres it is another database on the same server, named after the state
database with _whatsapp added (dblm_whatsapp on a hosted instance);
otherwise it is the SQLite file ~/.dblm/whatsapp.db. DBLM_WHATSAPP_DSN
names a database made by hand instead, with DBLM_WHATSAPP_READER_DSN its
read-only login, for a server where the state user may not create databases
or roles.
askDBLM offers it to the query side as a connection it keeps,
called whatsapp ("managed": "whatsapp"), read only, made the first time
WhatsApp is used. Index it, generate knowledge for it and add it to a
module, and questions about who was messaged what go through
questions, the public API and fassads. Put the email
database in the same module, and one question covers both channels through
the people's user IDs ("people who got the trial email but never read the
WhatsApp reminder"). It comes with knowledge already written, pinned so that
generating knowledge keeps it, and indexing it replaces numbers in the sample
rows with +15550000001 and the like and leaves out the cells of uploaded
lists and Meta's IDs for people and messages, so none of them reaches the
model. It is in no module until you add it.
| Table | Holds |
|---|---|
whatsapp_sends |
One row for every message Meta took, kept for good: the broadcast and template (with their names, language and category when it was sent), the contact, the number and its country, sent_at, Meta's wamid, its latest delivery (accepted, held, sent, delivered, read or failed, with error_code and error) and delivery_at, pricing_category and pricing_type, and replied_at when the person answered. A template sent again adds rows. |
whatsapp_contacts |
The people messaged: a user_id when a list gave one, else a number. A person known by number takes the user ID the first time a list gives one with that number; two users who share a number stay two contacts. |
whatsapp_templates, whatsapp_template_history |
Templates and their versions |
whatsapp_broadcasts, whatsapp_recipients |
Broadcasts and their lists; a list is deleted 30 days after its broadcast ends, the sends stay |
whatsapp_suppressions |
The do-not-message list |
GET /whatsapp-settings reports it as database: backend, location,
connection (once made), ready, and a problem when something stops it, as
GET /email-settings does for the email database.
| Method and path | Role | Does |
|---|---|---|
POST /whatsapp-contacts/forget |
owner | Removes a person, for an erasure request: {"phone": "+44 7400 123456"} (a number without its country code is read in default_country) or {"user_id": "4812"}. Deletes their contact, every message sent to them and their rows in recipient lists not yet deleted. Answers {"contacts": 1, "sends": 3, "recipients": 1}. It does not touch the do-not-message list: a number on it stays on it. For a person emailed too, forget them in the email database as well. |
Survey settings
The link base used when a survey link goes out, and the message a closed form
shows. On a hosted instance the base is the instance's own address; set
public_url when links should point somewhere else.
| Method and path | Role | Does |
|---|---|---|
GET /survey-settings |
owner | public_url, closed_message, and database, reported as the survey database |
PUT /survey-settings |
owner | Changes public_url or closed_message |
{ "public_url": "https://surveys.example.com", "closed_message": "This survey has closed." }
Both fields are optional; send "" to clear one.
Survey forms
A form is one whole HTML page, written by the form-builder agent, with every
question it asks declared beside it as typed fields. Build one by talking to
the agent, or send the page and its fields yourself. Once a form exists, send
it to a list through a broadcast, or mint one person's
link directly. Either way the person answers at a public page, GET /s/{token},
and the page's form submits the answers back to POST /s/{token}. The page
needs no token of its own: the link carries one.
| Method and path | Role | Does |
|---|---|---|
GET /survey-forms |
viewer | Lists forms, {"items": [...], "total": N} |
POST /survey-forms |
editor | Creates a form from the page and fields you send. 201 with the form |
GET /survey-forms/{name} |
viewer | One form |
PUT /survey-forms/{name} |
editor | Replaces its description, html, fields, one_per_person or closed_message |
DELETE /survey-forms/{name} |
editor | Removes it. 204 |
POST /survey-forms/{name}/closed |
editor | Opens or closes it: {"closed": true} |
GET /survey-forms/{name}/history |
viewer | Its versions |
GET /survey-forms/{name}/history/{seq} |
viewer | One version |
POST /survey-forms/{name}/history/{seq}/restore |
editor | Restores that version as a new one |
GET /survey-forms/{name}/preview |
viewer | Renders the form as the page would |
POST /survey-forms/{name}/link |
owner | Mints one person's link: {"user_id": "4812", "name": "Ana"}. Answers {"url", "token", "user_id", "form"} |
POST /survey-forms/chat |
editor | Talks to the agent to build a new form. 201 |
POST /survey-forms/{name}/chat |
editor | The agent, with an existing form |
POST /survey-forms/check |
editor | Checks a page and its fields without saving: {"html": "...", "fields": [...]} |
A form body names it, describes it, and holds its page and its fields.
one_per_person allows one submission per link — a person who has already
finished such a survey is refused a second time with 409 survey_already_answered; closed makes it take no new answers.
html is the whole page the agent wrote — markup, <style> and <script>
alike; a script the page carries runs for the person answering. Leave the
page's colours and fonts to the variables --bg, --surface, --text,
--muted, --border, --accent, --accent-hi, --danger, --radius,
--heading-font and --body-font: the instance's look fills them in. A page
that is not already a whole document is wrapped in a bare one. At most 200,000
characters.
A form that a broadcast's issued links have gone out on, or that a broadcast is
still draft or open on, cannot be renamed or deleted: it answers
409 survey_form_in_use. Delete the broadcast first to free the form.
{
"name": "onboarding",
"description": "A short onboarding check-in",
"one_per_person": true,
"closed": false,
"closed_message": "This survey has closed.",
"html": "<form><h1>About you</h1><textarea name=\"q1\" required></textarea><button type=\"submit\">Send</button></form>",
"fields": [
{ "id": "q1", "kind": "long_text", "label": "What did you think?", "required": true }
]
}
A field has an id (unique within the form), a kind, a label, and
optionally required, help, options (for a choice), min and max (for a
rating, scale or number), max_length (for text) and min_label/max_label
(for a scale). A text field may also demand more of the answer: min_length is
the fewest characters it will take, and pattern a regular expression the text
must match. A multi-choice field may demand min_choices. A date may be pinned
between min_date and max_date. The kinds are short_text, long_text,
single_choice, multi_choice, rating, scale, number, date, email
and phone. A form holds at most 200 fields; a field at most 50 options. The
names new, chat and check are reserved.
Every control that collects an answer must carry name="<field id>", the id
of the field it answers; askDBLM reads the answers from those controls when the
page's form submits. Put the questions inside a <form>, so that submitting it
sends the survey.
Talking to the agent:
{ "message": "a two-screen onboarding survey: what they thought, and how likely they are to recommend us" }
The answer is {"form": {...}, "entry": {...}} — the form as it now stands,
and the turn just taken.
Survey broadcasts
A broadcast sends one form to a list of people, each getting their own link. It
goes: create it naming the form, upload the list as CSV, look over the review,
then issue it. After issuing, every row has a token. Read the links with
GET /survey-broadcasts/{id}/links or export them with /links.csv, and read
the answers gathered so far with /results.csv.
| Method and path | Role | Does |
|---|---|---|
GET /survey-broadcasts |
owner | Lists them, newest first |
POST /survey-broadcasts |
owner | Creates one. 201 |
GET /survey-broadcasts/{id} |
owner | One, with a review while it is a draft |
PUT /survey-broadcasts/{id} |
owner | Changes its name, user_id_column or name_column |
DELETE /survey-broadcasts/{id} |
owner | Removes the draft and its list. 204 |
PUT /survey-broadcasts/{id}/recipients |
owner | Uploads the list: raw CSV in the body, the file name in ?file_name= or the X-File-Name header |
POST /survey-broadcasts/{id}/issue |
owner | Mints a link for every row. {"confirm_recipients": 500} must match the current count |
POST /survey-broadcasts/{id}/close |
owner | Closes it; no more answers. Closing the last open broadcast on a form closes the form too |
GET /survey-broadcasts/{id}/links |
owner | The links, filter by ?status= and page with ?offset=/?limit= |
GET /survey-broadcasts/{id}/links.csv |
owner | Every link as CSV |
GET /survey-broadcasts/{id}/results.csv |
owner | Every answer gathered as CSV |
{ "name": "March onboarding", "form": "onboarding", "user_id_column": "user_id", "name_column": "name" }
user_id_column and name_column say which column of the uploaded list holds
the person's ID and name. While the broadcast is a draft, GET carries a
review with ready, will_issue, skipped, problems and warnings, the
columns the list has (and which are empty), the unused_columns, and a few
samples with the link each row would get and why a row is skipped.
A broadcast is draft, open or closed. Each link is not_started, started,
completed or skipped; a row with no user ID, or a repeat of one already in
the list, is skipped and kept so the review can show it.
Survey responses
| Method and path | Role | Does |
|---|---|---|
GET /survey-responses |
owner | Answers every form gathered, filter by ?form=, ?broadcast= or ?status=, paged with ?offset=/?limit= |
GET /survey-responses/{user_id} |
owner | One person's responses |
POST /survey-responses/forget |
owner | Erases a person, for an erasure request: {"user_id": "4812"}. Answers {"user_id": "4812", "forgotten": 3} |
A response is in_progress until the person sends it, then completed.
Survey database
Everything surveys keep is in a database of its own, apart from the
email database and the WhatsApp database.
On an instance whose state is in Postgres it is another database on the same
server, named after the state database with _survey added (dblm_survey on a
hosted instance); otherwise it is the SQLite file ~/.dblm/survey.db.
DBLM_SURVEY_DSN names a database made by hand instead, with
DBLM_SURVEY_READER_DSN its read-only login.
askDBLM offers it to the query side as a connection it keeps,
called survey ("managed": "survey"), read only, made at startup, like the
email and WhatsApp connections. Index it, generate knowledge for it and add it to a module,
and questions about who answered what go through
questions, the public API and fassads. Put it in the
same module as the email and WhatsApp databases, and one question covers all
three through the people's user IDs. It comes with knowledge already written,
pinned so that generating knowledge keeps it, and indexing it leaves the cells
that identify a person out, so none of them reaches the model. It is in no
module until you add it.
| Table | Holds |
|---|---|
survey_forms, survey_form_history |
Forms and their versions |
survey_broadcasts |
Broadcasts: the form it sends (with its page, fields and the brand look captured when it was issued), the column map and the counts |
survey_links |
One row a person: their user_id, name, the row's data, their token and status |
survey_responses, survey_answers |
Each person's response, and one row for every field they answered |
GET /survey-settings reports it as database: backend, location,
connection (once made), ready, and a problem when something stops it, as
GET /email-settings does for the email database.
API keys
API keys are what software uses to call the askDBLM API.
| Method and path | Role | Does |
|---|---|---|
GET /apikeys |
owner | Lists keys, never with their secret |
POST /apikeys |
owner | Issues one. 201 with {"key": {...}, "secret": "dblm_…"}. The secret is returned only here. |
PATCH /apikeys/{id} |
owner | Changes its name, scope or expires_in_days. The secret stays the same. |
DELETE /apikeys/{id} |
owner | Revokes it. 204. |
The body when issuing one:
{
"name": "website",
"scope": { "fassads": ["top_genres"], "query": true, "targets": ["module:chinook"] },
"expires_in_days": 90
}
scope.fassads lists fassad names, or ["*"] for every fassad.
scope.targets says which modules the key's questions may go to:
module:<name>, or ["*"] for any module, and it applies when query is
true. A connection or connection/database target is refused with
400 invalid_api_key: questions are asked of modules only. expires_in_days is 1 to 3650, or 0 for never. A key as
returned has id, prefix, name, scope, created_by, created_at,
expires_at, last_used_at and last_used_ip.
People
Who may use a self-hosted instance's control panel, and in which role. In a
hosted instance people are managed in askDBLM, and these endpoints answer
409 managed_by_portal.
| Method and path | Role | Does |
|---|---|---|
GET /operators |
owner | Everybody, with name, role, pending (invited, not yet joined), created_at and last_login_at |
POST /operators |
owner | Invites someone: {"name": "ben", "role": "editor"}. 201 with a one-time token for them to set their password; it expires in 7 days. |
PUT /operators/{name}/role |
owner | Changes a role: {"role": "viewer"} |
DELETE /operators/{name} |
owner | Removes someone. 204. Their tokens stop working at once. |
The last owner cannot be removed or demoted.
Audit log
| Method and path | Role | Does |
|---|---|---|
GET /audit |
owner | Recent entries, newest first |
Query parameters: user, connection, operation (for example token,
nlq or fassad), date_from and date_to (YYYY-MM-DD), errors_only=1,
limit (up to 1000; 100 by default) and offset. It answers with items
(each with ts, user, op, connection, sql, row_count, error,
duration_ms and remote_addr), has_more, and a summary of the page.
A token's change is filed under its owner, with op set to token and
connection naming the call and the token: POST /api/admin/connections (token setup-script).
Access tokens
| Method and path | Role | Does |
|---|---|---|
GET /tokens |
viewer | Your tokens. Owners get everybody's. Each has id, prefix, name, owner, role, created_at, expires_at, last_used_at and last_used_ip. |
DELETE /tokens/{id} |
viewer | Revokes one of yours; owners may revoke anybody's. 204. |
Tokens are created on the Access tokens page. A token cannot create one
(403 tokens_cannot_create_tokens).
6. Recipes
Each recipe assumes ASKDBLM_ADMIN and ASKDBLM_TOKEN are set, as in the
quickstart.
Set up an instance from a script
This adds a connection, checks it, indexes it, waits for the index, and has knowledge written. It needs an owner token.
#!/bin/sh
# setup.sh: connect, index and document one database.
set -eu
auth="Authorization: Bearer $ASKDBLM_TOKEN"
json="Content-Type: application/json"
# 1. The connection. The password comes from your secret store, not the script.
curl -sS --fail-with-body -H "$auth" -H "$json" "$ASKDBLM_ADMIN/connections" \
-d "$(jq -n --arg pw "$WAREHOUSE_PASSWORD" \
'{name: "warehouse", driver: "postgres", host: "db.acme.internal", username: "readonly", password: $pw}')" >/dev/null
# 2. Does it connect?
curl -sS -X POST -H "$auth" "$ASKDBLM_ADMIN/connections/warehouse/test" | jq -e '.ok' >/dev/null ||
{ echo "warehouse does not connect" >&2; exit 1; }
# 3. Index one database, and wait for it.
job=$(curl -sS --fail-with-body -X POST -H "$auth" -H "$json" \
"$ASKDBLM_ADMIN/databases/warehouse/index" -d '{"databases": ["sales"]}' | jq -r '.items[0].id')
until status=$(curl -sS -H "$auth" "$ASKDBLM_ADMIN/jobs/$job" | jq -r '.status') &&
[ "$status" != queued ] && [ "$status" != running ]; do sleep 2; done
[ "$status" = completed ] || { echo "indexing $status" >&2; exit 1; }
# 4. Have knowledge written for it. This runs in the background for a few minutes.
curl -sS --fail-with-body -X POST -H "$auth" "$ASKDBLM_ADMIN/knowledge/warehouse/sales/generate" | jq -r '.id'
Keep a fassad in version control
Store each fassad as a JSON file in your repository, and deploy it with PUT,
or with POST the first time. It needs an editor token.
# deploy-fassad.sh fassads/top_genres.json
set -eu
name=$(jq -r '.name' "$1")
code=$(curl -sS -o /dev/null -w '%{http_code}' -X PUT \
-H "Authorization: Bearer $ASKDBLM_TOKEN" -H "Content-Type: application/json" \
"$ASKDBLM_ADMIN/fassads/$name" --data-binary "@$1")
if [ "$code" = 404 ]; then
curl -sS --fail-with-body -H "Authorization: Bearer $ASKDBLM_TOKEN" -H "Content-Type: application/json" \
"$ASKDBLM_ADMIN/fassads" --data-binary "@$1" >/dev/null
elif [ "$code" != 200 ]; then
echo "deploying $name failed: HTTP $code" >&2; exit 1
fi
Issue an API key for a new integration
An owner token can provision the key an integration needs. The secret is in this one response, so hand it straight to your secret store:
curl -sS --fail-with-body -H "Authorization: Bearer $ASKDBLM_TOKEN" -H "Content-Type: application/json" \
"$ASKDBLM_ADMIN/apikeys" \
-d '{"name": "website", "scope": {"fassads": ["top_genres"]}, "expires_in_days": 90}' | jq -r '.secret'
Send a broadcast from a script
With an owner token, send this month's newsletter to a CSV exported from your own systems:
B=https://acme.askdblm.com/api/admin
AUTH="Authorization: Bearer $ASKDBLM_TOKEN"
# A draft for the template, then the list
id=$(curl -s -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"name": "October newsletter", "template": "newsletter"}' $B/broadcasts | jq -r .id)
curl -s -H "$AUTH" -H 'Content-Type: text/csv' --data-binary @customers.csv \
"$B/broadcasts/$id/recipients?file_name=customers.csv" | jq '.counts, .review.problems, .review.warnings'
# Send to exactly as many people as the review counted
n=$(curl -s -H "$AUTH" $B/broadcasts/$id | jq .counts.pending)
curl -s -H "$AUTH" -H 'Content-Type: application/json' \
-d "{\"confirm_recipients\": $n}" $B/broadcasts/$id/send | jq .status
Then follow GET /broadcasts/{id} until its status is completed, or
paused, with the pause_reason to act on.
Rotate a connection's password
curl -sS --fail-with-body -X PUT -H "Authorization: Bearer $ASKDBLM_TOKEN" -H "Content-Type: application/json" \
"$ASKDBLM_ADMIN/connections/warehouse" \
-d "$(jq -n --arg pw "$NEW_PASSWORD" '{host: "db.acme.internal", port: 5432, username: "readonly", password: $pw}')"
A new password has to come with the host, port and username. Then test the connection, as in the first recipe.
Send a survey to a list of people
Put a CSV of people behind a form and mint one link each, for your own system
to send. It needs an owner token. (The form itself can be built by hand with
POST /survey-forms, or by the agent through POST /survey-forms/chat.)
#!/bin/sh
# survey.sh: make a form, upload a list of people, and issue their links.
set -eu
auth="Authorization: Bearer $ASKDBLM_TOKEN"
json="Content-Type: application/json"
# 1. The form: a screen per step, each field with an id, a kind and a label.
curl -sS --fail-with-body -H "$auth" -H "$json" \
"$ASKDBLM_ADMIN/survey-forms" -d @form.json | jq -r '.name'
# 2. A broadcast that sends the form, one link per person.
id=$(curl -sS --fail-with-body -H "$auth" -H "$json" \
"$ASKDBLM_ADMIN/survey-broadcasts" \
-d '{"name": "March onboarding", "form": "onboarding"}' | jq -r '.id')
# 3. The list of people, as a CSV with a column of ids (and, if you like, names).
curl -sS --fail-with-body -X PUT -H "$auth" -H "Content-Type: text/csv" \
--data-binary @people.csv \
"$ASKDBLM_ADMIN/survey-broadcasts/$id/recipients?file_name=people.csv" \
| jq '{ready: .review.ready, will_issue: .review.will_issue, skipped: .review.skipped, problems: .review.problems}'
# 4. Issue a link to exactly as many people as the review counted.
n=$(curl -sS -H "$auth" "$ASKDBLM_ADMIN/survey-broadcasts/$id" | jq -r '.review.will_issue')
curl -sS --fail-with-body -X POST -H "$auth" -H "$json" \
"$ASKDBLM_ADMIN/survey-broadcasts/$id/issue" -d "{\"confirm_recipients\": $n}" \
| jq '{status, total}'
# 5. Take the links and send them yourself; read the answers the same way.
curl -sS --fail-with-body -H "$auth" \
"$ASKDBLM_ADMIN/survey-broadcasts/$id/links.csv" -o links.csv
curl -sS --fail-with-body -H "$auth" \
"$ASKDBLM_ADMIN/survey-broadcasts/$id/results.csv" -o results.csv
Each link is https://<survey host>/s/<token>; the host comes from the
instance's survey settings. A person's answers are saved
screen by screen, so a form left half-finished is resumed on the next visit,
and one person keeps one submission. For a single person,
POST /survey-forms/{name}/link with {"user_id": "…"} returns their url
too. The people and their answers are read through the read-only survey
connection, and a person can be forgotten with
POST /survey-responses/forget (see Survey responses).
7. Security
A personal access token is as powerful as its role. An owner token can:
- read every setting;
- change connections and their credentials, including pointing a connection at a different server;
- change which AI model answers questions, and so where your questions and schemas are sent;
- issue API keys that keep working after the token is revoked;
- change who may use the instance.
Treat an owner token like the password to the whole instance.
- Give a token the smallest role that does the job. Deploying fassads needs editor. Reading the configuration needs viewer.
- Give each script its own token, with a short expiry, so that revoking one stops one thing.
- Keep tokens in a secret manager or your CI system's secret store, never in a repository, a web page or an app you ship. The API sends no CORS headers, so browsers will not let a web page call it anyway.
- Revoke a token at once if it may have leaked, on the Access tokens page. Then check the Audit page for what was done with it, including any API keys it issued.
- When somebody leaves, their tokens stop working with their access. You don't need to hunt them down.
Nothing in this API can write to your databases. Every query the instance runs against them is checked to be read-only, and fassads and widgets whose queries would change data are refused.
8. Troubleshooting
401 invalid_token. The token was revoked, or it was copied incompletely.
A token is one unbroken string that starts with dblm_pat_.
401 access_revoked. The token's owner no longer has access to the
instance. Tokens stop working when their owner does.
403 insufficient_role with a token you made as an owner. The token acts
at the lesser of its own role and yours. If your role was lowered, so was your
tokens'.
403 tokens_cannot_create_tokens. Create tokens on the Access tokens page,
signed in as yourself.
409 managed_by_portal. In a hosted instance, invite and remove people in
askDBLM rather than through this API.
409 jobs_running. Wait for the job working on that database (see
GET /jobs), or cancel it, then try again.
503 portal_unavailable. A hosted instance could not reach askDBLM to
confirm the token's owner. Tokens keep working through a short outage;
retry in a minute.
My change seems to be ignored. Fields an endpoint does not use are ignored rather than refused. Check the field names against this guide.
9. Compatibility
The configuration API is the API the control panel is built on, so it grows with the panel. Endpoints and fields documented here keep working. New endpoints and new response fields may appear at any time, so ignore fields you don't recognise. A change that would break a documented call will be listed here before it ships.
Changes
| Date | Change |
|---|---|
| 2026-10-12 | Surveys: build multi-screen forms with the agent (/survey-forms, /survey-forms/chat), send one to a list through /survey-broadcasts with a per-person link (/s/{token}), read and forget answers through /survey-responses, and set the link base through /survey-settings. The survey database is offered as a read-only connection, survey. |
| 2026-09-30 | Personal access tokens open the configuration API to scripts. |
| 2026-10-09 | WhatsApp broadcasts can be sent by hand: mode hand works a list through by hand, with GET /whatsapp-hand, the hand sub-paths (editor) that take one person at a time and record sent, skipped, not_on_whatsapp or stopped, and the deployment's hand_daily_limit. |
| 2026-10-05 | Email settings and email templates: write React Email templates with AI, preview them and send tests through your own Resend account. |
| 2026-10-06 | Broadcasts and the do-not-email list: send a template to a CSV of recipients, with one-click unsubscribe. Email settings gain public_url and requests_per_second. |
| 2026-10-06 | Bounce and complaint reports from Resend: /email-settings/webhook, webhook_secret, each recipient's delivery, and delivered, bounced and complained counts. |
| 2026-10-06 | The template agent and the brand guide: /email-templates/chat, a template's history of versions and restoring one, and /email-brand with analyze. A template may no longer be called chat. |
| 2026-10-07 | Widgets can pin an answer merged from several databases: steps, refreshed by running each step and the join of their results. |
| 2026-10-08 | Widgets chart every row: chart_data carries the chart's columns of all the rows a run returned, and row_count is now how many it returned rather than how many data keeps. |
| 2026-10-08 | Charts in full: answers' results and widgets gain visual (several measures, a split, stacks, a second axis, area, heatmap and number); a widget takes visual on create and change, a chart alone is fitted to the rows at the next refresh, and a chart needing columns the saved rows lack refreshes the widget. |
| 2026-10-08 | POST /query with chart: the model names the chart in the same reply as the query, and the summary is written knowing what it shows. |
| 2026-10-08 | Charts group time: x.grain and aggregate, and chart_data reduced by "group". A widget's chart sent back as it was returned is kept whole. |
| 2026-10-08 | A number chart may combine every row: aggregate with no x. |
| 2026-10-06 | The email database: everything Email keeps moves into a database of its own, offered as a read-only connection, email, that askDBLM keeps (managed, 409 connection_managed), with a record of every email sent and whom it went to. Broadcasts gain user_id_column and template_id; email settings gain database; POST /email-contacts/forget. |
| 2026-10-08 | The WhatsApp database: everything WhatsApp keeps moves into a database of its own, offered as a read-only connection, whatsapp, that askDBLM keeps, with a record of every message sent (whatsapp_sends) and whom it went to (whatsapp_contacts); WhatsApp settings gain database; POST /whatsapp-contacts/forget. |
| 2026-10-08 | The WhatsApp template agent: /whatsapp-templates/chat, a template's history of versions with restoring one, and a change to a template Meta has proposed rather than saved. |
| 2026-10-08 | Every kind of WhatsApp template: picture, video, document and place headers (header.example, header.location, with placeholders for testing), one-time codes (AUTHENTICATION, auth), limited-time offers (offer) and carousels (cards), written by hand or by the agent for any business, and sent to Meta with their samples uploaded through the app (app_id). |
| 2026-10-07 | WhatsApp reports: /webhooks/whatsapp and /whatsapp-settings/webhook; recipients gain delivery reports, delivery_at, replied_at, bsuid and pricing_category; counts gain delivered, read and replied; settings gain stop_words and the webhook's fields; the do-not-message list fills from opt-out taps, stop words and WhatsApp's own setting. |
| 2026-10-07 | WhatsApp broadcasts and the do-not-message list: send an approved template to a CSV of phone numbers, with consent confirmed, Meta's daily limit waited out, and every message's outcome. WhatsApp settings gain messages_per_second. |
| 2026-10-07 | WhatsApp settings and templates: connect your own WhatsApp Business account, write templates checked against Meta's rules, submit them for review, import the account's templates, and send tests. |
| 2026-10-06 | draft_failed and email_send_failed are 422 rather than 502, as is the new brand_failed: a proxy in front of a hosted instance replaced a 502's body, and its message with it. The codes are unchanged. |