DEV Community

Cover image for Export a Confluence Cloud space to a real folder tree, with only the attachments you want
Mihai Perdum
Mihai Perdum

Posted on Originally published at leanzero.net

Export a Confluence Cloud space to a real folder tree, with only the attachments you want

Export a Confluence Cloud space to a real folder tree, with only the attachments you want

Key takeaways

  • On Confluence Cloud the HTML export puts every page in one flat folder and attachments in SPACEKEY/attachments/pageID/attachmentID.ext — the original filenames are not in the file tree at all.
  • The page tree is not lost. index.html carries the whole hierarchy as nested lists, folders included.
  • There is no REST endpoint for space exports (CONFCLOUD-40457, 455 votes, still Gathering Interest), but the browser's own actions accept an API token and can be driven headlessly.
  • The obvious tree-rebuilding script loses pages on two silent failure modes: parents that are folders, and two titles that sanitise to the same folder name.

Somebody asked this on the Atlassian Community in July, and it is the plainest version of a question I get in every migration conversation:

My case is that I need to download the whole space with the same hierarchy of the tree, and with that I need to download a specific attachments from every page, is that possible with native ways or do I need scripts or plug-ins or an app from the marketplace.

I answered that thread badly the first time. I said the HTML export preserves the tree as folders. Jason Krewson went and tested it, said it does not, and he was right — I had to post a correction three hours later with a script instead.

This is the version of that answer I should have written. Everything below was run on 6 August 2026 against a throwaway space on our test site, and I have kept the failures in, including the two bugs in the script I posted to that thread.

The test space

Nine pages, one folder, nine attachments, and two deliberate traps:

Newsroom export test Home
├── Contracts
│   ├── 2026 Q1          statement.pdf, logo.png, notes.txt
│   └── 2026 Q2          statement.pdf, diagram.png
├── Runbooks
│   └── Failover         runbook.pdf
├── Reports / Q3         q3.pdf
├── Reports : Q3         statement.pdf
└── Archive              (a FOLDER, not a page)
    └── 2025 archive     archive.pdf
Enter fullscreen mode Exit fullscreen mode

"Reports / Q3" and "Reports : Q3" are two different pages whose titles collapse to the same string once you strip the characters a filesystem will not take. "Archive" is one of the folders Confluence Cloud grew a while back, and a folder is not a page. Both of those matter later.

One thing I could not build: two pages with the same title. Confluence Cloud rejects it outright with a 400 BAD_REQUEST whose message reads "A page with this title already exists: A page already exists with the same TITLE in this space", which is CONFCLOUD-2524, closed Won't Fix with 353 votes. Titles are unique within a space, and that is the only reason building folders out of titles is viable at all.

What the HTML export actually puts on disk

Space settings, then Export space, then HTML. The zip has 31 entries, 21 of them files and 10 of them bare directory records. Here are the 21 files:

NRXPORT/2025-archive_294387733.html
NRXPORT/2026-Q1_294322177.html
NRXPORT/2026-Q2_294354945.html
NRXPORT/294420481.html
NRXPORT/294453249.html
NRXPORT/Archive_294518791.html
NRXPORT/Contracts_294289409.html
NRXPORT/Failover_294289429.html
NRXPORT/Newsroom-export-test-Home_294224320.html
NRXPORT/Runbooks_294387713.html
NRXPORT/attachments/294289429/294453275.pdf
NRXPORT/attachments/294322177/294453269.png
NRXPORT/attachments/294322177/294486017.pdf
NRXPORT/attachments/294322177/294518785.txt
NRXPORT/attachments/294354945/294551553.pdf
NRXPORT/attachments/294354945/294584321.png
NRXPORT/attachments/294387733/294486023.pdf
NRXPORT/attachments/294420481/294584327.pdf
NRXPORT/attachments/294453249/294649857.pdf
NRXPORT/index.html
NRXPORT/styles/site.css
Enter fullscreen mode Exit fullscreen mode

Three things in there are worth stopping on.

Every page is in one folder. Ten content HTML files — the nine pages and the folder — sitting at the same level next to index.html, with no nesting whatsoever. That is what Jason found and what Atlassian's own KB says: "the pages are included in a folder, but they are not organized in the same structure as the space's page tree" (How to get the page tree structure in an HTML export, last updated 25 September 2025, applies to Cloud and Data Center).

