Export a Confluence Cloud space to a real folder tree, with only the attachments you want
Gabriela Perdum
Author
12 min readAugust 6, 2026
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:
text
1Newsroom export test Home
2├── Contracts
3│ ├── 2026 Q1 statement.pdf, logo.png, notes.txt
4│ └── 2026 Q2 statement.pdf, diagram.png
5├── Runbooks
6│ └── Failover runbook.pdf
7├── Reports / Q3 q3.pdf
8├── Reports : Q3 statement.pdf
9└── Archive (a FOLDER, not a page)
10 └── 2025 archive archive.pdf
"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:
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.
Warning
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:
html
1<li><ahref="Newsroom-export-test-Home_294224320.html">Newsroom export test Home</a>2<ul><li><ahref="Contracts_294289409.html">Contracts</a>3<ul><li><ahref="2026-Q1_294322177.html">2026 Q1</a></li></ul>4<ul><li><ahref="2026-Q2_294354945.html">2026 Q2</a></li></ul>5</li></ul>6<ul><li><ahref="Archive_294518791.html">Archive</a>7<ul><li><ahref="2025-archive_294387733.html">2025 archive</a></li></ul>8</li></ul>9</li>
So does the filename mapping. Each exported page lists its own attachments with the real name as the link text:
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=<KEY> returns the export form. Scrape <input name="atl_token" value="..."> out of it, keeping the cookie jar.
2
Post the export
POST /wiki/spaces/doexportspace.action?key=<KEY> with atl_token, exportType=TYPE_HTML, contentOption=visibleOnly, includeComments=true, confirm=Export. The response HTML carries <meta name="ajs-pollURI" content="rest/internals/1.0/io/export/spe-…">.
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
4 rows × 4 columnsHeader row enabled
Caution
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:
js
1const pages =awaitall(`/spaces/${space.id}/pages?limit=250`);2const byId =newMap(pages.map((p)=>[p.id, p]));34constdirFor=(p)=>{5const parts =[];let cur = p;6while(cur){ parts.unshift(safe(cur.title)); cur = cur.parentId? byId.get(cur.parentId):null;}7return path.join("export",...parts);8};
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:
text
1Done: 9 pages.
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".
js
1#!/usr/bin/env node2// Export a Confluence Cloud space to a real folder tree on disk, with only the3// attachments you want. Node 18+ (uses global fetch).4//5// CONF_BASE=https://your-site.atlassian.net \6// CONF_EMAIL=you@example.com \7// CONF_TOKEN=<API token> \8// SPACE_KEY=NRXPORT \9// KEEP='\.pdf$' \10// node export-tree.mjs11//12// KEEP is a JavaScript regular expression tested against the attachment13// filename. Leave it unset to download every attachment.1415importfsfrom"node:fs";16importpathfrom"node:path";1718const{CONF_BASE,CONF_EMAIL,CONF_TOKEN,SPACE_KEY,KEEP,OUT="export"}= process.env;19for(const[k, v]ofObject.entries({CONF_BASE,CONF_EMAIL,CONF_TOKEN,SPACE_KEY})){20if(!v){console.error(`missing env: ${k}`); process.exit(2);}21}22const keep =KEEP?newRegExp(KEEP):null;23const auth ="Basic "+Buffer.from(`${CONF_EMAIL}:${CONF_TOKEN}`).toString("base64");24const wiki =`${CONF_BASE.replace(/\/+$/,"")}/wiki`;2526// Confluence Cloud answers 429 with a Retry-After. Honour it, and retry 5xx.27asyncfunctionreq(url, init ={}){28for(let attempt =0;; attempt++){29const r =awaitfetch(url,{...init,headers:{Authorization: auth,...(init.headers||{})}});30if(r.status===429|| r.status>=500){31if(attempt >=5)thrownewError(`${r.status} after 6 attempts: ${url}`);32const wait =Number(r.headers.get("retry-after"))*1000||2000*2** attempt;33console.error(`${r.status} — retrying in ${wait}ms`);34awaitnewPromise((res)=>setTimeout(res, wait));35continue;36}37if(!r.ok)thrownewError(`${r.status}${await r.text().then((t)=> t.slice(0,200))} on ${url}`);38return r;39}40}41constgetJson=async(url)=>(awaitreq(url,{headers:{Accept:"application/json"}})).json();4243// Walk a v2 collection. _links.next is already /wiki-prefixed and carries the cursor.44asyncfunctionall(pathAndQuery){45const out =[];46let url =`${wiki}/api/v2${pathAndQuery}`;47while(url){48const j =awaitgetJson(url);49 out.push(...(j.results||[]));50const next = j._links?.next;51 url = next ?`${CONF_BASE.replace(/\/+$/,"")}${next}`:null;52}53return out;54}5556// A page's parent can be a FOLDER, and folders are not in the pages collection.57// There is no list-folders-in-a-space endpoint, so resolve them by id, cached.58const folderCache =newMap();59asyncfunctiongetFolder(id){60if(!folderCache.has(id)) folderCache.set(id,awaitgetJson(`${wiki}/api/v2/folders/${id}`));61return folderCache.get(id);62}6364constsafe=(s)=> s.replace(/[/\\:*?"<>|]/g,"_").replace(/[. ]+$/,"").slice(0,120)||"untitled";6566const space =(awaitgetJson(`${wiki}/api/v2/spaces?keys=${encodeURIComponent(SPACE_KEY)}`)).results[0];67if(!space)thrownewError(`space not found: ${SPACE_KEY}`);68const pages =awaitall(`/spaces/${space.id}/pages?limit=250`);69const byId =newMap(pages.map((p)=>[p.id, p]));70console.log(`${pages.length} pages in ${SPACE_KEY}`);7172// Two different titles can sanitise to the same folder name ("A / B" and "A : B"73// both become "A _ B"). Claim each path once; anything else gets its id appended.74const claimed =newMap();75asyncfunctiondirFor(node){76const parts =[];77let cur = node;78while(cur){79 parts.unshift(safe(cur.title));80if(!cur.parentId)break;81 cur = cur.parentType==="folder"?awaitgetFolder(cur.parentId): byId.get(cur.parentId);82}83let rel = path.join(...parts);84if(claimed.has(rel)&& claimed.get(rel)!== node.id){85 rel =`${rel} (${node.id})`;86console.log(` name clash — using ${rel}`);87}88 claimed.set(rel, node.id);89return path.join(OUT, rel);90}9192let written =0, files =0, skipped =0;93const manifest =[];94for(const p of pages){95const dir =awaitdirFor(p);96 fs.mkdirSync(dir,{recursive:true});97const full =awaitgetJson(`${wiki}/api/v2/pages/${p.id}?body-format=storage`);98 fs.writeFileSync(path.join(dir,"page.html"), full.body?.storage?.value ??"");99 written++;100 manifest.push(`${p.id}\t${path.relative(OUT, dir)}`);101102for(const a ofawaitall(`/pages/${p.id}/attachments?limit=250`)){103if(keep &&!keep.test(a.title)){ skipped++;continue;}104const link = a.downloadLink|| a._links?.download;105if(!link){console.error(` no download link: ${a.title}`);continue;}106const buf =Buffer.from(await(awaitreq(`${wiki}${link}`)).arrayBuffer());107if(buf.length!== a.fileSize)thrownewError(`${a.title}: got ${buf.length} bytes, expected ${a.fileSize}`);108 fs.writeFileSync(path.join(dir,safe(a.title)), buf);109 files++;110}111console.log(path.relative(OUT, dir));112}113fs.writeFileSync(path.join(OUT,"MANIFEST.tsv"), manifest.join("\n")+"\n");114115// Fail loudly rather than leave a quietly incomplete export behind.116const onDisk = manifest.length;117if(onDisk !== pages.length)thrownewError(`wrote ${onDisk} pages, expected ${pages.length}`);118if(newSet(manifest.map((m)=> m.split("\t")[1])).size!== pages.length)thrownewError("two pages share a directory");119console.log(`\nOK — ${written} pages, ${files} attachments kept, ${skipped} skipped.`);
Run it with a filter and you get the thing the original question asked for:
text
1$ KEEP='\.pdf$' node export-tree.mjs
29 pages in NRXPORT
3Newsroom export test Home
4Newsroom export test Home/Contracts
5Newsroom export test Home/Runbooks/Failover
6Newsroom export test Home/Contracts/2026 Q1
7Newsroom export test Home/Contracts/2026 Q2
8Newsroom export test Home/Runbooks
9Newsroom export test Home/Archive/2025 archive
10Newsroom export test Home/Reports _ Q3
11 name clash — using Newsroom export test Home/Reports _ Q3 (294453249)
12Newsroom export test Home/Reports _ Q3 (294453249)
1314OK — 9 pages, 6 attachments kept, 3 skipped.
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.
Tip
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?