The space export format
This is the developer reference for Wodo's space export format. If you just want to export or import a space, read Import and export instead — this page is for people writing tools against the format: converters from other trackers, auditing scripts, or their own backup tooling.
The format identifier is wodo-space-export-v2. The format evolves additively: new fields may appear, existing fields keep their meaning, and anything optional here may be absent in older files. An importer should ignore fields it doesn't recognize.
The two artifacts
A space exports two ways, and the import wizard accepts either:
- Data only — a single
data.jsonfile: the completeSpaceExportobject described below. - Full archive — a ZIP containing
data.jsonplus every attachment asattachments/{uuid}/{filename}. The directory name is the attachment's UUID — the join key to theattachmentsarray in the JSON; the file inside keeps its original (sanitized-at-upload) filename.
The two artifacts are self-describing and may be produced at different moments. Tools must tolerate asymmetry: JSON attachment entries missing from the ZIP (report and skip), and ZIP entries not referenced by the JSON (ignore).
Conventions
- IDs are UUID strings.
- Timestamps are ISO 8601 strings (RFC 3339 where a time is present); day-precision fields like due dates are ISO 8601 dates.
- Rich text comes in dual form: a
*_textfield with a plain-text extraction for humans and tooling, and a*_yjsfield with the authoritative content as a base64-encoded Yjs update. For items, comments, and the space overview, the update contains a ProseMirror -compatible XML fragment under the key"content"; for documents,content_yjsencodes the document's entire Yjs doc. The*_textform is lossy; the*_yjsform is exact. - Optional fields are omitted, not written as
null(exception:labels.primary_label_id, which is written asnullwhen unset). - Array order is meaningful where the object has no explicit order array:
itemsanddocumentsappear in their display order, and each item'scommentsappear in thread order.
Top level
{
"format": "wodo-space-export-v2",
"exported_at": "2026-06-11T09:00:00Z",
"space": { … },
"labels": { … },
"milestones": { … },
"cycle_config": { … },
"cycles": [ … ],
"archive_config": { … },
"views": { … },
"items": [ … ],
"documents": [ … ],
"attachments": [ … ],
"users": [ … ],
"teams": [ … ],
"overview_text": "…",
"overview_yjs": "…"
}
| Field | Presence | Meaning |
|---|---|---|
format | always | "wodo-space-export-v2" |
exported_at | always | export timestamp |
space | always | space metadata |
labels | always | label configuration |
milestones | always | milestone definitions, absolute deadlines |
cycle_config | optional | recurring-cycle configuration |
cycles | always (may be empty) | concrete cycle instances with dates |
archive_config | optional | auto-archive configuration |
views | always | saved views |
items | always | all items, including archived and deep-archived |
documents | always | all documents |
attachments | always | attachment metadata (join key to the ZIP) |
users | always | snapshot of every referenced user |
teams | always | snapshot of every referenced team |
overview_text / overview_yjs | optional | the space overview, dual form |
space
| Field | Type | Notes |
|---|---|---|
id | UUID | the source space |
name | string | |
slug | string | |
region | string | region the space lived in |
short_id_prefix | string, optional | e.g. "PROJ" |
short_id_visible | bool | |
created_at | timestamp |
labels
{
"order": ["<label_id>", …],
"primary_label_id": "<label_id>" | null,
"definitions": {
"<label_id>": {
"id": "…", "name": "Status", "description": "", "icon": "",
"values_order": ["<value_id>", …],
"values": {
"<value_id>": {
"id": "…", "name": "Done", "color": "#22c55e",
"is_completion_state": true,
"completion_prompt": "What shipped?"
}
}
}
}
}
Items hold one value per label (items[].labels maps label id → value id). A value with is_completion_state: true counts as "done" for that label; its optional completion_prompt is the question shown when an item reaches that state.
Deprecated labels and label values are included — items may still reference them — carrying three optional fields on the definition: deprecated (bool), deprecated_at (timestamp), and deprecated_by (user UUID, resolved against users).
milestones
order (array of ids) plus definitions keyed by id:
| Field | Type | Notes |
|---|---|---|
id, name | ||
description | string, optional | |
deadline | date, optional | absolute |
deprecated | bool | deprecated milestones are included |
cycles and cycle_config
Each entry in cycles is a concrete instance: { "id", "name", "start_date", "end_date", "archived" }.
cycle_config, when present: { "enabled", "pattern", "start_day", "prefix", "generate_ahead", "retain_past" }.
archive_config
{ "migration_days": <int> } — days after which archived items migrate to the deep archive.
views
order plus definitions keyed by view id:
| Field | Type | Notes |
|---|---|---|
id, name | ||
content_type | string | "items" or "docs" |
view_type | string | "board", "table", or "timeline" |
column_grouping / row_grouping | string, optional | a label id, "milestone", or "cycle" |
sort_order | string | "0" default, "1" reversed, "da"/"dd" due date asc/desc |
show_archived | bool | |
filters | string, optional | encoded filter tokens, see below |
zoom_level | string, optional | timeline only: "d", "w", "m", "q" |
Filter encoding. filters is a comma-separated token list. UUID-bearing tokens use the raw 16 UUID bytes encoded base64url without padding:
| Token | Meaning |
|---|---|
{label}.{value} | label value filter (two encoded UUIDs) |
u:{user} | assigned user |
t:{team} | assigned team |
m:{milestone} | milestone |
cy:{cycle} | cycle |
d:overdue d:today d:soon d:has d:none | due date |
p:has p:none | has/has no parent |
bl:yes bl:no | blocked state |
items
| Field | Type | Notes |
|---|---|---|
id | UUID | preserved on import |
short_id | int, optional | the number in PROJ-123; preserved on import |
title | string | |
description_text / description_yjs | optional | dual form |
labels | object | label id → value id |
assignee_user_ids / assignee_team_ids | arrays of UUIDs | resolve against users / teams |
due_date / start_date | date, optional | |
milestone_id / cycle_id | UUID, optional | |
parent_id | UUID, optional | sub-item relationship |
blocked_by | array of UUIDs | items blocking this one |
duplicate_of | UUID, optional | |
archived | bool | |
deep_archived | bool | item lives in the deep archive |
created_at / updated_at | timestamp, optional | preserved on import |
created_by | UUID, optional | resolve against users; preserved on import via the people mapping |
archived_at | timestamp, optional | when archived; preserved on import (an old value means the deep-archive clock keeps running honestly) |
completion_prompt | string, optional | snapshot of the prompt at the moment the item was completed |
completion_note_text / completion_note_yjs | optional | the completion note, dual form |
comments | array | see below |
comments
Per item, in thread order:
| Field | Type | Notes |
|---|---|---|
id | UUID | preserved on import |
author_id | UUID | resolve against users |
author_name | string, optional | display-name snapshot at export time |
content_text / content_yjs | optional | dual form |
created_at / edited_at | timestamp, optional | |
deleted | bool | soft-deleted comments keep their content |
deleted_at | timestamp, optional | |
parent_id | UUID, optional | reply threading |
documents
| Field | Type | Notes |
|---|---|---|
id | UUID | regenerated on import |
title | string | |
labels | object | label id → value id |
content_text / content_yjs | optional | dual form; content_yjs is the full Yjs doc |
archived | bool | |
archived_at | timestamp, optional | |
owner_user_ids / owner_team_ids | arrays of UUIDs | document owners; resolve against users / teams |
parent_item_id | UUID, optional | anchor item (at most one of the two parents) |
parent_milestone_id | UUID, optional | anchor milestone |
review_cadence_days | int, optional | review reminder cadence |
last_reviewed_at | timestamp, optional | exported honestly — a restore doesn't pretend documents were just reviewed |
is_template | bool | document lives in the Templates tab |
forked_from | UUID, optional | lineage to another document in this export |
created_at / created_by / updated_at | optional | provenance, as on items |
attachments
Metadata for every attachment row, whether or not the ZIP was downloaded:
| Field | Type | Notes |
|---|---|---|
id | UUID | join key to attachments/{id}/… in the ZIP |
filename | string | |
content_type | string | |
size_bytes | int | |
uploaded_by | UUID | resolve against users |
uploaded_at | timestamp | |
document_id | UUID, optional | set when attached to a document |
orphaned | bool | no longer referenced; pending deletion |
Rich-text content references attachments by URL paths containing the attachment UUID; the import rewrites these inside the Yjs blobs.
users and teams
Snapshots of every user and team referenced anywhere in the export:
"users": [
{ "id": "…", "display_name": "Alice Anders", "email": "alice@example.com" }
],
"teams": [ { "id": "…", "name": "Platform" } ]
email is the cross-instance join key for import mapping; tombstoned accounts export as "Former user" without an email, and hard-deleted users are absent (importers fall back to the per-comment author_name snapshot).
How import treats the format
- Only
"wodo-space-export-v2"is accepted. - People are mapped on a ladder: a user UUID that exists in the target organization maps directly; otherwise a matching
emailmaps to that member; otherwise the person is unmatched — their comments keep the exported name without an account, and their assignments and document ownerships are dropped. Teams map by UUID only. Everything is shown for review before the import runs. - Provenance is preserved: when
created_at/created_byare present, imported items and documents keep them (creators resolve through the people mapping; an unmatched creator falls back to the importing admin). Exports without these fields — older ones — import as before: importer + import time. Which of the two happened is recorded in the audit log. - Item and comment UUIDs carry over; document and attachment UUIDs are regenerated, with references inside rich-text blobs (and
forked_fromlineage) rewritten. - Saved-view filters survive: label, milestone, cycle, and keyword tokens carry over verbatim;
u:/t:tokens are re-encoded for the mapped person or team, and unmatched ones are removed with a warning. short_idvalues are preserved and the space's counter is seeded past the maximum.- Attachments over 50 MiB per file are skipped with a warning.
Current fidelity limitations
Known gaps in the format today — tracked, and the list shrinks as the format evolves:
- Saved views don't carry column/row collapse state or their own creation metadata.
- View filters partially encode UUIDs (base64url of the raw 16 bytes, unpadded) rather than plain UUID strings — tooling that rewrites identities must handle the token encoding.