Export

Boards as files you can keep.

Two downloads, both zips. One board, for anybody who can see it; the whole instance, for its administrators. A board's JSON is the same from either, so a script written against one reads the other.

GET /api/data/board-export

One board: its JSON, and the files its cards hold. Needs read access to the board.

Archived areas and cards are included, each with its archivedAt. An export is what somebody keeps, and a card archived last week is still work that happened.

Parameters

NameTypeRequiredDescription
boardIdintegeryesThe board to export.

What comes back

A zip named <id>-<name>-<date>.zip:

12-website-relaunch.json
attachments/
  41-Quartalszahlen.xlsx
  images/3f9c0b7d4e2a8c1f6b5d9e0a7c3b2f1e.webp

Attachments keep their own names, prefixed with their id so two files called Screenshot.png stay two files. Pictures pasted into a description or a comment are in images/, under the name they were stored with.

curl "https://boards.example.com/api/data/board-export?boardId=12" \
  -H "X-API-Key: $LOKALBOARDS_KEY" \
  -o website-relaunch.zip
import { writeFile } from "node:fs/promises";

const response = await fetch(
  "https://boards.example.com/api/data/board-export?boardId=12",
  { headers: { "X-API-Key": apiKey } },
);

if (!response.ok) throw new Error(await response.text());

await writeFile("website-relaunch.zip", Buffer.from(await response.arrayBuffer()));
<script setup>
const config = useRuntimeConfig();

async function download() {
  const zip = await $fetch("https://boards.example.com/api/data/board-export", {
    headers: { "X-API-Key": config.lokalBoardsKey },
    query: { boardId: 12 },
    responseType: "blob",
  });

  const link = document.createElement("a");
  link.href = URL.createObjectURL(zip);
  link.download = "website-relaunch.zip";
  link.click();
  URL.revokeObjectURL(link.href);
}
</script>

<template>
  <button type="button" @click="download">Export board</button>
</template>
export function ExportButton({ apiKey, boardId }) {
  async function download() {
    const response = await fetch(
      `https://boards.example.com/api/data/board-export?boardId=${boardId}`,
      { headers: { "X-API-Key": apiKey } },
    );
    if (!response.ok) throw new Error(await response.text());

    const link = document.createElement("a");
    link.href = URL.createObjectURL(await response.blob());
    link.download = `board-${boardId}.zip`;
    link.click();
    URL.revokeObjectURL(link.href);
  }

  return <button onClick={download}>Export board</button>;
}
<?php

$file = fopen('website-relaunch.zip', 'w');
$ch = curl_init('https://boards.example.com/api/data/board-export?boardId=12');

curl_setopt($ch, CURLOPT_FILE, $file);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'X-API-Key: ' . $apiKey,
]);

curl_exec($ch);
curl_close($ch);
fclose($file);

GET /api/data/export

Everything on the instance. Needs an administrator: a session of one, or an API key one of them issued. Anybody else is answered 403.

What comes back

A zip named lokalboards-export-<date>.zip:

boards/
  12-website-relaunch.json
  13-personal.json
attachments/
  12-website-relaunch/
    41-Quartalszahlen.xlsx
    images/3f9c0b7d4e2a8c1f6b5d9e0a7c3b2f1e.webp
database.sql

boards/ holds every board, archived ones included, each in the board file format. attachments/ holds each board's files in a folder named like its JSON, and the paths inside that JSON point there.

database.sql is the whole database: each table's CREATE TABLE, then its rows. It restores into an empty database the way any dump does:

mysql -u lokalboards -p lokalboards < database.sql

The rows of session and verification are left out — the tables are created, empty — so everybody signs in again after a restore rather than finding logins brought back that had ended. Everything else is in it, password hashes included: keep the zip the way you keep the database.

The response begins straight away and the zip is written while it is sent, so a large instance takes longer to finish, not longer to start. Give the request a generous timeout rather than a short one.

# Nightly, from cron: one dated zip per day.
curl "https://boards.example.com/api/data/export" \
  -H "X-API-Key: $LOKALBOARDS_ADMIN_KEY" \
  --fail -o "lokalboards-$(date +%F).zip"
import { createWriteStream } from "node:fs";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";

const response = await fetch("https://boards.example.com/api/data/export", {
  headers: { "X-API-Key": adminKey },
});

if (!response.ok) throw new Error(await response.text());

// Streamed to disk rather than held in memory: this can be large.
await pipeline(
  Readable.fromWeb(response.body),
  createWriteStream(`lokalboards-${new Date().toISOString().slice(0, 10)}.zip`),
);
<script setup>
// In an internal tool running on the same instance, where the administrator is
// already signed in: a plain link is enough, and the browser streams it.
</script>