The attachment path I quoted is the Data Center one. What I wrote in that correction was download/attachments/<pageID>/. On Cloud in August 2026 it is <SPACEKEY>/attachments/<pageID>/, with no download segment. The ...\download\attachments\xxxxxx wording is real, but it is on the Data Center page, not the Cloud one. If you have a script that globs download/attachments, it finds nothing on Cloud and tells you the space has no attachments.

The filenames are gone. statement.pdf came out as 294486017.pdf. Every attachment is named after its attachment ID, keeping only the extension. This is the fact that decides the whole "specific attachments" question, and I will come back to it.

Two smaller things. A page whose title contains a character the export will not slugify loses the title from its filename entirely — I tested a slash and a colon: "Reports / Q3" became 294420481.html and "Reports : Q3" became 294453249.html, bare IDs with no human-readable part. And the folder was exported as Archive_294518791.html, a real HTML file, so folders do come through.

The zip contains an entry literally named /. Info-ZIP unzip 6.00, the one macOS ships, refuses it with warning: stripped absolute path spec from / followed by mapname: conversion of failed, and exits 2 — while still extracting all 21 files. Under set -e that kills your script on a successful extraction. bsdtar 3.5.3 reads the same zip and exits 0, as does Python's zipfile.

The tree is not lost, it is in index.html

This is the part I missed in both of my community answers, and it changes the shape of the problem. index.html carries the entire hierarchy as nested lists, with the folder in the right place:

<li><a href="Newsroom-export-test-Home_294224320.html">Newsroom export test Home</a>
  <ul><li><a href="Contracts_294289409.html">Contracts</a>
    <ul><li><a href="2026-Q1_294322177.html">2026 Q1</a></li></ul>
    <ul><li><a href="2026-Q2_294354945.html">2026 Q2</a></li></ul>
  </li></ul>
  <ul><li><a href="Archive_294518791.html">Archive</a>
    <ul><li><a href="2025-archive_294387733.html">2025 archive</a></li></ul>
  </li></ul>
</li>
Enter fullscreen mode Exit fullscreen mode

So does the filename mapping. Each exported page lists its own attachments with the real name as the link text:

<a href="attachments/294322177/294486017.pdf">statement.pdf</a> (application/pdf)
Enter fullscreen mode Exit fullscreen mode

Both ingredients for an offline reconstruction are therefore in the zip: the shape in index.html, the real filenames in each page's own attachment list. I did not build that reconstruction, so treat it as a route I have verified the inputs for rather than one I have run. If you already have an export on disk and no API token, that is where I would start. Otherwise the API is cleaner, and the rest of this is about the API.

You can trigger the export without a browser

There is no REST endpoint for this. The request is CONFCLOUD-40457, open since January 2016, 455 votes, status Gathering Interest, last updated 5 August 2026. I read that off the ticket this morning rather than trusting my memory, because it is the kind of thing that would be embarrassing to get wrong.

What does work is the page the browser itself posts to. The atlassian-python-api community library has carried this call since long before I looked, in a method its own docstring labels "an experimental method that does not trigger an officially supported REST endpoint" (I read the source of version 4.0.7). I re-implemented it in plain curl to watch it work end to end, and it authenticates with an ordinary email plus API token.

  1. Get the XSRF token GET /wiki/spaces/exportspacehtml.action?key= returns the export form. Scrape out of it, keeping the cookie jar.
  2. Post the export POST /wiki/spaces/doexportspace.action?key= with atl_token, exportType=TYPE_HTML, contentOption=visibleOnly, includeComments=true, confirm=Export. The response HTML carries .
  3. Poll that URI keep requesting it until the JSON says "complete":true, then pull the download path out of the message field, which is an anchor carrying the class space-export-download-path.
  4. Download it same credentials. For HTML and XML the href came back site-relative; for CSV it came back absolute, so handle both.

Swap exportspacehtml for exportspacexml or exportspacecsv, and TYPE_HTML for TYPE_XML or TYPE_CSV, and the same four steps work. One wrinkle: the HTML and XML tasks came back with a poll id shaped spe-<contentId>, the CSV one with v2-spe-<uuid>, so do not pattern-match the id. Timed on my own clock, from the POST to the completed poll, on this nine-page space:

format wall time zip size what is in it
HTML 5.3 s 46,660 bytes 10 content files, index.html, site.css, 9 attachments named by ID
XML 5.2 s 16,683 bytes entities.xml at 408,702 bytes, attachments with no extension
CSV 65.3 s 40,585 bytes 48 gzipped database tables, attachments with no extension

