API and agents
Errors
Read the error answer a failed API call returns, and know what to do about the codes a script or an agent meets most often.
A failed request answers with an HTTP error status and a JSON body that names what went wrong. Try it with a query on a dataset that does not exist:
curl
curl -s -w "\n%{http_code}\n" http://localhost:3000/api/query \ -H "Authorization: Bearer <YOUR_TOKEN>" \ -H "Content-Type: application/json" \ -d '{"sql": "SELECT * FROM no_such_dataset"}'-w "\n%{http_code}\n"prints the status after the body.<YOUR_TOKEN>is your API token. The API quickstart shows how to create one.
{"error":{"code":"sql_error","message":"The query refers to an unknown table.","position":{"line":1,"column":15}}}400JavaScript
const response = await fetch("http://localhost:3000/api/query", { method: "POST", headers: { Authorization: "Bearer <YOUR_TOKEN>", "Content-Type": "application/json", }, body: JSON.stringify({ sql: "SELECT * FROM no_such_dataset" }),});const { error } = await response.json();console.log(response.status, error.code, error.message);Run it with Node.js 24 or newer: node error.mjs.
400 sql_error The query refers to an unknown table.What an error answer holds
Section titled “What an error answer holds”Every error answer an operation gives holds one error object. These two fields are always in it:
| Field | What it holds |
|---|---|
code | A stable name for what went wrong, such as sql_error. |
message | A sentence for a person. It often names the detail of this request, such as a dataset, a limit or the setting that changes it. |
Branch on code, not on the message or the status. One status covers many
codes: 400 answers both sql_error and invalid_request.
Some codes add a field beside the two:
| Field | Comes with | What it holds |
|---|---|---|
position | sql_error, when the engine reports where | The line and column of the problem in your SQL, counted from 1. |
retryAfter | rate_limited from sign-in or a password check, and authentication_busy | The seconds to wait before you try again. The Retry-After header carries the same number. |
retryAt | rate_limited from sign-in or a password check | The time you may try again. |
rule | rule_refused | The instance rule that refused you: tokens.create, publishing.publish, connections.create or imports.links. |
setting | basemap_not_offered | The basemap switch that refused it, such as basemaps.satellite. |
issues | geometry_choice_invalid, and invalid_request for a tool’s arguments over MCP | Each reason the request fails: a code and a message for a geometry choice, a message and the argument’s path for a tool call. |
What to do about common codes
Section titled “What to do about common codes”Find the code in these tables, then do what its row says. The error catalogue lists every other code with its meaning, and Limits gives the code each limit answers with.
Agents can read the same list from the API description:
x-error-codes maps each code to its meaning.
Sign-in and permission
Section titled “Sign-in and permission”| Code | Status | What happened | What to do |
|---|---|---|---|
authentication_required | 401 | The request has no token, or one Dezfil doesn’t accept: mistyped, revoked, or its account deactivated. | Send the header Authorization: Bearer <YOUR_TOKEN>. If it still fails, create a new token. |
token_expired | 401 | The token reached the expiry it was created with. | Create a new token and use it instead. |
forbidden | 403 | The operation is for admins, you can view the item but not change it, or a request signed in with the session cookie lacks the instance’s Origin. Nothing changed. | Use an admin’s token for admin operations. For an item you can’t change, see Who may do what; for the Origin, Sign in. |
rule_refused | 403 | An instance rule leaves you out, such as Who may publish. The rule field names it. Nothing changed. | Ask an admin to change the rule in Settings. |
not_found | 404, or 410 for an expired query download | The item does not exist, or your account cannot reach it. Both answer the same. | Check the id. A list call, such as GET /api/maps, shows what you reach. |
Requests and edits
Section titled “Requests and edits”| Code | Status | What happened | What to do |
|---|---|---|---|
invalid_request | 400, in a few cases 404, 409, 410 or 422 | The request is malformed, or a field fails validation. A change to a map or workflow that sends no edit revision, or a malformed one, answers this too. | Fix what the message names. Read a map or workflow before you change it, and send its editRevision in the X-Edit-Revision header, or over MCP as the expected_revision argument. |
method_not_allowed | 405 | The path exists, but not with this method. | Use a method from the Allow header. |
stale_revision | 409 | Someone changed the item after you read it. Your change was not applied. | Read the item again, then redo your change on what you find. |
name_taken | 409, or 400 when you save a workflow step | A dataset of that name exists already. | Choose another name. |
run_in_flight | 409 | The workflow is already running, and a workflow runs one run at a time. | Wait for the run to finish, or cancel it, then start again. |
refresh_in_flight | 409 | A refresh of this dataset is already running. | Retry when it finishes. |
request_body_too_large | 413 | The body is over the operation’s byte limit. | Send a smaller body. The operation reference gives each operation’s limit. |
upload_too_large | 413 | The file is over the instance’s largest upload, or a picture is over its fixed 10 MB. | Send a smaller file, or ask an admin to raise the limit. A picture’s limit is fixed. See Limits. |
unsupported_image | 415 | The picture is not a still JPEG, PNG or WebP image: an SVG, a GIF, an animated image, or one damaged or incomplete. Nothing changed. | Choose a still JPEG, PNG or WebP image. |
image_too_large | 413 | The picture has more than 64 million pixels, a fixed limit. Nothing changed. | Choose a smaller image. |
rate_limited | 429 | Too many sign-in attempts or wrong current passwords, too many requests to a published map, or the OpenStreetMap service refused an import. | Wait, then retry. A sign-in or password refusal gives the wait in retryAfter and the Retry-After header. |
| Code | Status | What happened | What to do |
|---|---|---|---|
sql_rejected | 400 | The SQL is not one read-only SELECT, or it reads a file directly. | Send one SELECT over your datasets. |
sql_error | 400 | The SQL failed to parse or run, or it names a dataset you cannot reach. | Fix the SQL. position points at the problem when the engine gives one. Another person’s dataset is <sql name>.<dataset>: see Who may do what. |
query_timeout | 408 | The query ran past the instance’s query time limit. | Narrow the query, with a WHERE or fewer columns. The message names the setting that raises the limit. See Limits. |
query_memory_exceeded | 400 | The query needed more memory than the engine allows SQL. | Narrow the query, or ask the operator to raise DEZFIL_QUERY_MEMORY_MB. |
query_result_too_large | 422 | The answer, or one value in it, is over a byte limit, so no rows come back. A query answers this, and so do a dataset’s page, a geometry or PostGIS preview, a layer’s filter values and a category fill. | Narrow the query, or keep every row: Answers too large to return says what to do at each read. |
Busy or failing server
Section titled “Busy or failing server”| Code | Status | What happened | What to do |
|---|---|---|---|
operation_queue_full | 503 | Too many operations are already waiting to run. | Retry once some finish. |
authentication_busy | 503 | Too many sign-ins or password checks are waiting their turn. | Retry after the seconds in Retry-After. |
internal | 500 | The server failed in a way that has no code of its own. | Retry once. If it fails again, the operator finds the detail in the server log. |
Answers too large to return
Section titled “Answers too large to return”Limits gives the two byte limits behind query_result_too_large, and how geometry counts. No setting raises them, so what to do depends on the read that answers it:
| The read | What to do |
|---|---|
A query, on the SQL page, with POST /api/query or run_sql | Narrow it, as Query with SQL shows. To keep every row, download it or save it as a dataset. In the app, a SQL step saves it, and the dataset’s export in Data downloads it. |
| A dataset’s page, or its Set geometry panel | Keep a copy without the large column, as A dataset’s page does not open shows. |
| A layer’s Filters or Color by, or a Category widget’s Group by | Choose another column: one with a value that long holds no categories. |
| The PostGIS five-row preview | Import the table all the same: an import is not held to these limits. |
Download every row
Section titled “Download every row”An export runs the same query and streams every row of its result as a file, with no byte limit:
curl
curl -s http://localhost:3000/api/query/export \ -H "Authorization: Bearer <YOUR_TOKEN>" \ -H "Content-Type: application/json" \ -d '{"sql": "SELECT name, country, population FROM world_cities ORDER BY population DESC LIMIT 3", "format": "csv", "name": "largest_cities"}'JavaScript
const response = await fetch("http://localhost:3000/api/query/export", { method: "POST", headers: { Authorization: "Bearer <YOUR_TOKEN>", "Content-Type": "application/json", }, body: JSON.stringify({ sql: "SELECT name, country, population FROM world_cities ORDER BY population DESC LIMIT 3", format: "csv", name: "largest_cities", }),});console.log((await response.text()).trimEnd());Run it with node export.mjs.
name,country,populationTokyo,Japan,35676000New York,United States of America,19040000Mexico City,Mexico,19028000sqlis your query, here over theworld_citiesdataset of the API quickstart.formatiscsv,geoparquetorgeojson. CSV writes geometry as WKT text. GeoJSON needs a geometry column and uses the first one.namenames the file, herelargest_cities.csv. Without it the file isquery_result.csv.- curl prints the file. To save it instead, add
-o largest_cities.csv -w "%{http_code}\n":200means the file holds the rows, and any other status that it holds the error answer.
The export still keeps to the Query time limit and Engine memory, so it can answer query_timeout or query_memory_exceeded. The operation reference gives every field.
Over MCP, the tool export_sql_result hands the file back to the agent, up to Tool attachment size. A larger file fails with attachment_too_large: download it over HTTP, or save the rows as a dataset with run_operation, as the next section shows.
Save every row as a dataset
Section titled “Save every row as a dataset”A run of the operation sql_step saves every row of a query as a new dataset, with no byte limit:
curl
curl -s http://localhost:3000/api/operations/run \ -H "Authorization: Bearer <YOUR_TOKEN>" \ -H "Content-Type: application/json" \ -d '{"operation": "sql_step", "parameters": {"sql": "SELECT name, country, population, geometry FROM world_cities WHERE population > 10000000", "name": "big_cities"}}'JavaScript
const response = await fetch("http://localhost:3000/api/operations/run", { method: "POST", headers: { Authorization: "Bearer <YOUR_TOKEN>", "Content-Type": "application/json", }, body: JSON.stringify({ operation: "sql_step", parameters: { sql: "SELECT name, country, population, geometry FROM world_cities WHERE population > 10000000", name: "big_cities", }, }),});console.log(JSON.stringify(await response.json()));Run it with node save.mjs.
{"run":{"id":"<RUN_ID>","operation":"sql_step","state":"queued",...}}parametersholds the query assqland the new dataset’sname. A name already taken answers 409name_taken, and nothing runs.- The answer comes at once, with the run’s
id, and the run writes the dataset afterwards.
Read the run to see how it ended:
curl
curl -s http://localhost:3000/api/operations/runs/<RUN_ID> \ -H "Authorization: Bearer <YOUR_TOKEN>"JavaScript
const response = await fetch("http://localhost:3000/api/operations/runs/<RUN_ID>", { headers: { Authorization: "Bearer <YOUR_TOKEN>" },});console.log(JSON.stringify(await response.json()));Run it with node read-run.mjs.
{"run":{"id":"<RUN_ID>","operation":"sql_step","state":"succeeded",...,"finishedAt":"2026-10-20T09:00:01.503Z","output":{"id":"<DATASET_ID>","name":"big_cities"}}}stateisqueued, thenrunning. Read the run again every few seconds until it issucceeded,failedorcancelled.- A succeeded run names its dataset under
output. A failed one holds anerrorwith acodeand amessage, such assql_error, oroperation_timeoutpast Time limit per tool run. - Over MCP,
run_operationtakes the sameoperationandparameters, andget_operation_runreads the run.
The operation reference gives every field of a run.
Handle errors in your code
Section titled “Handle errors in your code”This helper returns the answer of a successful call and throws on an error, with its status and code. It retries the three codes that mean “wait and try again”:
const base = "http://localhost:3000";const token = "<YOUR_TOKEN>";const waitAndRetry = new Set(["rate_limited", "operation_queue_full", "authentication_busy"]);
export async function request(method, path, { body, headers = {} } = {}) { for (let attempt = 1; ; attempt++) { const response = await fetch(base + path, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...headers }, body: body === undefined ? undefined : JSON.stringify(body), }); const answer = await response.json().catch(() => null); if (response.ok) return answer; const error = answer?.error ?? { code: `http_${response.status}`, message: response.statusText }; if (!waitAndRetry.has(error.code) || attempt === 3) { throw Object.assign(new Error(`${response.status} ${error.code}: ${error.message}`), { status: response.status, error }); } const seconds = Number(response.headers.get("Retry-After")) || 5; await new Promise(resolve => setTimeout(resolve, seconds * 1000)); }}- A successful call returns its JSON answer, or
nullwhen the answer is not JSON or has no body. rate_limited,operation_queue_fullandauthentication_busywait for the seconds inRetry-After, or 5 without it, and try again, three attempts in all.- Every other error throws.
failure.errorkeeps the wholeerrorobject, with any extra fields. - An answer without an
errorobject gets the codehttp_<status>, such ashttp_404. See Troubleshooting.
Call it the way you call the API:
import { request } from "./dezfil.mjs";
try { await request("POST", "/api/query", { body: { sql: "SELECT * FROM no_such_dataset" } });} catch (failure) { console.log(failure.message);}Run it with node query.mjs, with both files in one folder.
400 sql_error: The query refers to an unknown table.Leave stale_revision out of the retry list: the same change with the same
revision fails again. Read the item, then send your change with the new
editRevision. How the API works shows the
pattern.
Troubleshooting
Section titled “Troubleshooting”A 404 answer has no error object
Section titled “A 404 answer has no error object”The path does not exist, so Dezfil answers with its not-found page. Check the path and its spelling against the operation reference.
Every change answers stale_revision
Section titled “Every change answers stale_revision”Check that you send the revision from the latest answer, not from your first read. Edit with revisions shows the chain.
A dataset’s page does not open
Section titled “A dataset’s page does not open”A dataset whose first 50 rows are too large to return keeps its page and Set geometry closed, and its inspection answers query_result_too_large. Text there shows only its first 512 characters, so the large value is of another kind, such as JSON, a list, a binary value or a second geometry column.
To find its column, run this on the SQL page, or with POST /api/query or run_sql:
SELECT max(length(CAST(COLUMNS(*) AS VARCHAR))) FROM <DATASET>;It answers one row: the length of each column’s longest value, in characters, under the column’s name. The column to leave out shows a number in the millions. The dataset’s own geometry column does not count.
Keep a copy without that column: on the SQL page, run SELECT * EXCLUDE (<COLUMN>) FROM <DATASET> and click Save as dataset. If that query answers query_result_too_large too, save it as a SQL step in a workflow, or as Save every row as a dataset shows. The copy’s page opens.
The message says more than the catalogue
Section titled “The message says more than the catalogue”The catalogue gives each code’s general meaning. An answer’s message adds the detail of this request, such as the dataset, the limit or the setting that changes it.
Next steps
Section titled “Next steps”- How the API works: the address, sign-in, edit revisions and who may do what.
- Limits: every limit, and the setting that changes it.
- Error catalogue: every code and what it means.
7439201b