Import, export and external publishing
For site owners and integrators. Kartotek's native JSON interchange format for a Post — a lossless, versioned document that can be downloaded from a Published Post's static export, uploaded through the admin editor, or submitted by an external system through a scoped API. See Welcome to Kartotek for the vocabulary this doc assumes (Post, Version, ContentElement, Role, Rank, Reference).
Getting started
Two export formats, two different jobs:
- JSON-LD (
/<uid>/metadata/currentand friends — see Posts and publishing) — descriptive metadata for a generic consumer (search engines, link previews, anything that understands Schema.org). Not lossless — don't use it to move a Post between Kartotek instances. - Native JSON (this doc) — a complete, lossless, re-importable document of a Post's own structure. Use this to back up a Post, move it to a different uid, or publish into a Kartotek instance from an external system.
Public backup vs. self-contained admin export: every Published Post has a public export at /_static/<uid>.json (media referenced by URL, Hidden Versions excluded). An admin can additionally download a self-contained variant (media embedded as base64, Hidden Versions included) for a Post in any Stage — see "Downloading an export," below.
Simplest way to import: open the admin sidebar's Imports section, paste or upload a .json document, and submit — see "Admin upload walkthrough," below.
Simplest way to publish externally, one request with a scoped token:
curl -X POST https://hoejriis.dk/_api/import/posts \
-H "Authorization: Bearer kit_xxxxxxxx…" \
-H "Content-Type: application/json" \
-d '{"document": {"format":"kartotek-post","formatVersion":2,"post":{"uid":"external-post"},"contentElements":[],"versions":[]}, "mediaMode": "automatic", "autoFinalize": true}'
A 201 means it finalized immediately into a new PrePub Post; anything else stages the job for review — see "Integration reference," below, for the full endpoint set and response codes.
Downloading an export
Every Published Post has a static, public export alongside its PDF (see Posts and publishing's "Static PDF export"):
GET /_static/<uid>.json
- native
kartotek-postformat, current version 2; - the Post's complete public Version history (Hidden Versions excluded entirely — not included and marked hidden, simply absent);
- media referenced by absolute URL, never embedded bytes;
- regenerated on every publish while the Post stays Published, deleted the moment it leaves Published — same lifecycle as the PDF.
An admin can also download a self-contained export — Hidden Versions included, media embedded as base64 data URIs — for a Post in any Stage (including PrePub), from the admin editor or directly:
GET /_api/admin/posts/<uid>/export.json?includeHidden=true&embed=true (admin session required)
Both query parameters default to false; omit either to get the public-equivalent shape from an authenticated request.
Admin upload walkthrough
From the admin sidebar's Imports section: paste a document or choose a .json file, pick a media mode (Automatic — embedded data first, otherwise fetch the URL; Embedded only; Fetch URLs), and submit. A clean import with every media item acquired successfully finalizes immediately. Anything else stages the job for review: failed media items can be repaired individually — replace the source URL or upload a replacement file directly — then retried, or the whole job finalized anyway with the still-failing items explicitly omitted (Finalize without failed media, with a confirmation listing exactly what's being dropped). A staged job can also be abandoned outright, which deletes any already-acquired media files and frees the uid.
Integration reference
The rest of this doc is detailed reference material — the exact document schema, every external-API endpoint and response code, security constraints, and troubleshooting — for building or debugging an integration, not needed to just download a backup or use the admin import UI above.
Troubleshooting, by symptom
409 Conflicton submit — the requested uid is already taken (by a Post, a Category, a historical alias, or another import already in progress for it). Changepost.uidand resubmit.- Post imports uncategorized unexpectedly —
post.categoryUiddidn't match an existing Category; check the import's report for the warning, and re-assign the Category manually after import if needed. - A media item stays failed after retrying — the source URL may be unreachable, return an unexpected content-type, or exceed the size limit (see "Security," below); replace it with a working URL or upload the file directly instead.
400 Bad Requestwith no job created — the document itself is structurally invalid (bad format/version, invalid Role, a Version referencing an unknown ContentElement key). Check the error detail against "Document structure," below.- A document is rejected for its
formatVersion— this deployment only understands the versions listed in "Format versioning," below; re-export from a compatible version or wait for this deployment to support a newer one. An older supported version (currently: 1) is never rejected for that alone — see "Format versioning."
Document structure
{
"format": "kartotek-post",
"formatVersion": 2,
"exportedAt": "2026-07-19T06:00:00.000Z",
"source": { "baseUrl": "https://hoejriis.dk", "uid": "example-post" },
"post": { "uid": "example-post", "categoryUid": "photos", "stage": "published", "sourceLanguage": "en" },
"contentElements": [],
"versions": []
}
| Field | Required | Meaning |
|---|---|---|
format |
yes | Must equal "kartotek-post". |
formatVersion |
yes | Integer; this deployment currently understands 1 through 5 (see "Format versioning," below). An unrecognized version is rejected, not guessed at. |
exportedAt |
no | Informational timestamp; ignored on import. |
source.baseUrl / source.uid |
no | Informational — where this export came from. |
post.uid |
yes | The uid a fresh import will use. Editable in the admin upload flow before starting. |
post.categoryUid |
no | An existing Category's uid. Unresolved on import → a warning, imported uncategorized. |
post.stage |
no | Informational only — an import always starts PrePub regardless of this value. |
post.sourceLanguage |
no | The Post's one authoritative language — see Posts and publishing's "Translation" section. An unrecognized value → a warning, imported using the site default instead. Never treated as authored translated content — an importer declares this as fact, it isn't detected. |
contentElements |
yes | The Post's deduplicated ContentElement pool (see below). |
versions |
yes | The complete, ordered Version history, each referencing the pool by key. |
Database ids are never part of this format. Every ContentElement instead gets an export-local key, <kind>:<n> (e.g. image:1, text:3), assigned in first-appearance order across the Version history. A ContentElement shared by several Versions (carried forward unchanged, per Posts and publishing's structural-sharing rule) appears once in contentElements and is referenced by that same key from every Version that uses it — reimporting reproduces that sharing rather than duplicating rows.
ContentElement shape
{
"key": "image:1",
"kind": "image",
"role": "cover",
"rank": 1,
"reference": "hero-image",
"createdAt": "2026-07-19T05:00:00.000Z",
"contentKey": "vLyOl-R21giF",
"data": {}
}
contentKey is this element's own stable, permanent identity — distinct from key above, which is only a within-this-document cross-reference token, regenerated fresh on every export. contentKey survives round-tripping: re-importing an exported document restores each element's original identity instead of minting a new one. Optional on import (an export produced before this field existed simply omits it, and a fresh identity is assigned automatically); when present, it's carried through as-is, not validated against any particular format.
kind is one of image, text, link, contact, video, geospatial, timeline, audio, attachment. role/reference follow the same validation as everywhere else in Kartotek (lowercase letters, numbers, hyphens — see Posts and publishing). data's shape depends on kind:
| Kind | data fields |
|---|---|
text |
bodyMarkdown (string), format ("markdown" | "html", optional — see "Text format and the shared metadata envelope," below), metadataEnvelope (optional) |
link |
url, label, note, metadataEnvelope (optional) |
contact |
contactKind (email|phone|social), value |
geospatial |
geometryType (point|linestring|polygon|heatmap), points (array of {lat, lng} — every Geometry Type is a points list, see the Geospatial Content Type's schema page), label, metadataEnvelope (optional) |
timeline |
title (optional), precision ("year" | "date" | "time" | "date_time" | "full"), events (array of {timestamp, label, description, rank} — see the Timeline Content Type's schema page). A formatVersion 1-3 document still uses the older single-date shape (dateStart, dateEnd, label, description) — see "Format versioning," below. |
image / video / audio / attachment |
see below — metadataEnvelope (optional) is additionally supported on image, video, and audio; attachment additionally supports searchText (optional); all four additionally support categoryUids/tags (optional — see below) |
Image, Video, Audio, Attachment
Embed form (no local file — see Media and the Media Archive; Attachment never uses this form, since it has no Embed mode):
{ "mode": "embed", "embedHtml": "<iframe …></iframe>", "caption": "…" }
File form — url-only (the static public export's shape):
{ "mode": "file", "contentUrl": "https://hoejriis.dk/_media/123.jpg", "embeddedData": null, "caption": "…", "metadata": null }
File form — self-contained (the admin export's shape, embed=true):
{ "mode": "file", "contentUrl": "https://hoejriis.dk/_media/123.jpg", "embeddedData": "data:image/jpeg;base64,…", "caption": "…", "metadata": null }
contentUrl is kept even when embeddedData is present, as provenance. metadata mirrors Kartotek's own extracted EXIF/capture data where present, but an importer always re-extracts fresh metadata from the actual bytes rather than trusting this field. For an attachment, data additionally carries searchText — the PDF/plain-text content extracted at the original ingestion (see Media and the Media Archive) — which an importer carries through verbatim rather than re-extracting.
categoryUids and tags (both optional, on image/video/audio/attachment only) preserve that element's own Category/Tag relationships (see Organizing and presenting content's "Categories and Tags on media") across export/re-import — omitted entirely when the element has neither, the same "omit rather than export an empty/null placeholder" rule post.categoryUid already follows. categoryUids is an array of existing Category uids; tags is an array of Tag spellings, same as a Version's own tags field. An unresolved categoryUid (a Category renamed or detached elsewhere) or a malformed Tag is skipped individually on import rather than failing the whole document — this is best-effort relationship carry-forward, not a required field. Re-importing under a new post.uid (rather than updating the original Post) never affects the source element's own relationships, only the newly-imported one's.
Text format and the shared metadata envelope
Text supports two storage formats, declared explicitly per element rather than guessed at: "format": "markdown" (the default when omitted — an older formatVersion 1 export is implicitly this) or "format": "html", a trusted, explicit escape hatch for legacy content that doesn't convert cleanly to Markdown. A Text element's bodyMarkdown field holds whichever format its own format says, never a mix.
Text, Image, Link, Geospatial, Video, and Audio ContentElements (not Contact/Attachment/Timeline) may additionally carry a metadataEnvelope object:
{
"label": "Original caption",
"description": "Longer free-text description",
"altText": "Alt text (Image only, in practice)",
"creator": "Attribution / byline / credit",
"sourceLabel": "Human-readable source name",
"sourceUrl": "https://original-source.example/item/123",
"originalDate": "1998-06-14T00:00:00.000Z",
"location": { "lat": 55.6, "lng": 12.5 },
"captureAngle": 180,
"rights": "CC-BY-4.0",
"language": "da",
"fieldOrigin": { "label": "imported", "description": "owner" },
"provenance": {
"sourceSystem": "legacy-cms-name",
"sourceItemId": "12345",
"converterName": "legacy2kartotek",
"converterVersion": "1.0.0",
"importedAt": "2026-07-23T00:00:00.000Z"
},
"extensions": {
"legacycms": { "legacyId": "12345", "legacyStatus": "archived" }
}
}
Every field is optional, and import is lenient: a malformed individual field (an invalid originalDate, out-of-range location, an unrecognized fieldOrigin value) is dropped with a warning rather than failing the whole element or document. extensions is a namespaced escape hatch for source-specific fields with no first-class home above — it's inert (never read by any rendering code) and passes through a safety filter: a key name suggesting a credential/session/token, a value that looks like serialized legacy code, active markup, an IP address, or an internal filesystem path is dropped (with a warning); nested objects/arrays aren't supported, only flat scalars; and the whole extensions object is dropped past a total size bound. This filtering is best-effort defense-in-depth, not a substitute for an external converter only ever producing safe values in the first place.
Version shape
{
"ordinal": 2,
"createdAt": "2026-07-19T05:30:00.000Z",
"postType": "article",
"title": "Example Post",
"hidden": false,
"hiddenReason": null,
"content": ["image:1", "text:2"],
"tags": ["woodworking", "journal"],
"translations": [
{ "language": "da-DK", "status": "succeeded" },
{ "language": "en", "status": "original" }
],
"sourceProvenance": null
}
ordinal is the Version's position in the Post's original full history (see Posts and publishing's "the Nth-ever-published Version") — strictly ascending, but a public export (Hidden Versions excluded) can have gaps where a Hidden Version used to be. A gap is a warning on import, not an error: the missing ordinals are reported, and the imported Post's own local ordinals come out contiguous (no fabricated Versions are ever created to fill a gap). content is the ordered list of ContentElement keys this Version references — normal Post Types are supported (article, link, image, gallery, category_presentation, fullscreen, facebook_post, instagram_post, linkedin_post), each subject to the same Display Schema described in Posts and publishing and the three social Types' own Schema pages (Facebook Post, Instagram Post, LinkedIn Post). tags is this Version's own Tags (see Organizing and presenting content's "Tags" section) in authored display spelling — optional, omitted or empty means no Tags; an individual malformed entry is dropped with a warning rather than failing the whole document, since there's no registry for a Tag to be valid or invalid against beyond its own character rules. translations reports this Version's own translation status per configured language (see Posts and publishing's "Translation" section) — export-only, informational: importing a document never restores or recreates this array, an imported Post simply starts with nothing translated yet, as if newly Published. sourceProvenance (formatVersion 5+) is this Version's own source-provenance envelope — see "Source provenance (social imports)," below; optional, null/omitted means none is recorded.
Source provenance (social imports)
Any Version — most usefully one of the three social PostTypes' own imported Versions — may carry a sourceProvenance object recording where it actually came from:
{
"platform": "facebook",
"sourceAccountId": "1234567890",
"sourceAccountName": "Martin Højriis",
"sourcePostId": "9876543210",
"sourceUrl": "https://facebook.com/martin.hoejriis/posts/9876543210",
"originalPublishedAt": "2020-06-14T18:30:00.000Z",
"originalTimezone": "Europe/Copenhagen",
"originalVisibility": "public",
"importedAt": "2026-08-15T00:00:00.000Z",
"converterName": "social2kartotek",
"converterVersion": "1.0.0",
"sourceChecksum": "b7e23ec29af22b0b4e41da31e868d57226121c84"
}
Every field is optional — missing optional source metadata never blocks import, whether the whole object is absent or just some fields within it. platform must be "facebook", "instagram", or "linkedin" when present; an unrecognized value is dropped with a warning, same leniency as every other field. sourceAccountId + sourcePostId (alongside platform) is the preferred stable identity for duplicate detection — see "Inventory endpoint," below; sourceUrl/sourceChecksum are fallbacks a converter may use when a stable id isn't available. On import, a Version submitting no sourceProvenance key at all carries forward the previous Version's own stored value unchanged (an ordinary content edit never silently wipes source facts) — but every Version this doc's own export/import machinery builds from a document always sets the key explicitly (to null when the source document has none), so a full document reconstructs exactly what it declared, never accidentally inheriting a value from elsewhere in the same import.
Examples
Minimal Image post, one Version, one ContentElement:
{
"format": "kartotek-post", "formatVersion": 1,
"post": { "uid": "a-photo" },
"contentElements": [
{ "key": "image:1", "kind": "image", "role": "cover", "rank": 1, "reference": null, "createdAt": "2026-07-19T00:00:00.000Z",
"data": { "mode": "file", "contentUrl": "https://example.com/photo.jpg", "embeddedData": null, "caption": "A photo", "metadata": null } }
],
"versions": [
{ "ordinal": 1, "createdAt": "2026-07-19T00:00:00.000Z", "postType": "image", "title": "A Photo", "hidden": false, "hiddenReason": null, "content": ["image:1"] }
]
}
Article with Text and Image, then a second Version that edits the text and carries the same image forward (structural sharing — image:1 is referenced by both Versions, defined only once):
{
"format": "kartotek-post", "formatVersion": 1,
"post": { "uid": "an-article", "categoryUid": "main" },
"contentElements": [
{ "key": "text:1", "kind": "text", "role": "body", "rank": 1, "reference": null, "createdAt": "2026-07-19T00:00:00.000Z", "data": { "bodyMarkdown": "First draft." } },
{ "key": "image:1", "kind": "image", "role": "cover", "rank": 1, "reference": null, "createdAt": "2026-07-19T00:00:00.000Z", "data": { "mode": "file", "contentUrl": "https://example.com/cover.jpg", "embeddedData": null, "caption": null, "metadata": null } },
{ "key": "text:2", "kind": "text", "role": "body", "rank": 1, "reference": null, "createdAt": "2026-07-19T01:00:00.000Z", "data": { "bodyMarkdown": "Revised text." } }
],
"versions": [
{ "ordinal": 1, "createdAt": "2026-07-19T00:00:00.000Z", "postType": "article", "title": "An Article v1", "hidden": false, "hiddenReason": null, "content": ["text:1", "image:1"] },
{ "ordinal": 2, "createdAt": "2026-07-19T01:00:00.000Z", "postType": "article", "title": "An Article v2", "hidden": false, "hiddenReason": null, "content": ["text:2", "image:1"] }
]
}
UID and Category rules
The requested post.uid is validated and checked for availability before any media is fetched — against current Post/Category uids, every historical alias (see Domains, UIDs and addresses), and any other import currently in progress for that same uid. A conflict is a hard error (409); no job is created and nothing is fetched. The admin upload flow lets the uid be edited before starting; an external API caller must submit a different uid and retry. post.categoryUid is resolved against existing Categories — if it doesn't match one, the import proceeds uncategorized with a warning rather than failing outright, and never auto-creates a Category.
Import safety
An imported Post:
- is always new — importing never merges into or overwrites an existing Post;
- always starts PrePub, regardless of the document's
post.stage— nothing is auto-published; - is reviewed through the normal admin editor before its Stage can change;
- has its declared
post.sourceLanguagepreserved exactly, and never triggers translation of any kind — import, re-import, and a later Stage change to Published all make zero requests to the external translation provider (see Posts and publishing's "Translation" section: every translation is now an explicit, separate, Full-Admin action, never an automatic side effect of anything). This specific guarantee — zero provider calls across a representative multi-Post import, re-import, and post-import publish — is proven by an automated test running a real mock provider and asserting it never receives a single request, not just a code-review claim; it's part of this repository's standard automated test run, so a regression here would fail every build.
External import API
For automated publishing, the same import machinery is reachable over HTTP with a scoped bearer token instead of an admin session. Create one from the admin Imports section's Import Tokens area — the secret is shown once, at creation, and never again; only its hash is stored. A token is scoped to posts:import — it can create/manage its own import jobs and query the deduplication inventory below; it cannot read or edit existing Posts' content, browse the database, or manage other tokens.
Endpoints
| Method & path | Purpose |
|---|---|
POST /_api/import/posts |
Submit a document. Body: { "document": {...}, "mediaMode": "automatic", "autoFinalize": true }. |
GET /_api/import/posts/:id |
Fetch the current report for a job. |
PATCH /_api/import/posts/:id/media/:key |
Replace a failed media item's source URL: { "url": "…" }. |
PUT /_api/import/posts/:id/media/:key/file |
Upload replacement bytes directly for a failed media item. |
POST /_api/import/posts/:id/retry |
Retry every currently-failed media item. |
POST /_api/import/posts/:id/finalize |
Finalize: { "allowMissingMedia": false }. |
DELETE /_api/import/posts/:id |
Abandon the job and delete any staged media files. |
GET /_api/import/inventory?platform=<facebook|instagram|linkedin> |
List already-imported Posts for one platform — see "Inventory endpoint," below. |
All accept either the admin session cookie or Authorization: Bearer <token>. A token may only ever access an import job it created itself — not another token's, and not one created through the admin UI; the inventory endpoint has no such per-job scoping (there's no "job" to own), any valid token or admin session may query it.
Inventory endpoint (external converter deduplication)
Before submitting a document, a converter can check what's already been imported for a given platform, to avoid creating a duplicate Post:
curl -H "Authorization: Bearer kit_xxxxxxxx…" \
"https://hoejriis.dk/_api/import/inventory?platform=facebook&limit=50"
{
"items": [
{
"uid": "a-facebook-post",
"postType": "facebook_post",
"stage": "prepub",
"platform": "facebook",
"sourceAccountId": "1234567890",
"sourceAccountName": "Martin Højriis",
"sourcePostId": "9876543210",
"sourceUrl": "https://facebook.com/martin.hoejriis/posts/9876543210",
"originalPublishedAt": "2020-06-14T18:30:00.000Z",
"sourceChecksum": "b7e23ec29af22b0b4e41da31e868d57226121c84",
"latestVersionId": 42,
"updatedAt": "2026-08-15T00:00:00.000Z"
}
],
"nextCursor": null
}
platform(required) is one offacebook,instagram,linkedin— it selects that platform's own social PostType (facebook_post/instagram_post/linkedin_post), so every Post of that Type is included even when it has no recordedsourceProvenanceat all (missing optional identity is represented asnullper field, never omitted or fabricated).- Results include Posts in every Stage — Published, PrePub, Hidden, and Private — with
stagereported on each record, never filtered out. This is deliberately different from every other public/admin listing in this app, which excludes non-Published content by default; the inventory's whole purpose is duplicate avoidance before submission, which requires seeing everything already imported regardless of visibility. limit(optional, default 50, max 200) andcursor(optional, opaque — pass back the previous response's ownnextCursor) paginate deterministically by Post id ascending.nextCursorisnullonce the last page has been returned. Repeated calls against unchanged data return identical pages; a Post created or edited between calls can't cause an already-issued page to skip or duplicate a record.- The endpoint reports inventory only — it never decides whether two records are duplicates. The preferred identity match is
platform+sourceAccountId+sourcePostId;sourceUrlorsourceChecksummay be used conservatively when a stable id isn't available. Multiple Posts sharing the same identity are all returned (never deduplicated or hidden) so the converter can detect and handle the ambiguity itself. - Never exposes full Post content, media, or fields belonging to any other Post Type — only the identity/diagnostic fields shown above.
Example
curl -X POST https://hoejriis.dk/_api/import/posts \
-H "Authorization: Bearer kit_xxxxxxxx…" \
-H "Content-Type: application/json" \
-d '{"document": {"format":"kartotek-post","formatVersion":1,"post":{"uid":"external-post"}, "contentElements":[...], "versions":[...]}, "mediaMode": "automatic", "autoFinalize": true}'
Responses
201 Created— validated, media acquired, and finalized immediately (only whenautoFinalizewas requested and nothing failed). Body includesresultPostIdand the full report.202 Accepted— the job is valid but staged for review (a media item failed, orautoFinalizewasn't requested). Body is the report; use the endpoints above to correct and finalize.400 Bad Request— the document itself is structurally invalid (bad format/version, invalid Role, a Version referencing an unknown ContentElement key, and so on). No job is created.409 Conflict— the requested uid is already in use, or another import for it is already in progress.401— no valid session or token.429— a token exceeded its rate limit.
Security
Every media acquisition — whether from the admin UI or the external API — goes through the same protections normal media ingestion already has (see Media and the Media Archive): per-kind size limits, an allowed content-type list, no redirect-following, and SSRF-safe URL fetching (private/loopback/link-local addresses rejected, including cloud metadata addresses). Token secrets are never logged or returned again after creation; a revoked or expired token is rejected the same generic way as a wrong one.
Format versioning
formatVersion exists so this format can evolve without breaking older exports. This deployment currently understands versions 1 through 5 — a document declaring any other version is rejected outright rather than guessed at. Version 2 added metadataEnvelope (Text/Image/Link/Geospatial, extended to Video/Audio later — still additive, no formatVersion bump needed for that extension) and format (Text) — both purely additive: a version-1 document simply doesn't have them, which imports exactly as before (Text defaults to format: "markdown", every envelope comes in null). Version 3 changed Contact's own shape: a single contactKind/value pair became an entries[] array — an older document's single-pair shape still imports, becoming a one-entry array. Version 4 changed Timeline's own shape: a single dateStart/dateEnd/label/description element became a container (title/precision/events[]) — an older document's single-date shape still imports, becoming a one-Event container with Precision "full" (see the timeline row above). Version 5 added three new postType values (facebook_post/instagram_post/linkedin_post) and each Version's own optional sourceProvenance object — both purely additive: a pre-5 document simply has neither, which imports exactly as before (no social-typed Version to begin with, sourceProvenance comes back null). The two full examples above are deliberately still formatVersion: 1, to show that an old-style document keeps working unchanged. A future version bump would document its own migration path here, not silently reinterpret an old document's fields.
See also
Posts and publishing for the Post/Version/ContentElement model this format serializes, Media and the Media Archive for how Image/Video/Audio ingestion and its security limits work, Domains, UIDs and addresses for uid/alias rules, and Administrator quick start for the admin editor generally.