Do not use the elapsedTime field in the poll response as a duration. On the XML task it read 7,447 ms the moment it first reported "complete":true, and 581,510 ms when I polled the same finished task later in the session. Across one deliberately timed 20-second gap it advanced 20,465 ms, so it is a clock that never stops. The two HTML tasks froze at 2,822 ms and 3,010 ms and the CSV task at 64,507 ms, so it is not uniformly broken, which is worse. Time it yourself. I do not know why the XML task behaves differently.

The CSV export deserves a warning of its own. Atlassian's documentation says CSV exports "everything you can view, including attachments and comments by default", which is true and completely misleading about the shape. What arrives is a database dump: 49 gzipped files, of which 48 are table dumps — content.csv.gz, bodycontent.csv.gz, spacepermissions.csv.gz and fifteen AO_* app tables among them — plus an exportDescriptor.properties.gz, and attachments stored as attachments/<pageID>/<attID>/1 with no file extension at all. It is a backup artefact, not a spreadsheet. Take the 65 seconds as a hint.

Why none of this answers "specific attachments"

Because on disk the attachments no longer have names. The HTML export gives you attachments/294322177/294486017.pdf; there is nothing in that path to filter on except the extension. You can recover the names by parsing each page's HTML, and if all you need is "the PDFs" then the extension is enough. But "download the signed contract from every page" is not answerable from the export alone without that second parsing pass.

The API knows the names. That is the whole argument for doing it over REST instead.

The obvious script, and the page it ate

Here is the shape everyone reaches for, and the shape I posted to that thread. List the space's pages, build each one's folder path by walking parentId up the tree, write the body, download the attachments:

const pages = await all(`/spaces/${space.id}/pages?limit=250`);
const byId = new Map(pages.map((p) => [p.id, p]));

const dirFor = (p) => {
  const parts = []; let cur = p;
  while (cur) { parts.unshift(safe(cur.title)); cur = cur.parentId ? byId.get(cur.parentId) : null; }
  return path.join("export", ...parts);
};
Enter fullscreen mode Exit fullscreen mode

The API side of that is sound. GET /wiki/api/v2/spaces/{id}/pages does return parentId and parentType on every result, the cursor in _links.next pages correctly (I re-ran the same script at limit=2, forcing five round trips, and it still collected all nine pages), and limit caps at 250 — ask for 251 and you get 400 Provided size {251} for 'limit' is greater than the max allowed: 250.

The path building is where it falls apart. Run it against the test space and it prints one line per page, nine of them, then this:

Done: 9 pages.
Enter fullscreen mode Exit fullscreen mode

Exit code 0. Eight page.html files on disk.

A page whose parent is a folder lands at the root. byId is built from the pages collection, and a folder is not a page, so byId.get(parentId) returns undefined, the while loop stops, and "2025 archive" — which lives at Home → Archive → 2025 archive — is written to the top level as if it had no parent at all. No error, because "undefined" is a perfectly good way for that loop to end.

Two titles that sanitise to the same name share a directory. "Reports / Q3" and "Reports : Q3" both become Reports _ Q3. The second page's body overwrites the first's page.html, and both pages' attachments end up mixed in one folder. Nine pages went in, eight came out, and the script reported success.

That second one is the reason I am writing this up rather than quietly editing the thread. It is not a crash, it is a silent single-page data loss in an answer that was marked as accepted, and the only way I found it was building a space specifically designed to be awkward.

The version that does not lose pages

Four changes. Resolve folder parents by ID (there is no endpoint that lists folders in a space — /spaces/{id}/folders is a 404 and /folders without an ID is a 500 — but a GET /wiki/api/v2/folders/{id} returns the folder with its own parentId, so a cached lazy lookup up the chain works). Claim each directory path exactly once and suffix the page ID on a clash. Retry 429 and 5xx honouring Retry-After. And assert at the end that as many pages landed on disk as came back from the API, so a lossy run fails loudly instead of printing "Done".

#!/usr/bin/env node
// Export a Confluence Cloud space to a real folder tree on disk, with only the
// attachments you want. Node 18+ (uses global fetch).
//
//   CONF_BASE=https://your-site.atlassian.net \
//   CONF_EMAIL=you@example.com \
//   CONF_TOKEN=<API token> \
//   SPACE_KEY=NRXPORT \
//   KEEP='\.pdf$' \
//   node export-tree.mjs
//
// KEEP is a JavaScript regular expression tested against the attachment
// filename. Leave it unset to download every attachment.

