API reference
Auth, scopes, rate limits, errors and every public endpoint's request and response.
On this page
The exact shape of every public endpoint: authentication, scopes, rate limits, errors, and each request and response. For integrators writing or debugging a client.
curl -s "https://<public-api-host>/v1/documents?type=core/article&limit=1" \
-H "Authorization: Bearer vk_a1b2c3d4_J8YOBfm5LDpFwQ..."
{ "ok": true, "data": { "items": [], "nextCursor": null, "hasMore": false } }
An organisation with nothing published gets an empty page like this
one; the full item shape is under GET /v1/documents below.
Conventions
- Base URL. Every path is relative to the deployment's public API
host, written
<public-api-host>. The document, media and phrase endpoints sit under/v1; the trust list is at/api/keys. - Format. Requests and responses are JSON unless an endpoint says otherwise.
- Envelope. A success is
{ "ok": true, "data": { ... } }. An error is{ "ok": false, "error": { "code", "message" } }, with the codes under Errors below; some errors add adetailsarray.GET /api/keysand CSV exports have no envelope. - Organisation. Every
/v1call acts on the organisation that owns the key. No endpoint takes an organisation id.
Authentication
Every /v1 request carries an API key:
Authorization: Bearer vk_<prefix>_<secret>
The prefix is 8 hex characters and identifies the key in the
Settings list; the secret is a long random string. Owners and admins
create keys in Settings → API keys and choose its scopes there. The
full key is shown once, at creation, and is stored only as a hash: a
lost key is revoked and replaced, never recovered. A key carries
scopes, not roles, so it can never act as a member.
GET /api/keys needs no key.
Scopes
| Scope | Grants |
|---|---|
documents:create |
POST /v1/documents |
publisher:read |
GET /v1/documents: published articles, images, audio, tags and site configuration. GET /v1/media/images/:id/:variant and GET /v1/media/audio/:id/:variant: the files published articles use |
phrases:read |
GET /v1/phrases |
phrases:write |
PUT /v1/phrases, POST /v1/phrases/import, DELETE /v1/phrases/:id |
A key without the scope an endpoint needs gets 403 FORBIDDEN.
Rate limits
Two fixed-window ceilings apply to every /v1 request, including
requests that fail:
- Per address. Counted before the key is checked, so a flood of bad keys is turned away without touching anyone's budget.
- Per key. Counted once the key is accepted.
Each deployment sets both ceilings. Once the key is accepted, the response carries the per-key window:
| Header | Value |
|---|---|
X-RateLimit-Limit |
Requests allowed in the window |
X-RateLimit-Remaining |
Requests left in the window |
X-RateLimit-Reset |
When the window resets, in Unix seconds |
Over either ceiling the response is 429 RATE_LIMITED with a
Retry-After header in seconds. Wait that long before retrying.
Errors
{
"ok": false,
"error": {
"code": "INVALID_PAYLOAD",
"message": "Document failed schema validation: meta.sourceItemId: Required"
}
}
Branch on code; message is for people and may change.
| Code | HTTP | Meaning |
|---|---|---|
INVALID_PAYLOAD |
400 | The body is not valid JSON, misses a field, or fails the type's schema. |
UNKNOWN_FIELDS |
400 | The body has fields its type does not define. details lists them. |
UNSUPPORTED_TYPE |
400 | The document type cannot be pushed through the API. |
INVALID_STATUS |
400 | meta.status is set to something other than draft. |
INVALID_ID |
400 | An id in the path is not a UUID. |
INVALID_QUERY |
400 | A query parameter is missing or not accepted. |
AUTH_ERROR |
401 | No Authorization header, or the key is unknown or revoked. |
FORBIDDEN |
403 | The key lacks the scope, or the action needs a member permission. |
NOT_FOUND |
404 | No such resource in the organisation. |
CONFLICT |
409 | A document with this id already exists. |
DOCUMENT_DELETED |
409 | This id belonged to a deleted document. |
RATE_LIMITED |
429 | A rate limit was hit. See Retry-After. |
INTERNAL_ERROR |
500 | An unexpected failure. Retry with backoff. |
AUTH_ERROR, FORBIDDEN, RATE_LIMITED and INTERNAL_ERROR can
answer any /v1 request; the endpoint sections list only the other
codes.
POST /v1/documents
Creates one document. Scope: documents:create.
Request
The body is the document itself, with no wrapper. Its shape is the
type's schema in the Document reference, including
the rules each type lists there, such as an article's target or
slot time and its planning links, or a planning item's startdate
and enddate. A field the type does not define is refused with
UNKNOWN_FIELDS rather than ignored, so a misspelt field name is
caught.
Three types can be pushed: core/newswire, core/planning-item and
core/article. Any other type is refused with UNSUPPORTED_TYPE.
| Field | Type | Required | Notes |
|---|---|---|---|
id |
UUID string | yes | 8-4-4-4-12 hex, any version. Chosen by the caller. Compared exactly, so keep one letter case. |
type |
string | yes | A document type, such as core/newswire or core/article. |
title |
string or rich text | yes | A string becomes one paragraph of rich text. |
meta |
object | yes | Required keys depend on type. |
content |
object | yes | Type-specific. Rich-text fields accept a string or a node array. |
links |
array | yes | May be empty. |
history |
array | no | Leave it out or send []. The platform records history from then on. |
Every pushed document enters its workflow at draft, the entry
status, and moves on only through the workflow. meta.status may be
left out or set to draft; any other value is refused with
INVALID_STATUS. The platform binds the document to its
organisation's workflow for the type and sets meta.workflowId,
meta.workflowVersion and meta.version, replacing any values sent.
meta.publishedVersion is dropped, and so is any AI step state a
body carries in internal.meta (aiPending, aiFailure): only the
platform writes it.
A core/newswire also needs meta.priority (1-6), meta.origin
(wire, pressrelease or media), meta.source and
meta.sourceItemId. It may carry meta.sourceUrl, meta.byline and
content.attachments; meta.sourceRef is the platform's and is
dropped. The platform creates it at draft and moves it
to usable in the same request. That snapshots the wire and fires
matching webhooks. The wire stays at draft only if that step fails.
Planning items and articles stay at draft until someone moves them
on in the workflow.
Idempotency
- Same
id. A second create returns409 CONFLICTand changes nothing, so a retry after a network failure is safe. - Deleted
id. Deleting a document reserves itsidfor good. A create with it returns409 DOCUMENT_DELETED; send the document under a newid. - Same source item. A
core/newswirewhosemeta.sourceandmeta.sourceItemIdmatch an existing wire returns200with"idempotent": trueand the existing wire'sid, whateveridthe request carried. This check comes first, so a retried wire gets200rather than409. Wires are kept and cannot be deleted, so the existing wire is always found.
Response
| Status | Body | Meaning |
|---|---|---|
201 |
{ "ok": true, "data": { "id": "..." } } |
Created. |
200 |
{ "ok": true, "data": { "id": "...", "idempotent": true } } |
core/newswire only: the source item already exists. Nothing created. |
Errors
| Code | When |
|---|---|
INVALID_PAYLOAD |
Body not a JSON object, id not a UUID, type missing, history not empty, or the schema rejects the body. The message names each failing path. |
UNKNOWN_FIELDS |
The body has fields the type does not define. details lists their paths, such as meta.sorce; the message also names any other failure. |
UNSUPPORTED_TYPE |
type is not core/newswire, core/planning-item or core/article. |
INVALID_STATUS |
meta.status is set to anything other than draft. |
FORBIDDEN |
The key lacks documents:create. |
CONFLICT |
A document with this id exists. |
DOCUMENT_DELETED |
This id belonged to a deleted document. |
{
"ok": false,
"error": {
"code": "UNKNOWN_FIELDS",
"message": "Document has fields its schema does not define: meta.sorce. Other failures: meta.source: Required",
"details": ["meta.sorce"]
}
}
Example
curl -s -X POST "https://<public-api-host>/v1/documents" \
-H "Authorization: Bearer vk_a1b2c3d4_J8YOBfm5LDpFwQ..." \
-H "Content-Type: application/json" \
-d '{
"id": "3b2c1a40-f128-4cb6-b8c2-19a4d8d7e2f5",
"type": "core/newswire",
"title": "Cabinet reshuffle expected ahead of Thursday vote",
"meta": {
"priority": 3,
"origin": "wire",
"source": "acme-wire",
"sourceItemId": "item-20260706-0142"
},
"content": { "text": "The prime minister is expected to name three new ministers." },
"links": []
}'
{ "ok": true, "data": { "id": "3b2c1a40-f128-4cb6-b8c2-19a4d8d7e2f5" } }
GET /v1/documents
Lists published documents of one type, newest change first. Scope:
publisher:read.
Request
| Parameter | Type | Notes |
|---|---|---|
type |
string | Required. One of core/article, core/image, core/audio, core/video, core/tag, core/publisher-site, core/brand. |
status |
string | Optional. Only usable is accepted, and it is the value applied anyway. |
updatedSince |
string | Optional. ISO 8601. Only documents changed at or after this instant. |
limit |
integer | Optional. 1-200, default 50. Values below 1 become 1 and above 200 become 200; 0 or a non-number means 50. |
cursor |
string | Optional. nextCursor from the previous page, verbatim. An unreadable cursor starts from the top. |
Only documents whose current status is usable are returned. Drafts,
withheld, cancelled, custom statuses, and every other type are out
of reach.
Response
{
"ok": true,
"data": {
"items": [
{
"id": "3b2c1a40-f128-4cb6-b8c2-19a4d8d7e2f5",
"type": "core/article",
"updatedAt": "2026-07-06T14:42:00.123456Z",
"createdAt": "2026-07-06T09:15:02.004211Z",
"content": {
"id": "3b2c1a40-f128-4cb6-b8c2-19a4d8d7e2f5",
"type": "core/article",
"title": [
{
"id": "6f0e...",
"class": "text",
"type": "core/text",
"children": [{ "text": "Cabinet reshuffle expected ahead of Thursday vote" }]
}
],
"meta": { "priority": 3, "status": "usable", "version": 3, "publishedVersion": 3 },
"links": [],
"content": { "text": [] }
}
}
],
"nextCursor": "2026-07-06T14:42:00.123456Z|3b2c1a40-f128-4cb6-b8c2-19a4d8d7e2f5",
"hasMore": true
}
}
items: up tolimitdocuments ordered byupdatedAt, newest first, ties broken onid. Timestamps are UTC with microseconds.content: the whole document as it is now, withouthistoryandinternal. Rich-text fields come back as node arrays. It is not a signed snapshot: signed snapshots arrive only through webhooks.nextCursor: pass it back to get the next page.nullon the last page. Treat it as opaque.hasMore:truewhile another page exists.
To catch up after a pause, keep the newest updatedAt you have seen
and send it as updatedSince. A document that leaves usable drops
out of the results, so polling cannot see an unpublish; a webhook
reports it.
Errors
| Code | When |
|---|---|
INVALID_QUERY |
type missing or not in the list, status other than usable, or updatedSince not a timestamp. |
GET /v1/media/images/:id/:variant, GET /v1/media/audio/:id/:variant
Downloads an image or an audio file that a published article uses.
Scope: publisher:read. Each file in a webhook's embeddedAssets
and embeddedAudioAssets carries this address, with variant
original, as its readUrl.
Request
| Parameter | Notes |
|---|---|
id |
The core/image or core/audio id. |
variant |
Images: original, thumb, preview or delivery. Audio: original or waveform. |
A file is readable only while an article in status usable or
withheld uses it, meaning an image block, audio embed or audio quote
in the article body names the file. A file used only by drafts,
cancelled articles, planning items or assignments is out of reach, and
so is every file of another organisation. Download and keep the file
when the event that names it arrives: once no published article uses
it, the address answers 404.
Response
302 Found, with Location set to a storage link that expires within
10 minutes and Cache-Control: no-store. Follow the redirect at once
and do not store the link; the /v1/media address itself does not
expire. The storage link carries its own signature: do not send the
API key to it. Most HTTP clients, curl -L included, drop the
Authorization header when a redirect changes host.
original serves the uploaded bytes, the ones contentHash and
signature in the webhook cover. thumb, preview and delivery
are the derived image versions described in Media,
with no embedded metadata; an SVG image answers each of them with its
original. waveform is the recording's display waveform.
Errors
| Code | When |
|---|---|
FORBIDDEN |
The key lacks publisher:read. |
NOT_FOUND |
Unknown variant; no file of that kind with this id in the organisation; or no usable or withheld article uses the file. The answer does not say which. |
Example
curl -sL -o photo.jpg \
"https://<public-api-host>/v1/media/images/7d0c6a52-1b9e-4f3a-9a51-2f1c0e8b4d10/original" \
-H "Authorization: Bearer vk_a1b2c3d4_J8YOBfm5LDpFwQ..."
GET /v1/phrases
Exports the organisation's spelling phrases: correct forms the
spellchecker accepts, with the misspellings it flags. Scope:
phrases:read.
Request
| Parameter | Notes |
|---|---|
language |
Optional. A language code such as sv or en-gb. Omit for all. |
format |
Optional. csv returns CSV; anything else returns JSON. |
Response
{
"ok": true,
"data": {
"phrases": [
{
"id": "0b7c5e9a-3f71-4d0e-9a51-8c2f1d6e4b30",
"language": "sv",
"text": "Riksdagen",
"description": "Capitalised as an institution",
"commonMistakes": ["riksdagen", "Riksdag-en"],
"level": "error",
"createdAt": "2026-07-06T09:15:02.004Z",
"updatedAt": "2026-07-06T09:15:02.004Z"
}
]
}
}
Each phrase has the same shape as in the PUT and DELETE responses:
the id, the fields described under PUT /v1/phrases, and when it
was created and last changed.
With format=csv the body is text/csv; charset=utf-8, no envelope,
with the header row language,text,common_mistakes,description,level.
A cell in common_mistakes holds one mistake per line.
Example
curl -s "https://<public-api-host>/v1/phrases?language=sv&format=csv" \
-H "Authorization: Bearer vk_a1b2c3d4_J8YOBfm5LDpFwQ..."
PUT /v1/phrases
Creates or replaces one phrase. Scope: phrases:write.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
language |
string | yes | Lower-cased, _ read as -. Two or three letters, optional subtag: sv, en-gb. |
text |
string | yes | The correct form. Trimmed, 1-512 characters. |
commonMistakes |
string[] | no | Wrong forms to flag. Blank entries dropped; at most 500. |
description |
string | no | A note on the phrase. Defaults to "". |
level |
string | no | error (default) or suggestion: how a mistake is marked. |
A phrase is identified by language and text. Sending the same pair
again replaces its commonMistakes, description and level.
Response
{
"ok": true,
"data": {
"phrase": {
"id": "0b7c5e9a-3f71-4d0e-9a51-8c2f1d6e4b30",
"language": "sv",
"text": "Riksdagen",
"description": "Capitalised as an institution",
"commonMistakes": ["riksdagen", "Riksdag-en"],
"level": "error",
"createdAt": "2026-07-06T09:15:02.004Z",
"updatedAt": "2026-07-06T09:15:02.004Z"
}
}
}
The id deletes the phrase later. GET /v1/phrases returns it too.
Errors
| Code | When |
|---|---|
INVALID_PAYLOAD |
Body not an object, or a field fails the rules above. |
POST /v1/phrases/import
Creates or replaces many phrases at once. Scope: phrases:write.
Request
Content-Type: application/json: an array of phrase objects, or{ "phrases": [ ... ] }. Each object followsPUT /v1/phrases.Content-Type: text/csv: the export's CSV.languageandtextcolumns are required;leveldefaults toerror.
The import is all or nothing: one invalid phrase rejects the whole
request and names it (phrase[3]: text must not be empty).
Response
{ "ok": true, "data": { "imported": 42 } }
Errors
| Code | When |
|---|---|
INVALID_PAYLOAD |
Not an array, a phrase fails validation, or the CSV lacks language or text. |
DELETE /v1/phrases/:id
Deletes one phrase by its id, as returned by GET /v1/phrases and
PUT /v1/phrases. Scope: phrases:write.
Response
The deleted phrase, in the same shape as PUT /v1/phrases returns:
{
"ok": true,
"data": {
"phrase": {
"id": "0b7c5e9a-3f71-4d0e-9a51-8c2f1d6e4b30",
"language": "sv",
"text": "Riksdagen",
"description": "Capitalised as an institution",
"commonMistakes": ["riksdagen", "Riksdag-en"],
"level": "error",
"createdAt": "2026-07-06T09:15:02.004Z",
"updatedAt": "2026-07-06T09:15:02.004Z"
}
}
}
Errors
| Code | When |
|---|---|
INVALID_ID |
The id is not a UUID. |
NOT_FOUND |
No phrase with that id. |
GET /api/keys
Returns the Ed25519 public keys that verify snapshot and media signatures. No key needed, no envelope.
Response
{
"active": "void-2026-05",
"keys": [
{
"keyId": "void-2026-05",
"algorithm": "ed25519",
"publicKey": "<base64 SPKI DER>",
"validFrom": "2026-05-12T00:00:00.000Z"
},
{
"keyId": "void-2025-11",
"algorithm": "ed25519",
"publicKey": "<base64 SPKI DER>",
"validFrom": "2025-11-01T00:00:00.000Z",
"validUntil": "2026-05-12T00:00:00.000Z"
}
]
}
active: thekeyIdsigning new material, ornullwhen signing is off on the deployment.keys: every key to trust, current and retired. Pick the entry whosekeyIdmatches the signature.validUntilis absent on a key with no end date.
Cache the response for about an hour and refetch when a signature
names a keyId you do not have.