<template>
  <a href="/api/data/export">Export all data</a>
</template>
// Same instance, administrator signed in: the session cookie does the rest.
export function ExportAll() {
  return <a href="/api/data/export">Export all data</a>;
}
<?php

$file = fopen('lokalboards-' . date('Y-m-d') . '.zip', 'w');
$ch = curl_init('https://boards.example.com/api/data/export');

curl_setopt($ch, CURLOPT_FILE, $file);
curl_setopt($ch, CURLOPT_TIMEOUT, 0);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'X-API-Key: ' . $adminKey,
]);

curl_exec($ch);
curl_close($ch);
fclose($file);

The board file

One JSON object per board.

FieldTypeDescription
formatstringAlways lokalboards.board.
formatVersioninteger1. Raised if a field changes what it means; new fields can appear without it.
lokalboardsVersionstringThe version that wrote the file.
exportedAtstringWhen it was written.
boardobjectid, name, style, status, color, image, owner and archivedAt.
membersarrayEveryone invited to the board, each a person with a permission of read or edit. The owner is board.owner.
labelsarrayThe words in use on the board: id and name.
areasarrayIn the board's order: id, name, sort, archivedAt, and its cards.
imagesobjectEvery uploaded picture named in a description, a comment or the board's cover, from its address to its path in the zip.

Each card:

FieldTypeDescription
id, nameAs on the board.
contentstringThe description as it is stored, in HTML. Pictures keep their addresses; look them up in images.
donebooleanWhether it is ticked off.
sortintegerIts position in its area.
dueDatestring | nullWhen it is due.
assigneeperson | nullWho it is assigned to.
labelsarrayThe names of the labels it wears.
archivedAtstring | nullWhen it was archived, if it was.
remindersarrayMinutes before the due date that a reminder goes out.
attachmentsarrayid, filename, filetype, filesize, createdAt, and file: its path in the zip, or null when the file was missing on the server. A linked rather than uploaded attachment has a url instead.
commentsarrayid, author (a person), content in HTML, createdAt.
activityarrayThe card's history: type, actor (a person), data with the detail of that kind of event, createdAt.

A person is { "id": "…", "name": "…" } — no email addresses. Dates are ISO 8601 in UTC, and anything unset is null.

{
  "format": "lokalboards.board",
  "formatVersion": 1,
  "lokalboardsVersion": "0.38.0",
  "exportedAt": "2026-09-13T08:00:00.000Z",
  "board": {
    "id": 12,
    "name": "Website Relaunch",
    "style": "kanban",
    "status": "private",
    "color": null,
    "image": "/images/board_placeholder_03.webp",
    "owner": { "id": "8b1c…", "name": "Florian" },
    "archivedAt": null
  },
  "members": [{ "id": "2f0a…", "name": "Anna", "permission": "edit" }],
  "labels": [{ "id": 7, "name": "Webdesign" }],
  "areas": [
    {
      "id": 31,
      "name": "Todo",
      "sort": 0,
      "archivedAt": null,
      "cards": [
        {
          "id": 501,
          "name": "Contact form",
          "content": "<p>Spam filter first.</p><img src=\"/api/uploads/3f9c…e1.webp\">",
          "done": false,
          "sort": 0,
          "dueDate": "2026-09-20T15:00:00.000Z",
          "assignee": { "id": "2f0a…", "name": "Anna" },
          "labels": ["Webdesign"],
          "archivedAt": null,
          "reminders": [60],
          "attachments": [
            {
              "id": 41,
              "filename": "Quartalszahlen.xlsx",
              "filetype": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
              "filesize": 18233,
              "createdAt": "2026-09-01T09:12:00.000Z",
              "file": "attachments/41-Quartalszahlen.xlsx"
            }
          ],
          "comments": [
            {
              "id": 90,
              "author": { "id": "8b1c…", "name": "Florian" },
              "content": "<p>Looks good.</p>",
              "createdAt": "2026-09-02T10:00:00.000Z"
            }
          ],
          "activity": [
            {
              "type": "assigned",
              "actor": { "id": "8b1c…", "name": "Florian" },
              "data": { "assigneeName": "Anna" },
              "createdAt": "2026-09-01T09:00:00.000Z"
            }
          ]
        }
      ]
    }
  ],
  "images": {
    "/api/uploads/3f9c…e1.webp": "attachments/images/3f9c…e1.webp"
  }
}