import fs from "node:fs";
import path from "node:path";

const { CONF_BASE, CONF_EMAIL, CONF_TOKEN, SPACE_KEY, KEEP, OUT = "export" } = process.env;
for (const [k, v] of Object.entries({ CONF_BASE, CONF_EMAIL, CONF_TOKEN, SPACE_KEY })) {
  if (!v) { console.error(`missing env: ${k}`); process.exit(2); }
}
const keep = KEEP ? new RegExp(KEEP) : null;
const auth = "Basic " + Buffer.from(`${CONF_EMAIL}:${CONF_TOKEN}`).toString("base64");
const wiki = `${CONF_BASE.replace(/\/+$/, "")}/wiki`;

// Confluence Cloud answers 429 with a Retry-After. Honour it, and retry 5xx.
async function req(url, init = {}) {
  for (let attempt = 0; ; attempt++) {
    const r = await fetch(url, { ...init, headers: { Authorization: auth, ...(init.headers || {}) } });
    if (r.status === 429 || r.status >= 500) {
      if (attempt >= 5) throw new Error(`${r.status} after 6 attempts: ${url}`);
      const wait = Number(r.headers.get("retry-after")) * 1000 || 2000 * 2 ** attempt;
      console.error(`  ${r.status} — retrying in ${wait}ms`);
      await new Promise((res) => setTimeout(res, wait));
      continue;
    }
    if (!r.ok) throw new Error(`${r.status} ${await r.text().then((t) => t.slice(0, 200))} on ${url}`);
    return r;
  }
}
const getJson = async (url) => (await req(url, { headers: { Accept: "application/json" } })).json();

// Walk a v2 collection. _links.next is already /wiki-prefixed and carries the cursor.
async function all(pathAndQuery) {
  const out = [];
  let url = `${wiki}/api/v2${pathAndQuery}`;
  while (url) {
    const j = await getJson(url);
    out.push(...(j.results || []));
    const next = j._links?.next;
    url = next ? `${CONF_BASE.replace(/\/+$/, "")}${next}` : null;
  }
  return out;
}

// A page's parent can be a FOLDER, and folders are not in the pages collection.
// There is no list-folders-in-a-space endpoint, so resolve them by id, cached.
const folderCache = new Map();
async function getFolder(id) {
  if (!folderCache.has(id)) folderCache.set(id, await getJson(`${wiki}/api/v2/folders/${id}`));
  return folderCache.get(id);
}

