Skip to content

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.

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

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=5 matches the number 5 and the string "5"; the number 1e-7 is extras_n=0.0000001). An array matches when any of its elements does. Object and null values 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 with res_format[])
  • An empty key (extras_=) and more than 20 conditions in one request answer 400

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.

ターミナルウィンドウ
curl "https://your-kukan-site/api/v1/packages?q=population&organization[]=stat-bureau&limit=10"

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:

  • name set explicitly (the auto-generated placeholder is not accepted)
  • ownerOrg set
  • licenseId set

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)
  • PUT on a draft is a partial update: only the keys present in the request are applied, and an explicit null clears that field. name: null regenerates the placeholder (back to “unnamed”) and ownerOrg: null clears the organization (both block publishing again). PUT on an active package remains full replacement
  • DELETE on a draft skips the trash and permanently deletes it immediately (irreversible). If a deletion fails partway, re-running the same DELETE completes the permanent deletion
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

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}/resources and PUT /resources/{id}. Left out of a PUT it is kept; null clears it
  • Values are normalized on the way in: split on /, each segment trimmed, empty segments dropped, rejoined with /; an empty result becomes null. / 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 (sections on reorder, section on create / update) gets 400, and so does an order-only reorder or a null that would cut a run in two
  • PUT /packages/{id}/resources/reorder accepts an optional sections array 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 }
]
}

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}/resources and PUT /resources/{id}. PUT replaces it whole; leaving it out empties it ({}), as a dataset’s PUT and CKAN’s resource_update do
  • These keys are reserved and answer 400 (details.issues[].path is ["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_type and so on). The CKAN-compatible API spreads extras onto 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)
{
"name": "Population",
"url": "https://example.com/population.csv",
"extras": { "unit": "persons" }
}
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).

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 / to limit it to pages (slides, sheets). Returns units (what the document counts, and the first and last), entries (each heading’s depth, title and units; a sheet’s rows), unreadable (pages whose text cannot be read, such as scans) and partlyUnreadable (pages with some characters that cannot be read).
  • document-section: from / to (pages, slides or sheets, 1-based), heading (part of a heading, a path A > B, or part of a sheet’s name; case, width and spaces are ignored), max_chars (default: 8,000, max: 32,000). parts have the shape of document-text’s blocks; text is the part cut, and offset where in the block’s text it starts. A sheet block’s loc.rows are narrowed to the rows of the part. Where more text follows, next holds a cursor: pass it as cursor to read on. A cursor carries the range and the heading, so from / to / heading are then not read. Where nothing matches, parts is empty and unmatched.units gives 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.

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 / WITH statement only. Writes and DDL (INSERT / UPDATE / DELETE / CREATE / DROP / ATTACH / COPY / PRAGMA, …) are rejected.
  • PIVOT needs an explicit IN (…) 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

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 404 even 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 answers 410 Gone rather than handing back a table with two versions mixed into it
  • $select, $filter, $orderby, $apply, $search and $expand are not supported and answer 501. 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 RowId column holding the row’s position. Which of the two it is comes back in /schema as odataKey
  • A resource whose headings are not EDM identifiers (brackets, spaces, punctuation) answers 501; /schema’s odataRefusal says which headings
  • $metadata revalidates on every request (public, no-cache), so a stale declaration of the columns is never paired with rows read under a newer one

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": []
}
  • keyed is true only when both ends were read under the same primary key, and adds changedRows, sampleChangedAfter and sampleChangedBefore when 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 is false and 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": …}, where reason is no-previous-version / not-ingested / purged / key-missing / key-null / key-not-unique / ingest-failed. The three key-* values mean the declared primary key did not identify that version’s rows, so it was never loaded; ingest-failed means loading it was attempted repeatedly and given up on. not-ingested is 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 — noTableReason settles four of them, and the version is where they are answered
  • reasonVersion says 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, and not-ingested is a property of one end just as easily. It is null only 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}/query sandbox; 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…-…-…" }
  • restoreTo is the destination version number, not how many rungs to step back. null leaves the resource with no file
  • ifLiveRevision is the live_revision served by GET /resources/{id}/pipeline-status. It stops a request delayed past a newer upload from reverting content it never saw
  • The same pipeline-status also returns revert_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.

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 }]
}
}
  • schema is the live version’s frozen interpretation — the same columns a PUT is validated against, so a key chosen from this list cannot be refused for naming a column that is not there
  • unique marks 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 content
  • carried is false while the newest standing version has not been read under primaryKey yet. 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" }
  • fault is null when the key would work, or key-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: the PUT asks 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 }
  • [] and null both 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. queued says the rebuild that issues it is on its way; null means none was owed; false means 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 ignores false leaves 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

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.rule is the rule that decided the role, and share the share of values it measured
  • The examples of a numeric-text column are values that do not read as numbers — what the publisher has to fix
  • columns is null for a table whose roles have not been worked out yet
  • Read-only in this version; overriding a role is planned for a future version

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

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
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
Method Path Description Auth
POST /api-tokens Create token Auth
GET /api-tokens List own tokens Auth
DELETE /api-tokens/{id} Revoke token Auth
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