Tidenda

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 a details array. GET /api/keys and CSV exports have no envelope.
  • Organisation. Every /v1 call 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 returns 409 CONFLICT and changes nothing, so a retry after a network failure is safe.
  • Deleted id. Deleting a document reserves its id for good. A create with it returns 409 DOCUMENT_DELETED; send the document under a new id.
  • Same source item. A core/newswire whose meta.source and meta.sourceItemId match an existing wire returns 200 with "idempotent": true and the existing wire's id, whatever id the request carried. This check comes first, so a retried wire gets 200 rather than 409. 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 to limit documents ordered by updatedAt, newest first, ties broken on id. Timestamps are UTC with microseconds.
  • content: the whole document as it is now, without history and internal. 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. null on the last page. Treat it as opaque.
  • hasMore: true while 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 follows PUT /v1/phrases.
  • Content-Type: text/csv: the export's CSV. language and text columns are required; level defaults to error.

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: the keyId signing new material, or null when signing is off on the deployment.
  • keys: every key to trust, current and retired. Pick the entry whose keyId matches the signature. validUntil is 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.