# 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](/_docs/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/current` and friends — see [Posts and publishing](/_docs/posts)) — 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:

```bash
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](/_docs/posts)'s "Static PDF export"):

**`GET /_static/<uid>.json`**

- native `kartotek-post` format, 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 Conflict` on submit** — the requested uid is already taken (by a Post, a Category, a historical alias, or another import already in progress for it). Change `post.uid` and resubmit.
- **Post imports uncategorized unexpectedly** — `post.categoryUid` didn'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 Request` with 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

```json
{
  "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](/_docs/posts)'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](/_docs/posts)'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

```json
{
  "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](/_docs/posts)). `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](/_docs/media); Attachment never uses this form, since it has no Embed mode):

```json
{ "mode": "embed", "embedHtml": "<iframe …></iframe>", "caption": "…" }
```

File form — url-only (the static public export's shape):

```json
{ "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`):

```json
{ "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](/_docs/media)) — 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](/_docs/categories)'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:

```json
{
  "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

```json
{
  "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](/_docs/posts)'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](/_docs/posts) and the three social Types' own Schema pages ([Facebook Post](/_docs/facebook_post), [Instagram Post](/_docs/instagram_post), [LinkedIn Post](/_docs/linkedin_post)). `tags` is this Version's own Tags (see [Organizing and presenting content](/_docs/categories)'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](/_docs/posts)'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:

```json
{
  "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:

```json
{
  "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):

```json
{
  "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](/_docs/addressing)), 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.sourceLanguage` preserved 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](/_docs/posts)'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:

```bash
curl -H "Authorization: Bearer kit_xxxxxxxx…" \
  "https://hoejriis.dk/_api/import/inventory?platform=facebook&limit=50"
```

```json
{
  "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 of `facebook`, `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 recorded `sourceProvenance` at all (missing optional identity is represented as `null` per field, never omitted or fabricated).
- Results include Posts in **every** Stage — Published, PrePub, Hidden, and Private — with `stage` reported 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) and `cursor` (optional, opaque — pass back the previous response's own `nextCursor`) paginate deterministically by Post id ascending. `nextCursor` is `null` once 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`; `sourceUrl` or `sourceChecksum` may 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

```bash
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 when `autoFinalize` was requested and nothing failed). Body includes `resultPostId` and the full report.
- **`202 Accepted`** — the job is valid but staged for review (a media item failed, or `autoFinalize` wasn'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](/_docs/media)): 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](/_docs/posts) for the Post/Version/ContentElement model this format serializes, [Media and the Media Archive](/_docs/media) for how Image/Video/Audio ingestion and its security limits work, [Domains, UIDs and addresses](/_docs/addressing) for uid/alias rules, and [Administrator quick start](/_docs/admin-quickstart) for the admin editor generally.
