REST API
Base path: /api/v1
Auth column legend:
- Public — no authentication required
- Auth — login or API token required
- Org — organization member / editor / admin
- sysadmin — system administrator only
A request body may be up to 1 MiB; a larger one is refused with 413 (PAYLOAD_TOO_LARGE) before it is parsed.
The file upload (POST /resources/{id}/upload) is the exception: it is bounded by the file size limit instead.
Datasets (packages)
Section titled “Datasets (packages)”| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /packages |
List datasets (with search and filters) | Public |
| POST | /packages |
Create dataset (a resources array creates them with it; each item is validated as POST /packages/{id}/resources is, and one refusal creates nothing) |
Org editor |
| POST | /packages/drafts |
Create draft dataset (every field optional) | Auth |
| POST | /packages/highlights |
Lazy-load content highlights ({ q, chunks }; visible datasets only) |
Public |
| GET | /packages/{nameOrId} |
Get dataset details | Public |
| PUT | /packages/{nameOrId} |
Update dataset (active: full replacement / draft: partial update; resources not included) |
Org editor |
| DELETE | /packages/{nameOrId} |
Delete dataset (active: soft delete / draft: immediate permanent delete) | Org editor |
| POST | /packages/{nameOrId}/publish |
Publish a draft (draft → active) |
Org editor |
| POST | /packages/{nameOrId}/suggest-metadata |
AI metadata suggestions (nothing is saved; 503 when unavailable) | Org editor |
| POST | /packages/{nameOrId}/restore |
Restore soft-deleted dataset (body { private: true } restores it as private) |
Org admin |
| POST | /packages/{nameOrId}/purge |
Permanently delete soft-deleted dataset | Org admin |
| GET | /packages/{id}/resources |
List resources | Public |
| POST | /packages/{id}/resources |
Add resource | Org editor |
| PUT | /packages/{id}/resources/reorder |
Reorder resources | Org editor |
Key query parameters (GET /packages)
Section titled “Key query parameters (GET /packages)”| Parameter | Description |
|---|---|
q |
Search query (keywords or natural language; hybrid search; at most 500 characters) |
name |
Dataset name prefix match |
semantic |
false limits results to keyword matches only (default: true) |
match |
relaxed settles for fewer of q’s words where no dataset holds them all (default: all; see below) |
offset / limit |
Pagination (limit at most 100; offset + limit at most 10,000, except with my_org=true or state=draft) |
organization[] |
Filter by organization |
groups[] |
Filter by category |
tags[] |
Filter by tag |
res_format[] |
Filter by resource format (e.g., CSV) |
license_id[] |
Filter by license |
extras_<key> |
Datasets whose extras hold the value under <key> (see below) |
res_extras_<key> |
Datasets with a resource whose extras hold the value under <key> (see below) |
creator_user_id |
Filter by creator user ID |
state |
active / deleted / draft (draft requires auth; see below. deleted applies only with my_org=true and a signed-in user, and falls back to active otherwise) |
my_org |
Limit to organizations where the user is editor or above |
private |
true for private only, false for public only (omit for everything visible) |
sort_by |
updated / created / name |
sort_order |
asc / desc |
include_facets |
Include facet information |
Filtering on extras
Section titled “Filtering on extras”extras_<key>=<value> filters on a dataset’s extras, and res_extras_<key>=<value> on its resources’.
The key goes in the parameter name, URL-encoded, so a key with Japanese, . or : in it is written
as it is.
curl -G "https://your-kukan-site/api/v1/packages" \ --data-urlencode "extras_spatial_unit=municipality" \ --data-urlencode "res_extras_lang=ja"--data-urlencode encodes only what follows the =. Percent-encode a key with non-ASCII characters
yourself: extras_%E6%95%B4%E5%82%99%E5%8D%98%E4%BD%8D=... for extras_整備単位.
- Values are compared whole: no case folding, no trimming, no width or Unicode normalization
- Numbers are compared in decimal without an exponent, booleans as
true/false(extras_n=5matches the number5and the string"5"; the number1e-7isextras_n=0.0000001). An array matches when any of its elements does. Object andnullvalues match nothing - Every condition is ANDed, a repeated parameter included
- Each
res_extras_*condition is met by some resource; two conditions need not be met by the same one (as withres_format[]) - An empty key (
extras_=) and more than 20 conditions in one request answer400
Relaxing the word match (match=relaxed)
Section titled “Relaxing the word match (match=relaxed)”By keyword, a dataset matches if it holds every word of q (split at spaces). With match=relaxed,
and only where no dataset holds them all, the search asks again for one word fewer, then half of them,
then any one. A relaxed response carries minWords (how many words a dataset had to hold), and each
dataset found by keyword carries the words it lacks as missingWords (one found by meaning alone does not). Where every word was found, minWords is absent.
Example: dataset search
Section titled “Example: dataset search”curl "https://your-kukan-site/api/v1/packages?q=population&organization[]=stat-bureau&limit=10"Drafts
Section titled “Drafts”Datasets can be created as drafts and published once the publish requirements are met. Drafts never appear in search or listings (including the CKAN-compatible API) until published.
Create — POST /packages/drafts (auth required):
Every field is optional. When name is omitted, a placeholder of the form
untitled-xxxxxxxx is auto-generated (a draft cannot be published with the placeholder).
Specifying ownerOrg at creation requires editor rights in that organization.
curl -X POST https://your-kukan-site/api/v1/packages/drafts \ -H 'Authorization: Bearer <token>' \ -H 'Content-Type: application/json' \ -d '{"title": "Work-in-progress dataset"}'Publish — POST /packages/{nameOrId}/publish:
One-way draft → active transition (there is no way back to draft after publishing).
Returns 400 unless all three of the following are set:
nameset explicitly (the auto-generated placeholder is not accepted)ownerOrgsetlicenseIdset
Permission is the same as dataset update, and editor rights in the ownerOrg organization
are re-verified at publish time. Re-publishing an already active package is allowed and
re-runs the publish-time search index sync (idempotent — the retry entry point after a
failed sync).
List — GET /packages?state=draft (auth required):
Returns only drafts created by the caller plus drafts of organizations where the caller is
editor or above. Supported filters: q / name / organization[] / private /
sort_by / sort_order / offset / limit. Specifying tags[] / groups[] /
res_format[] / license_id[] / extras_<key> / res_extras_<key> / creator_user_id /
my_org / include_facets returns 400. Rows whose deletion failed partway (deletion incomplete) are included in
the listing.
Get, update, delete:
- Fetch details with
GET /packages/{id}?state=draft(auth and draft access required) PUTon a draft is a partial update: only the keys present in the request are applied, and an explicitnullclears that field.name: nullregenerates the placeholder (back to “unnamed”) andownerOrg: nullclears the organization (both block publishing again).PUTon anactivepackage remains full replacementDELETEon a draft skips the trash and permanently deletes it immediately (irreversible). If a deletion fails partway, re-running the sameDELETEcompletes the permanent deletion
Resources
Section titled “Resources”| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /resources/count |
Active resource count (my_org=true limits to organizations where the user is editor or above) |
Public |
| GET | /resources/formats |
List formats of the resources the viewer can see | Public |
| GET | /resources/{id} |
Resource details | Public |
| GET | /resources/{id}/text |
Text content (preview-size limited) | Public |
| GET | /resources/{id}/json |
Minified JSON/GeoJSON (415 if not JSON, 413 over the limit) | Public |
| GET | /resources/{id}/download |
Download file (302 redirect for external URLs) | Public |
| GET | /resources/{id}/preview |
Server-proxied preview (Range supported) | Public |
| GET | /resources/{id}/pipeline-status |
Pipeline job status | Public |
| GET | /resources/{id}/schema |
Column schema (names, types, row count, numeric stats). Tables only | Public |
| POST | /resources/{id}/query |
Server-side DuckDB query (SELECT only). Tables only | Public |
| POST | /resources/{id}/upload-url |
Issue presigned upload URL | Org editor |
| POST | /resources/{id}/upload |
Server-proxied upload (multipart file, streamed; 413 over the cap, 400 when the filename fails the same rule as upload-url: 1–500 characters) |
Org editor |
| POST | /resources/{id}/upload-complete |
Notify completion, enqueue pipeline | Org editor |
| POST | /resources/{id}/run-pipeline |
Manually trigger pipeline | Org editor |
| POST | /resources/{id}/cancel-pipeline |
Stop the running job (content is left alone) | Org editor |
| POST | /resources/{id}/revert |
Stop the job and put the content back on a given version | Org editor |
| GET | /resources/{id}/column-settings |
What has been settled about the columns, and what there is to settle over | Org editor |
| POST | /resources/{id}/column-settings/check |
Would this primary key identify the rows? | Org editor |
| PUT | /resources/{id}/column-settings |
Settle the primary key | Org editor |
| GET | /resources/{id}/column-roles |
The columns’ roles, and why the rules decided them | Org editor |
| PUT | /resources/{id}/summary |
Hide or unhide the AI-written description ({ hidden }) |
Org editor |
| PUT | /resources/{id} |
Update resource (full replacement; only section is kept when omitted) |
Org editor |
| DELETE | /resources/{id} |
Delete resource | Org editor |
Sections (section)
Section titled “Sections (section)”A resource carries a section field (a string, null when unset). It is a display
grouping that raises headings in the resource list on the dataset page; it changes
neither visibility nor permissions.
A heading opens for each run of consecutive resources sharing the same section. When
another section or an unlabelled resource sits in between, a heading of the same name
opens again. Ordering is position alone — section plays no part in it.
- Accepted by
POST /packages/{id}/resourcesandPUT /resources/{id}. Left out of aPUTit is kept;nullclears it - Values are normalized on the way in: split on
/, each segment trimmed, empty segments dropped, rejoined with/; an empty result becomesnull./is reserved for a later nested display - A name labels one contiguous run. A request that would put the same name in two
separated places (
sectionsonreorder,sectionon create / update) gets400, and so does an order-onlyreorderor anullthat would cut a run in two PUT /packages/{id}/resources/reorderaccepts an optionalsectionsarray and applies the labels in the same transaction as the order. Unlike a resource update it does not re-enqueue the pipeline, so use it when a reorder also changes labels. When given, it names every active resource exactly once — the later save wins whole. Omit it and the labels stay as they are
{ "resourceIds": ["<id-1>", "<id-2>"], "sections": [ { "resourceId": "<id-1>", "section": "Supporting documents" }, { "resourceId": "<id-2>", "section": null } ]}Custom fields (extras)
Section titled “Custom fields (extras)”A resource carries free-form key-value metadata (extras, an object). As with a dataset’s extras,
values may be any JSON.
- Set it with
POST /packages/{id}/resourcesandPUT /resources/{id}.PUTreplaces it whole; leaving it out empties it ({}), as a dataset’sPUTand CKAN’sresource_updatedo - These keys are reserved and answer
400(details.issues[].pathis["extras", "<key>"]):- The field names CKAN defines for a resource (
id,package_id,url,url_type,name,description,format,mimetype,size,hash,created,last_modified,metadata_modified,position,state,resource_typeand so on). The CKAN-compatible API spreadsextrasonto the resource’s top level, where a key of the same name could not be told from the standard field section, which the CKAN-compatible API puts on the same top level for the resource’s section- The keys the health check once used (
healthEtag,healthLastModified,healthError,healthHttpStatus,healthLastFullFetchAt)
- The field names CKAN defines for a resource (
{ "name": "Population", "url": "https://example.com/population.csv", "extras": { "unit": "persons" }}Versions
Section titled “Versions”| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /resources/{id}/versions |
List versions (newest first; purged ones remain as records) | Public |
| GET | /resources/{id}/versions/{v} |
Version details | Public |
| GET | /resources/{id}/versions/{v}/diff |
Row diff against the preceding version. Tables only | Org editor |
| GET | /resources/{id}/versions/{v}/download |
Download that version’s file | Public |
| POST | /resources/{id}/versions/{v}/purge |
Permanently erase a version’s content | Sysadmin |
The public endpoints apply the same visibility check as download and preview (a private dataset still requires permission).
Document text
Section titled “Document text”The text the pipeline extracts from PDF, Word, PowerPoint, Excel, Ichitaro and Markdown resources can be read
with the page, slide or sheet and the headings each part is under — the same reads as MCP’s
get_document_outline / get_document_section.
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /resources/{id}/document-text |
The text’s blocks from the start, at once (preview size limit) | Public |
| GET | /resources/{id}/document-outline |
The headings (or sheets), the pages each spans, and the pages without text | Public |
| GET | /resources/{id}/document-section |
The text of a range of pages or a heading (read on with a cursor) | Public |
document-outline:from/tolimit it to pages (slides, sheets). Returnsunits(what the document counts, and the first and last),entries(each heading’sdepth,titleandunits; a sheet’srows),unreadable(pages whose text cannot be read, such as scans) andpartlyUnreadable(pages with some characters that cannot be read).document-section:from/to(pages, slides or sheets, 1-based),heading(part of a heading, a pathA > B, or part of a sheet’s name; case, width and spaces are ignored),max_chars(default: 8,000, max: 32,000).partshave the shape ofdocument-text’s blocks;textis the part cut, andoffsetwhere in the block’s text it starts. A sheet block’sloc.rowsare narrowed to the rows of the part. Where more text follows,nextholds a cursor: pass it ascursorto read on. A cursor carries the range and the heading, sofrom/to/headingare then not read. Where nothing matches,partsis empty andunmatched.unitsgives the range of the document’s pages.- A broken cursor gets 400; a cursor from another document, or from before this one was extracted again, gets 409 — read again without it.
All three return the versions (latestVersion for the file the resource holds now, sourceVersion for the
file the text was extracted from, and describesLiveContent). Where describesLiveContent is false, the
file has been replaced and its text not yet extracted again: cite sourceVersion.
Schema and data query
Section titled “Schema and data query”Tabular resources (CSV/TSV, and JSON/GeoJSON whose records read as a table, with a pipeline-generated preview Parquet, ≤100 MB) support column schema
retrieval and read-only SQL queries. The MCP get_resource_schema / query_resource
tools offer the same capability for AI agents.
Get schema — GET /resources/{id}/schema:
{ "id": "…", "queryable": true, "primaryKey": null, "odataRefusal": null, "odataWideRows": false, "odataKey": { "names": ["RowId"], "synthetic": true, "fallback": "not-designated" }, "schema": { "rowCount": 1688, "columns": [ { "name": "prefecture", "type": "string", "nullable": true, "nullCount": 3 }, { "name": "weight_limit", "type": "integer", "nullable": false, "nullCount": 0, "stats": { "min": "600", "max": "1350" } } ] }, "columnRoles": [{ "role": "category" }, { "role": "measure" }]}odataRefusal, odataWideRows and odataKey belong to the OData feed (below): odataRefusal says
why no feed is served (not-public / unsupported-columns / duplicate-columns) and names the
headings at fault, odataWideRows says whether a page of the feed may fail to read because rows are
too wide (a caution, not a refusal), and odataKey says what the feed identifies rows by.
odataKey is null wherever no feed is served, and odataRefusal is null too where there is no
table to serve.
Resources that cannot be queried (unsupported format, not yet processed) return
queryable: false, schema: null. When a schema exists but there is nothing to
query (the preview has been purged, the schema has zero columns, or the preview
has not been regenerated after the content was replaced), schema is still
returned with queryable: false. primaryKey lists the column names of the
resource’s settled primary key (composite keys in their chosen order), or is
null when none is set. type is one of integer / float / boolean / string /
date / timestamp; stats is present only for numeric columns, and integer min/max are
always decimal strings, whatever their size, while float min/max are numbers (the type follows the
column’s type alone, so a range past 2^53 keeps its digits; rows from /query differ, with integers
below 2^53 as numbers). columnRoles is each column’s role (measure / numeric-text / identifier /
free-text / name / category / empty), at the same position as the column in schema, or
null for a table interpreted before roles. Why the rules decided a role is not returned here — GET /resources/{id}/column-roles (editors only) reads it.
droppedRows counts lines of the file that are not rows of this table — the reader refuses one
written short, and a note padded out to the table’s width is trimmed from the table itself — a version line at the bottom, a stray comma in the middle. Refusing them is what stops
one such line costing the file every column it has: one row two fields wide in a file of
seventy-nine is enough for the CSV sniffer to abandon the delimiter and read the whole file as a
single text column. Absent means none, or a schema written before the count existed.
droppedLines says where the refused ones are, first few first, numbered as the CSV reader counts
from the start of the file — 1-based, including the header and any skipped title rows, and a
record whose quoted cell holds newlines counts once rather than once per line. A note trimmed from
the table has no line to give: by then the blank lines are gone and a quoted newline has folded a
record onto one. So this list can be shorter than droppedRows, or absent while it counts, and it
is bounded besides.
Whether a refused line is taken for a sign-off decides if there is a table at all. One that is
gets dropped and the table is served; any other refused line refuses the resource a table instead:
schema.columns is empty, noTableReason is ragged-rows, and droppedLines names the lines to
look at.
Which it is, is a guess, and it rests on the shape of the line: how many values it holds against how many the table has. Two values in a table of thirty-one columns reads as a note rather than a row that lost twenty-nine fields; two in a table of three is the ordinary way a row breaks, and that file is refused. The values are counted by a parser, so a quoted cell holding the delimiter is one of them. Neither position nor type is consulted — a truncated row at the end of a file sits exactly where a note sits, and a row with a broken value fails every cast the table would ask of it just as prose does.
It is a guess and not a guarantee. A row truncated to three of six columns reads as a note by
this rule, and is dropped. Nothing in the file distinguishes the two, so droppedRows and
droppedLines exist to put the guess in front of you. A refused file is still stored and downloadable, and re-running the pipeline after
fixing it produces the table.
Query data — POST /resources/{id}/query:
curl -X POST https://your-kukan-site/api/v1/resources/{id}/query \ -H 'Content-Type: application/json' \ -d '{"sql": "SELECT prefecture AS city, count(*) AS n FROM data GROUP BY 1 ORDER BY n DESC LIMIT 3"}'{ "id": "…", "columns": ["city", "n"], "rows": [ { "city": "Adachi", "n": 117 }, { "city": "Edogawa", "n": 110 }, { "city": "Ota", "n": 86 } ], "rowCount": 3, "truncated": false, "elapsedMs": 31}Query rules:
- The data is exposed as a table named
data. Double-quote column names that contain spaces or non-ASCII characters. - A single
SELECT/WITHstatement only. Writes and DDL (INSERT/UPDATE/DELETE/CREATE/DROP/ATTACH/COPY/PRAGMA, …) are rejected. PIVOTneeds an explicitIN (…)list of the values to pivot on (PIVOT data ON col IN ('a', 'b') USING sum(v)). Without one, DuckDB adds a statement that creates a type ahead of the query, and it is refused as a second statement.- Runs in a throwaway in-memory DuckDB instance; all external access (files, URLs, extension loading) is disabled.
- Integers are numbers, except a value a double cannot hold exactly (absolute value 2^53 or more), which alone is a string. A column can mix the two, so handle both. DECIMAL is a string to keep its precision, as are dates and times.
Limits and errors:
| Item | Value |
|---|---|
| Max rows | 10,000 (truncated: true when exceeded) |
| Max result size | 5 MB |
| Execution timeout | 15 s (408) |
| Concurrency | 1 (429 when exceeded) |
| Error | Condition |
|---|---|
400 |
Invalid SQL / non-SELECT / over length / non-queryable resource |
404 |
Resource not found / private without access |
408 |
Execution timeout |
429 |
Concurrent query limit |
OData feed (for BI tools)
Section titled “OData feed (for BI tools)”A tabular resource is also served as a read-only OData v4 feed. Pasting the URL into the OData
connector in Tableau, Power BI, Excel and the like opens the table as it is. It lives in its own
URL space, not under /api/v1.
| Method | Path | What it returns |
|---|---|---|
| GET | /odata/v1/resources/{id} |
Service document (the entity sets) |
| GET | /odata/v1/resources/{id}/$metadata |
CSDL: column names, types, and the key |
| GET | /odata/v1/resources/{id}/Rows |
The table itself, paged |
| GET | /odata/v1/resources/{id}/Rows/$count |
Row count, as plain text |
curl 'https://your-kukan-site/odata/v1/resources/{id}/Rows?$top=2'- Public resources only, and no credentials. A private resource answers
404even to its owner - Paging continues through
@odata.nextLink. Pages are not a fixed size: one ends at 65,536 rows, 8 MB, or the boundary of a row group in the preview, whichever comes first (a row group is the smallest unit a Parquet read decodes, and crossing one doubles what has to be held). The next link carries a fingerprint of the version, so if the file is replaced mid-extract, a page bearing the old fingerprint answers410 Gonerather than handing back a table with two versions mixed into it $select,$filter,$orderby,$apply,$searchand$expandare not supported and answer501. Ignoring them silently would hand a reader every row under the impression they had asked for some of them- Rows are identified by the publisher’s primary key where OData allows that key, and otherwise
by an added
RowIdcolumn holding the row’s position. Which of the two it is comes back in/schemaasodataKey - A resource whose headings are not EDM identifiers (brackets, spaces, punctuation) answers
501;/schema’sodataRefusalsays which headings $metadatarevalidates on every request (public, no-cache), so a stale declaration of the columns is never paired with rows read under a newer one
Versions and diffs
Section titled “Versions and diffs”When a resource’s file is replaced, the content it replaced is kept as a version. No version is added while the content stays the same.
List versions — GET /resources/{id}/versions (newest first):
{ "id": "…", "versions": [ { "version": 2, "origin": "upload", "state": "active", "isLive": true, "purgeFallsBackTo": 1, "format": "CSV", "size": 20481, "hash": "…", "schema": { "rowCount": 1688, "columns": [] }, "noTableReason": null, "keyColumns": ["order_no", "line_no"], "created": "2026-08-01T09:12:44.000Z", "purgedAt": null } ]}| Field | Description |
|---|---|
origin |
upload (an explicit replacement) or fetch (observed when the external URL was read) |
state |
active / superseded (legacy rows left by the old-style revert; never written anymore) / purging / purged |
isLive |
Whether the resource is serving this version. Not derivable from version or state |
purgeFallsBackTo |
Where serving would land if this version were purged. Set only when isLive — purging any other version does not move serving |
format |
The format this version settled as. Relabeling the resource does not change it |
schema |
The version’s column schema (tabular only), frozen per version |
noTableReason |
Why no table was produced (no-columns / too-many-columns / too-large / out-of-memory / timed-out / ragged-rows / not-records) |
keyColumns |
The primary key this version’s rows were identified by, null for a version read without one. The resource’s current setting is a different question — it is what the next version will be read under |
A purged version stays in the list as a record, but size / hash / schema / keyColumns
become null and purgedAt is filled in. Downloading it returns 404. keyColumns goes with them
because a primary key is only settable over columns the content has, so its names are a subset of
the withheld schema (a format is a closed vocabulary, and stays). The reason it was purged is
not returned — it is free text about content that has been destroyed, and this endpoint is as
readable as the resource; who purged what, when and why is in the audit log.
Diff — GET /resources/{id}/versions/{v}/diff?from={v'} (from defaults to the preceding
version):
Requires editor permission on the owning dataset — it scans both snapshots in full, so it is not a public endpoint.
{ "available": true, "from": 1, "to": 2, "schemaChanged": false, "keyed": false, "addedRows": 42, "removedRows": 3, "sampleAdded": [{ "city": "Adachi", "count": 117 }], "sampleRemoved": []}keyedistrueonly when both ends were read under the same primary key, and addschangedRows,sampleChangedAfterandsampleChangedBeforewhen it is — the two samples aligned by position, the older side carrying only the columns whose value actually moved (the key matched, so both sides hold it, and a column that held still is left out). Sampled cells are trimmed for display, so comparing the two sides does not answer whether a cell changed; presence in the older side does. With no key declared, or a different key at each end, it isfalseand editing one row appears as one addition plus one removal — nothing identifies the row as the same one- Sample values follow the same rule as
POST /resources/{id}/query(integers are numbers; only a value of absolute value 2^53 or more is a string) - Versions whose columns differ return
{"schemaChanged": true, "schemaDiff": {"added": [], "removed": [], "retyped": []}}and no row comparison is attempted - When no diff can be produced, the response is
{"available": false, "reason": "…", "from": …, "to": …, "reasonVersion": …}, wherereasonisno-previous-version/not-ingested/purged/key-missing/key-null/key-not-unique/ingest-failed. The threekey-*values mean the declared primary key did not identify that version’s rows, so it was never loaded;ingest-failedmeans loading it was attempted repeatedly and given up on.not-ingestedis not a cause: one side has no row-level snapshot and no key fault to name, which includes the ordinary window between a version being created and the load running for it. A cause may still be recorded on the version and still give this reason —noTableReasonsettles four of them, and the version is where they are answered reasonVersionsays which of the two the reason is about, and it is not necessarily the one asked for: opening the version after a corrected key puts the refused one on the other end, andnot-ingestedis a property of one end just as easily. It isnullonly where no single version is the subject — nothing to compare against, and a purge, which stops the comparison the same way from either end- The SQL is composed server-side from version numbers alone. This is a separate path from the
POST /resources/{id}/querysandbox; user SQL never reaches it - Responses carry
Cache-Control: private, max-age=60, must-revalidate. An unavailable answer is not cached at all
| Error | Condition |
|---|---|
401 |
Not authenticated |
403 |
No editor permission on the owning dataset |
404 |
Resource or version not found |
408 |
Diff timed out |
429 |
DuckDB concurrency limit (shared with data queries) |
Purge a version — POST /resources/{id}/versions/{v}/purge (sysadmin only):
A reason is required and is recorded in the audit log. The version is claimed and 202 is
returned; a worker erases the content asynchronously. Repeating the call for the same version is
idempotent.
curl -X POST https://your-kukan-site/api/v1/resources/{id}/versions/2/purge \ -H 'Authorization: Bearer <token>' \ -H 'Content-Type: application/json' \ -d '{"reason": "Personal data was included"}'Revert — POST /resources/{id}/revert:
Stops the running job and puts the resource’s content back on a given version.
{ "restoreTo": 2, "ifLiveRevision": "018f…-…-…" }restoreTois the destination version number, not how many rungs to step back.nullleaves the resource with no fileifLiveRevisionis thelive_revisionserved byGET /resources/{id}/pipeline-status. It stops a request delayed past a newer upload from reverting content it never saw- The same
pipeline-statusalso returnsrevert_target, the default destination
After a revert the preview and the search index can still describe the old content. Rebuild them
with POST /resources/{id}/run-pipeline and {"rebuildOnly": true}, which regenerates from the
object the resource already holds rather than re-fetching the external URL.
Column settings
Section titled “Column settings”The primary key a resource’s rows are identified by. Declaring one makes the version diff report
edits (keyed: true) rather than an addition and a removal per changed row. All three require
editor permission — this is what a person settled about the columns, not a description of the
data.
Read — GET /resources/{id}/column-settings:
{ "id": "…", "primaryKey": ["order_no"], "carried": false, "schema": { "rowCount": 1688, "columns": [{ "name": "order_no", "type": "string", "nullCount": 0, "unique": true }] }}schemais the live version’s frozen interpretation — the same columns aPUTis validated against, so a key chosen from this list cannot be refused for naming a column that is not thereuniquemarks the columns that identify a row on their own, so choosing one of those needs no check. It says nothing about a combination — a composite key has to be read out of the contentcarriedisfalsewhile the newest standing version has not been read underprimaryKeyyet. That is the ordinary state between settling a key and the rebuild landing, not a fault
Check — POST /resources/{id}/column-settings/check, body {"primaryKey": [...]}:
{ "id": "…", "checked": true, "primaryKey": ["order_no"], "fault": "key-not-unique" }faultisnullwhen the key would work, orkey-missing/key-null/key-not-unique{"checked": false, "reason": "no-preview" | "preview-stale"}means the question could not be answered — there is no current interpretation to read. Not an error: thePUTasks nothing of the content, so a key can still be settled- Answered from the frozen per-column counts for a single-column key, and by reading the interpretation’s Parquet for a composite one
Settle — PUT /resources/{id}/column-settings, body {"primaryKey": [...] | null}:
{ "id": "…", "primaryKey": ["order_no"], "queued": true }[]andnullboth mean “no key”; the body normalizes to one spelling- Columns are kept in the order given, and rows are matched regardless of it: every use of a key ands its columns together. The order is part of the recorded key all the same, so the same columns in another order count as a different key and issue a version
- Settling a key creates a version, because a version is these bytes read this way and the key
is part of the reading. The bytes do not move, so the new version owns a copy of them.
queuedsays the rebuild that issues it is on its way;nullmeans none was owed;falsemeans the setting was stored and the rebuild could not be queued — the two are decided separately so that re-sending the same body is the repair. Nothing sweeps for a resource whose setting and newest version disagree, so a caller that ignoresfalseleaves a key no version is ever read under - Versions already in layer 2 are not re-read under the new key. The boundary is the version itself, and diffs across it fall back to comparing whole rows
Column roles
Section titled “Column roles”A column’s role decides how search treats its values and what the field list on the public page shows. Name and category columns are indexed as lists of their values, free-text columns as sentences with the row’s context, and the rest are not used for search. Roles are decided automatically from the values and the field name.
GET /resources/{id}/column-roles (requires editor permission) returns each column’s role and
why it was decided:
{ "columns": [ { "name": "capacity", "type": "string", "role": "numeric-text", "roleBasis": { "rule": "number-shape", "share": 0.92, "examples": ["-", "1,000"] } }, { "name": "category", "type": "string", "role": "category", "roleBasis": { "rule": "other" } } ]}roleBasis.ruleis the rule that decided the role, andsharethe share of values it measured- The
examplesof anumeric-textcolumn are values that do not read as numbers — what the publisher has to fix columnsisnullfor a table whose roles have not been worked out yet- Read-only in this version; overriding a role is planned for a future version
Hiding an AI-written description
Section titled “Hiding an AI-written description”PUT /resources/{id}/summary with {"hidden": true} takes an
AI-written description off the page.
{"hidden": false} puts it back.
- The text is not deleted. Unhiding returns the same sentences
- Hiding takes it out of the search index and the embedding as well as the page — so text somebody took down does not go on answering searches
- The pipeline does not regenerate a hidden description
Organizations
Section titled “Organizations”| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /organizations |
List organizations | Public |
| POST | /organizations |
Create organization | sysadmin |
| GET | /organizations/{nameOrId} |
Organization details | Public |
| PUT | /organizations/{nameOrId} |
Update organization (full replacement) | Org admin |
| DELETE | /organizations/{nameOrId} |
Soft-delete organization | Org admin |
| POST | /organizations/{nameOrId}/restore |
Restore soft-deleted organization | sysadmin |
| POST | /organizations/{nameOrId}/purge |
Permanently delete soft-deleted organization (async, through the job queue) | sysadmin |
| GET | /organizations/{nameOrId}/members |
List members | Org member |
| POST | /organizations/{nameOrId}/members |
Add member / change role | Org admin |
| DELETE | /organizations/{nameOrId}/members/{userId} |
Remove member | Org admin |
Member roles are one of admin / editor / member.
Categories (groups)
Section titled “Categories (groups)”| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /groups |
List categories | Public |
| POST | /groups |
Create category | sysadmin |
| GET | /groups/{nameOrId} |
Category details | Public |
| PUT | /groups/{nameOrId} |
Update category (full replacement) | Category admin |
| DELETE | /groups/{nameOrId} |
Delete category | Category admin |
| POST | /groups/{nameOrId}/restore |
Restore soft-deleted category | sysadmin |
| POST | /groups/{nameOrId}/purge |
Permanently delete soft-deleted category | sysadmin |
| GET | /groups/{nameOrId}/members |
List members | Category member |
| POST | /groups/{nameOrId}/members |
Add member / change role | Category admin |
| DELETE | /groups/{nameOrId}/members/{userId} |
Remove member | Category admin |
Passages
Section titled “Passages”| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /passages |
Passages of documents’ text that match a query, with where each is from and its page (visible ones only) | Public |
The same search as MCP’s search_passages. Parameters: q (required, up to 500 characters),
organization, tags[] (or tags), k (default: 5, max: 20), per_resource (default: 2), max_chars
(default: 8,000, max: 32,000), and semantic (false for keyword matches only). Where no dataset holds every
word, the documents are found by fewer of them, and the response carries minWords (how many words were
required). Each passage carries its
resource and dataset, where it is (first / last page, slide or sheet, and headings), the words that
matched (words), its text (text), a cursor for /resources/{id}/document-section to read on from the
top of its page — or from the passage itself in a long block — (cursor), where that is (readFrom), and its
versions (latestVersion for the file the resource holds now, sourceVersion for the file the text was
extracted from). Where describesLiveContent is false, the file has been replaced and its text not yet
extracted again: cite sourceVersion.
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /tags |
List tags | Public |
| GET | /tags/{id} |
Tag details | Public |
GET /tags takes q (part of the name), offset, limit (default 100, at most 100) and
orderBy (name, the default, or datasetCount, most datasets first). The order is fixed,
so paging with offset neither repeats nor skips a tag.
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /users/me |
Current user info | Auth |
| GET | /users/me/organizations |
User’s organizations | Auth |
| GET | /users/me/groups |
User’s categories | Auth |
| GET | /users |
Search users (for member management) | Auth |
API tokens
Section titled “API tokens”| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /api-tokens |
Create token | Auth |
| GET | /api-tokens |
List own tokens | Auth |
| DELETE | /api-tokens/{id} |
Revoke token | Auth |
Announcements
Section titled “Announcements”| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /announcements |
List announcements (published only; a sysadmin may pass publishedOnly=false to include unpublished ones) |
Public |
| GET | /announcements/{id} |
Announcement details (unpublished ones for sysadmin only) | Public |
| POST | /announcements |
Create announcement | sysadmin |
| PUT | /announcements/{id} |
Update announcement (full replacement) | sysadmin |
| DELETE | /announcements/{id} |
Delete announcement | sysadmin |
All sysadmin only.
| Method | Path | Description |
|---|---|---|
| GET | /admin/search/stats |
Search index statistics (doc count, size, DB counts) |
| GET | /admin/search/doc/{index}/{id} |
Get index document (index: packages / resources / contents) |
| GET | /admin/search/browse/{index} |
Browse index documents (pagination, search) |
| GET | /admin/search/chunks/{resourceId} |
List a resource’s content chunks |
| POST | /admin/reindex-metadata |
Rebuild the search index ({ includeContent: true } reprocesses every resource from its stored file, 409 while one is on its way; { reindexContent: true } indexes the content again from the stored interpretation; not both; async, through the job queue) |
| POST | /admin/reindex-embeddings |
Rebuild every resource’s embedding (unchanged text and model are not re-embedded; async, through the job queue) |
| GET | /admin/embedding-status |
Resources semantic search cannot reach (no embedding or a different model; { missing }) |
| GET | /admin/search/analysis-status |
Whether the analysis has moved on and a re-analysis is due ({ stale }) |
| POST | /admin/search/reanalyse |
Rebuild the index under the current analysis (async, through the job queue) |
| GET | /admin/search/chunk-status |
Resources whose content is indexed in the larger units used before ({ resources }) |
| GET | /admin/summary-estimate |
Scope, token estimate, and cost estimate for bulk AI descriptions |
| POST | /admin/generate-summaries |
Generate AI descriptions in bulk ({ refresh } also rewrites; async, through the job queue) |
| GET | /admin/version-backfill-status |
Migration work outstanding (layer 1: unversioned / layer 2: not ingested / old-style reverts not yet converted) |
| POST | /admin/backfill-versions |
Enqueue the one-time migrations (version backfill, conversion of old-style reverts; async, through the job queue) |
| GET | /admin/row-group-status |
Previews without recorded row groups |
| POST | /admin/record-row-groups |
Enqueue row-group recording for previews (async, through the job queue) |
| POST | /admin/jobs/enqueue-all |
Enqueue pipeline for all resources (409 while a reprocess is on its way) |
| GET | /admin/jobs/stats |
Per-resource pipeline status counts (reprocessPending: whether a reprocess of every resource is on its way) |
| GET | /admin/jobs |
Per-resource pipeline status list (status is comma-separated) |
| GET | /admin/queue/counts |
Job counts by type and status (scheduled / waiting / running / dead) |
| GET | /admin/queue/jobs |
List queued jobs (status and type filters, offset / limit) |
| POST | /admin/queue/jobs/:id/retry |
Put a dead job back, attempts reset |
| DELETE | /admin/queue/jobs/:id |
Delete a dead job |
| GET | /admin/settings |
List runtime settings |
| PUT | /admin/settings/{key} |
Set one runtime setting ({ value }) |
| GET | /admin/settings/vector-search |
Vector-search status (model, similarity floor) |
| GET | /admin/settings/ai-suggest |
AI-suggest status (provider, model) |
| POST | /admin/settings/ai-suggest/test |
Test the connection to the AI-suggest model |
| DELETE | /admin/data |
Delete all data (preserves users) |
| GET | /admin/health/stats |
Health check statistics |
| GET | /admin/health |
Health check results |
| GET | /admin/users/stats |
User statistics |
| GET | /admin/users |
List users |
| POST | /admin/users |
Create user |
| PATCH | /admin/users/{userId} |
Update user |
| DELETE | /admin/users/{userId} |
Soft-delete user (revokes sessions/tokens) |
| POST | /admin/users/{userId}/restore |
Restore user |
| POST | /admin/users/{userId}/purge |
Permanently delete user |
| GET | /admin/analytics/{report} |
Access analytics (GA4; report is dataset-views / resource-views / downloads / search-terms, startDate / endDate set the range, 404 when GA4 is not configured) |
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /api/health |
Health check | Public |
| GET | /api/v1/site/settings |
Site settings (registration, site metadata etc.) | Public |