const safe = (s) => s.replace(/[/\\:*?"<>|]/g, "_").replace(/[. ]+$/, "").slice(0, 120) || "untitled";

const space = (await getJson(`${wiki}/api/v2/spaces?keys=${encodeURIComponent(SPACE_KEY)}`)).results[0];
if (!space) throw new Error(`space not found: ${SPACE_KEY}`);
const pages = await all(`/spaces/${space.id}/pages?limit=250`);
const byId = new Map(pages.map((p) => [p.id, p]));
console.log(`${pages.length} pages in ${SPACE_KEY}`);

// Two different titles can sanitise to the same folder name ("A / B" and "A : B"
// both become "A _ B"). Claim each path once; anything else gets its id appended.
const claimed = new Map();
async function dirFor(node) {
  const parts = [];
  let cur = node;
  while (cur) {
    parts.unshift(safe(cur.title));
    if (!cur.parentId) break;
    cur = cur.parentType === "folder" ? await getFolder(cur.parentId) : byId.get(cur.parentId);
  }
  let rel = path.join(...parts);
  if (claimed.has(rel) && claimed.get(rel) !== node.id) {
    rel = `${rel} (${node.id})`;
    console.log(`  name clash — using ${rel}`);
  }
  claimed.set(rel, node.id);
  return path.join(OUT, rel);
}

let written = 0, files = 0, skipped = 0;
const manifest = [];
for (const p of pages) {
  const dir = await dirFor(p);
  fs.mkdirSync(dir, { recursive: true });
  const full = await getJson(`${wiki}/api/v2/pages/${p.id}?body-format=storage`);
  fs.writeFileSync(path.join(dir, "page.html"), full.body?.storage?.value ?? "");
  written++;
  manifest.push(`${p.id}\t${path.relative(OUT, dir)}`);

  for (const a of await all(`/pages/${p.id}/attachments?limit=250`)) {
    if (keep && !keep.test(a.title)) { skipped++; continue; }
    const link = a.downloadLink || a._links?.download;
    if (!link) { console.error(`  no download link: ${a.title}`); continue; }
    const buf = Buffer.from(await (await req(`${wiki}${link}`)).arrayBuffer());
    if (buf.length !== a.fileSize) throw new Error(`${a.title}: got ${buf.length} bytes, expected ${a.fileSize}`);
    fs.writeFileSync(path.join(dir, safe(a.title)), buf);
    files++;
  }
  console.log(path.relative(OUT, dir));
}
fs.writeFileSync(path.join(OUT, "MANIFEST.tsv"), manifest.join("\n") + "\n");

// Fail loudly rather than leave a quietly incomplete export behind.
const onDisk = manifest.length;
if (onDisk !== pages.length) throw new Error(`wrote ${onDisk} pages, expected ${pages.length}`);
if (new Set(manifest.map((m) => m.split("\t")[1])).size !== pages.length) throw new Error("two pages share a directory");
console.log(`\nOK — ${written} pages, ${files} attachments kept, ${skipped} skipped.`);
Enter fullscreen mode Exit fullscreen mode

Run it with a filter and you get the thing the original question asked for:

$ KEEP='\.pdf$' node export-tree.mjs
9 pages in NRXPORT
Newsroom export test Home
Newsroom export test Home/Contracts
Newsroom export test Home/Runbooks/Failover
Newsroom export test Home/Contracts/2026 Q1
Newsroom export test Home/Contracts/2026 Q2
Newsroom export test Home/Runbooks
Newsroom export test Home/Archive/2025 archive
Newsroom export test Home/Reports _ Q3
  name clash — using Newsroom export test Home/Reports _ Q3 (294453249)
Newsroom export test Home/Reports _ Q3 (294453249)

OK — 9 pages, 6 attachments kept, 3 skipped.
Enter fullscreen mode Exit fullscreen mode

Nine pages fetched, nine page.html files on disk, "2025 archive" nested under "Archive" where it belongs, the clash resolved and announced rather than swallowed, and the three non-PDFs left behind. KEEP is a plain regular expression against the attachment filename, so KEEP='^signed-.*\.pdf$' or a match on a.mediaType instead is a one-line change.

The fileSize assertion in the download loop earns its place. Confluence returns the expected byte count on every attachment record, and comparing it against what actually arrived is the cheapest possible guard against a truncated body that would otherwise sit in your export looking like a valid file. I checked the guard can fire rather than assuming it. Perturbing the comparison to a.fileSize + 1 stops the run on the third page with Error: runbook.pdf: got 31 bytes, expected 31 — the two numbers agree because I moved the goalposts rather than the file, but the throw path is real and it aborts before writing.

An export is a copy taken at one moment. Nothing in Confluence stops somebody replacing the signed PDF you just pulled with a new version under the same filename, and the page history will not shout about it. We built Sentinel Vault to lock individual attachments on a page so a signed-off file cannot be swapped or deleted — full disclosure, it is one of ours.

What I have not proved

I ran everything above as a site admin. Atlassian's documentation says space admin is the requirement for a space export, and I have no reason to doubt it, but I did not test the .action route from a space-admin-only account and I am not going to claim it works.

I did not test scoped API tokens against those endpoints either, only a classic one, and I did not push hard enough to hit a rate limit on repeated export triggers. If you are planning to loop this over two hundred spaces, find that ceiling before you find it in production. Which also means the 429 retry branch in the script has never actually run — it is written from the documented behaviour, not from a 429 I provoked, and it is the one piece of that file I would not call tested.

And the obvious caveat about the whole headless-trigger section: doexportspace.action is not an API. It is the page a browser posts to, it is not in any contract, and Atlassian can change it in a deploy without telling anybody. The atlassian-python-api library that carries this same call labels it experimental for exactly that reason. Everything in the script section is different — /wiki/api/v2 is a documented, supported API and I would happily put that on a schedule.

Blog posts and page comments are excluded from the HTML export according to Atlassian's Cloud documentation. My test space had neither, so that is theirs, not mine. The script above does not fetch blog posts either — /spaces/{id}/pages will not return them, and if you need them you want /spaces/{id}/blogposts as a second pass.

The one thing I would still like to know: has anybody seen the XML export's elapsedTime behave like the HTML one, or is it always a wall clock that never stops?


Originally published on leanzero.net. More Atlassian, Forge and local-AI write-ups at leanzero.net/blog, and if you're planning a migration or a Forge app, that's what we do: leanzero.net/services.

Top comments (0)