Pages and content properties use v2 (/wiki/api/v2/...). Attachment writes
and the user lookup use v1 (/wiki/rest/api/...) because v2 does not cover
them.
Child enumeration is the third v1 exception, and the least obvious one, since v2
does have children routes. It cannot be used: v2 refuses a folder id on every
page route, so there is no way to list what is inside a folder, and
/pages/{id}/children silently omits folders from a page's children — returning
a wrong answer rather than a partial one. See folders.md.
Verified 2026-08-07. Against the gateway, GET /wiki/api/v2/pages,
GET /wiki/rest/api/content/{id}/child/attachment, and
GET /wiki/rest/api/user/current all return 200. The path suffixes are
identical whether the request goes to the site domain or the gateway, which is
why every call in internal/client is written against one baseURL.
With a cloud ID configured, requests go to
https://api.atlassian.com/ex/confluence/{cloudId} instead of the site domain.
Basic auth is unchanged; only the base URL moves.
The cloud ID is not a secret. Verified 2026-08-07:
$ curl -s https://YOUR-SITE.atlassian.net/_edge/tenant_info
{"cloudId":"<a uuid>"}No credentials: the cloud ID is not a secret. It still resolves with the URL, from the same place, because it names one site.
A scoped token requires the gateway. Verified 2026-08-20 with a scoped service-account token — the credential the earlier attempt lacked. An unscoped personal token returns 200 against both the site domain and the gateway, so it cannot distinguish the two cases; a scoped one can.
Two calls the token is scoped for, each sent twice with the same basic auth:
| request | gateway | site domain |
|---|---|---|
GET /wiki/rest/api/user/current |
200 | 401 |
GET /wiki/rest/api/space/{key} |
200 | 401 |
The site-domain 401 is a Tomcat HTML error page, not a JSON API error — the request is rejected before it reaches Confluence's API layer. Nothing about the scopes changed between the two columns, so the base URL is the only variable.
Verified 2026-08-20. Through the gateway, a request the token is not scoped for returns:
{"code":401,"message":"Unauthorized; scope does not match"}The status alone does not identify it, and neither does the status of the other auth failures. Corrected and re-measured 2026-08-21 — an earlier version of this section guessed that a bad password also returns 401, and it does not:
| what is wrong | route | status | body |
|---|---|---|---|
| token lacks the scope | v1 or v2 via gateway | 401 | {"code":401,"message":"Unauthorized; scope does not match"} |
| scoped token sent to the site domain | site domain | 401 | Tomcat HTML, no JSON at all |
| credentials wrong, revoked, or absent | v1 | 403 | caller cannot access Confluence (/user/current) or Current user not permitted to use Confluence (/search) — the two phrasings differ |
| credentials wrong, revoked, or absent | v2 | 404 | {"errors":[{"status":404,"code":"NOT_FOUND","title":"Not Found","detail":null}]} |
Two of these are worth staring at.
A rejected credential is a 404 on every v2 route. Not 401, not 403. Since
markfluence reads pages over v2, a revoked token made read answer
page 2848423944 not found for a page that exists — and the obvious next move,
go and check the page id, is wrong for every id. It is distinguishable, but only
by the title: every genuine v2 404 names what it could not find, and the
authentication one does not.
| request | title |
|---|---|
| missing page, good credentials | Cannot find a page with id [999999999999] |
| missing folder, good credentials | Content with id: [999999999999] not found |
| missing page's properties, good credentials | Could not find page with id [999999999999] |
| existing page, bad credentials | Not Found |
client.HTTPError.RejectedCredential matches on that bare title, and notFound
uses it so the …OrNil helpers stop reading a rejected credential as "absent".
A 403 does not mean what it looks like it means. It is the credential
failure on v1, not a permission failure — sending no Authorization header at
all returns exactly the same body. A genuine permission denial is a different
403, which is why the hint fires only on the two measured phrasings and stays
silent otherwise.
The v1 credential failure is the same through the gateway. Verified
2026-09-23 with a made-up username and token against
GET /wiki/rest/api/user/current, once through
api.atlassian.com/ex/confluence/{cloudId} and once through the site domain:
both answer 403 with Request rejected because caller cannot access Confluence, byte for byte. So RejectedCredential holds on either base, which
is what credentials-init relies on to refuse a wrong token it checks through
the gateway.
Scopes are fixed when a token is issued, so a missing one needs a new token, not an edited one.
Two bases exist for a reason: BaseURL() is where requests go, SiteURL() is
always the site. Anything a human will see — printed URLs, and the baseURL
handed to the converter, since rewritten links get published into the page —
must use SiteURL(), or readers get gateway URLs they cannot open.
A v1 collection cannot be paged with _links.next the way v2 paths can.
/rest/api/searchis the exception, and it fails the other way. It ignoresstartentirely and can only be paged by its cursor, and a short page there does not mean the end. Applying the offset rule below to it silently truncates the results. See search.md.
Verified 2026-08-07 against a page with 3 attachments:
| request | _links keys |
|---|---|
?limit=50 (all 3 fit) |
base, context, self — no next |
?limit=1 |
base, context, next, self |
Two problems, hence the start/limit offset paging in ListAttachments:
nextis absent when the results fit one page, so its absence cannot be used as a loop termination signal without special-casing.- When present it is
/rest/api/content/…?next=true&limit=1&start=1— relative to the/wikicontext, not a v2-style path. The same response reports_links.base = https://SITE/wikiand_links.context = /wiki.
Verified 2026-09-18 (#171), and the reason WalkSpaceOperations has its own
pager instead of calling listV1:
limit=250&start=0 -> 200 rows
limit=250&start=200 -> 250 rows
limit=500&start=0 -> 500 rows
525 spaces in total. listV1 terminates when a page comes back shorter than it
asked for — correct for the child and attachment collections above, and wrong
here — so pointing this route at it reports 200 spaces, silently, with no
error. The first version of that probe did exactly this and produced a
confident wrong answer.
Termination is an empty page and nothing else, and the offset advances by
rows returned rather than by the limit requested, because the two differ. The
walk is also bounded (maxSpacePages), since an empty page is the only end
signal offset paging has here: a server that clamped start would otherwise
hand back rows forever.
So the offset rule has two shapes, and picking by "is it v1?" is not enough — the child/attachment collections end on a short page and this one does not. Full evidence: users.md.
_links.next on a v2 collection is a site-relative absolute path carrying the
cursor and the limit, e.g. /wiki/api/v2/pages?limit=1&title=…&cursor=….
Verified 2026-08-14 against GET /wiki/api/v2/pages?title=…&limit=1 on a
title with two matches. Because the path already includes the /wiki prefix,
resolveNext appends it to the base unchanged and the gateway's
/ex/confluence/{cloudId} segment survives. That is what listV2 pages with,
and it is why ListContentProperties and SearchPagesByTitle can share one
helper.
Unlike v1, absence of next is a reliable end-of-collection signal here.
_links.download is an API path — /rest/api/content/{page}/child/attachment/{id}/download
— not the /download/attachments/... UI path. Being an API path, it works
through the gateway.
Verified 2026-08-07, following the chain by hand:
GET https://SITE/wiki/rest/api/content/…/download (Authorization: Basic …)
302 Location: https://api.media.atlassian.com/…?…token=…
GET that URL with NO Authorization header
200 171 bytes
The redirect target is a different host and carries its own credential in the
query string. Fetching it with no Authorization header at all succeeds. Go's
default redirect policy drops Authorization on a cross-host hop, which is
exactly right here.
Never install a
CheckRedirectthat forwards headers. It would send site credentials to a host that does not need them and did not ask for them.
Note the join: _links.download is context-relative, so the full URL is
<base>/wiki + the path. Joining it onto the bare site URL 404s.
Transcribed 2026-08-14 from Confluence rate limiting and the identical Jira platform guidance:
- A 429 carries
Retry-After— "only returned with 429 responses. Indicates how many seconds to wait before retrying" — along withX-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-RateLimit-NearLimitandRateLimit-Reason. - "Some transient 5xx responses (such as 503) may also include a
Retry-Afterheader. While these are not rate limit responses, you can handle them with similar retry logic." - "Only retry if the API is idempotent and the response includes a
Retry-Afterheader." - "Use exponential backoff and add random jitter to delays to avoid the thundering herd problem."
- Nothing distinguishes 500 from 503.
Not verified first-hand, on purpose. Confirming these against a live site means deliberately tripping rate limits, which degrades a shared instance for everyone else using it — a poor trade for a header the vendor publishes. If a 429 ever happens during ordinary use,
--debugprints the headers, and this section can be upgraded to Verified with no artificial load.
| response | retried? |
|---|---|
| 429 | always, any method — the request was rejected before processing |
| 502 / 503 / 504 | idempotent methods only |
any other 5xx with Retry-After |
idempotent methods only — this is how a 500 becomes retryable |
any other 5xx without Retry-After |
no |
| network error | idempotent methods only |
| anything else | no |
send centralizes all of it. Backoff is exponential from baseBackoff, jittered
by a random factor in [0.7, 1.3], capped at maxBackoff; a server-supplied
Retry-After is used as given (still capped) and is not jittered, since it
is an instruction rather than a guess.
The 500 rule is Atlassian's own — retry when the server asks to be called back — and it avoids having to guess whether a given 500 is transient. A bare 500 is usually a deterministic rejection of that particular request, so retrying it just buys backoff on something that will not succeed.
502/503/504 stay unconditional rather than also requiring Retry-After.
Conforming strictly there would stop retrying a bare 502 from a proxy, which is
transient in practice.
Worst case is long and, without --debug, silent. Five attempts at the
120-second upload timeout plus backoff is roughly twelve minutes on one
attachment. That is why retry decisions are reported through a hook.
isIdempotent counts PUT as safe to retry, which is true of HTTP PUT in
general and not true of a PUT carrying an optimistic-concurrency version.
UpdatePage sends version.number = N. If it lands and the response is lost,
the retry re-sends version N, which Confluence refuses because N already exists —
so a successful update surfaces as a failure. SetContentProperty has always had
a bespoke retry-once-that-re-reads-first for exactly this shape.
UpdatePage now recovers the same way: on any error it re-reads the page and
treats the write as successful only when the version, the title, and the
stored body all match what was sent. Version alone would be wrong — a concurrent
human edit could have created that version, and claiming success over someone
else's content is far worse than reporting a failure that actually succeeded.
Unobserved: what status a stale-version PUT actually returns. The recovery triggers on any error specifically so nothing depends on the answer; guessing a status and getting it wrong would leave the recovery silently never firing.
A page's version.number tracks the body and the title, and nothing else.
Every other write markfluence makes to a page leaves it alone, which is what
makes it usable as a record of "has this page's content moved on?" (#149).
Verified 2026-09-13 against a scratch page in a personal space, created at version 1, through the gateway:
| write | route | page version |
|---|---|---|
| create a content property | POST /wiki/api/v2/pages/{id}/properties |
unchanged |
| update a content property | PUT /wiki/api/v2/pages/{id}/properties/{propId} |
unchanged |
| add a label | POST /wiki/rest/api/content/{id}/label |
unchanged |
| remove a label | DELETE /wiki/rest/api/content/{id}/label?name= |
unchanged |
| upload an attachment | PUT /wiki/rest/api/content/{id}/child/attachment |
unchanged |
| change the body | PUT /wiki/api/v2/pages/{id} |
+1, new createdAt |
| change only the title | PUT /wiki/api/v2/pages/{id} |
+1, new createdAt |
version.createdAt moves with the number and stays put when it does, so the
timestamp is no more sensitive than the counter — adding a label does not
re-stamp the page.
A content property carries its own independent version counter: across the create-then-update above it went 1 → 2 while the page stayed at 1. So a property's version says nothing about the page's, in either direction.
Two consequences, both load-bearing.
A publish advances the page by exactly one version. update writes the body
and then applies width (two content properties), labels, and attachments, so a
naive reader might expect the live version to be several ahead of the one the
body PUT returned. It is not. Nothing after the body write touches it.
The version in the PUT /pages/{id} response is the version a later GET
reports, checked on both publishes above, so a caller recording what it
published does not need a re-read to learn the number.
Not verified: whether restoring a previous version from the UI bumps the version, or whether a move made in the UI does. Measured once, on one Cloud instance, through the gateway, with a personal token.
Verified 2026-09-25 on scratch pages in a personal space, through the
gateway, with a personal token (#10, _plans/053). There are two routes, and
they differ in the one way that matters to update.
| request | result |
|---|---|
v2 PUT /pages/{id}, version+1, same body, new parentId |
200. The page moves, and its version goes up by one. It lands last among the new parent's children. |
| the same, version unchanged | 409 Version must be incremented. A v2 move always bumps the version. |
v2 PUT, version+1, parentId: null |
200, and nothing changes. v2 cannot move a page to the top of a space. |
v2 PUT, version+1, nothing changed at all |
200, and the version does not go up. |
v1 PUT /rest/api/content/{id}/move/append/{pageId} |
200 {"pageId": …}. The page moves under the page, last. The version does not change. |
v1 move/append/{folderId} |
200. The page moves into the folder. The version does not change. |
v1 move/after/{topLevelPageId} |
200. The page moves to the top of the space (parentId null). The version does not change. |
| a page with a child, moved by either route | The child moves with it. |
| v2, target is the page's own descendant | 400 Cannot move the content here as it creates a parent-child loop. Nothing changes. |
| v2, target does not exist | 404 Cannot find content with id [1]. Nothing changes. |
So MovePage uses the v1 route for every move. Leaving the version alone keeps
a move out of update's moved-page check and its unchanged-body skip, which
both read the version, and v1 is the only route that reaches the top of a
space. There is no "append to the space": a move to the top goes after the
last page already there, which the homepage guarantees exists.
update checks for a loop before it moves, so --dry-run can report one, by
walking the target's parentId chain through GET /pages/{id} and
GET /folders/{id}. The v2 GET /pages/{id}/ancestors route would answer in
one request, but it needs read:content.metadata:confluence, a scope nothing
else markfluence does needs.
Derived 2026-08-20 from Atlassian's own OpenAPI documents, one lookup per call markfluence actually makes:
- v1:
https://dac-static.atlassian.com/cloud/confluence/swagger.v3.json - v2:
https://dac-static.atlassian.com/cloud/confluence/openapi-v2.v3.json
Each operation carries an x-atlassian-oauth2-scopes array whose entries have a
state. The table takes the Current entries; Beta alternatives are noted
below.
| call | ver | method + path | scope |
|---|---|---|---|
GetPage, GetPageOrNil |
v2 | GET /pages/{id} |
read:page:confluence |
SearchPagesByTitle |
v2 | GET /pages |
read:page:confluence |
CreatePage |
v2 | POST /pages |
write:page:confluence |
UpdatePage |
v2 | PUT /pages/{id} |
write:page:confluence |
GetFolderOrNil |
v2 | GET /folders/{id} |
read:folder:confluence |
| space key to id | v2 | GET /spaces |
read:space:confluence |
ListContentProperties |
v2 | GET /pages/{id}/properties |
read:page:confluence |
SetContentProperty (create) |
v2 | POST /pages/{id}/properties |
read:page:confluence, write:page:confluence |
SetContentProperty (update) |
v2 | PUT /pages/{id}/properties/{propId} |
read:page:confluence, write:page:confluence |
GetUser, UserInfo |
v1 | GET /user |
read:confluence-user |
CurrentUser |
v1 | GET /user/current |
read:confluence-user |
GetSpace, WalkSpaceOperations |
v1 | GET /space, GET /space/{key} |
undocumented for GET, see below |
SearchUsers |
v1 | GET /search/user |
read:content-details:confluence — granular, and implied by nothing else (users.md) |
searchCQL |
v1 | GET /search |
search:confluence |
| attachment upload | v1 | POST /content/{id}/child/attachment |
write:confluence-file |
| attachment re-upload | v1 | POST /content/{id}/child/attachment/{attId}/data |
write:confluence-file |
DownloadAttachment |
v1 | GET /content/{id}/child/attachment/{attId}/download |
readonly:content.attachment:confluence |
ListAttachments |
v1 | GET /content/{id}/child/attachment |
undocumented, see below |
ListChildPages |
v1 | GET /content/{id}/child/page |
undocumented, see below |
ListChildFolders |
v1 | GET /content/{id}/child/folder |
undocumented, see below |
ListLabels |
v2 | GET /pages/{id}/labels |
read:page:confluence |
AddLabels |
v1 | POST /content/{id}/label |
write:confluence-content |
RemoveLabel |
v1 | DELETE /content/{id}/label?name=… |
write:confluence-content |
PageState |
v1 | GET /content/{id}/state |
read:confluence-content.summary |
AvailableStates |
v1 | GET /content/{id}/state/available |
write:confluence-content |
SetPageState |
v1 | PUT /content/{id}/state |
write:confluence-content |
MovePage |
v1 | PUT /content/{id}/move/{position}/{targetId} |
write:confluence-content |
Union, which is what a token needs:
read:page:confluence
write:page:confluence
read:space:confluence
read:folder:confluence
search:confluence
read:confluence-user
write:confluence-file
readonly:content.attachment:confluence
read:confluence-content.summary
write:confluence-content
read:content-details:confluence
write:confluence-content is the entry most likely to be missing from a token
granted before labels existed (#138). It buys the two label writes and, since
#168, the page-status write and the route that lists a space's statuses (see
page-status.md — that vocabulary route is a read behind a
write scope, which is Atlassian's oddity, not ours), and since #10, update's
page move. Reading a page's labels is covered by read:page:confluence, which a
token doing anything at all already has. So the failure mode stays narrow and
recognizable — every command works, and only the label, page-status and move
halves of create/update 401.
The GET on /rest/api/space and /rest/api/space/{key} is undocumented —
Atlassian's OpenAPI document carries only POST for the collection and
PUT/DELETE for the single space — so their scopes are observed rather than
derived, the same footing as the three v1 child-listing routes above. Both work
with the union below.
read:content-details:confluence is the newest, and the only one a whole
command depends on: without it user-find 401s with scope does not match
and everything else works. It is granular, and nothing implies it —
measured, not derived, with a token holding fifteen other scopes including
read:confluence-content.all (users.md).
A scope is not a permission, and the two fail differently. Measured with
the same token, against a space its account could read but not edit: the
missing scope is a 401 saying scope does not match, while a missing
permission is a 403 naming it (User does not have Page edit permission.) —
or, on a v2 route, the 404 this file's first section is about. A token with
every scope here still writes nothing until its account has Confluence
permission on the space, which is granted separately and is not markfluence's
to ask for.
Classic (read:confluence-user) and granular (read:page:confluence) are
separate grants, and neither implies the other. Note which side of the table
each style lands on: every v2 row is granular, every v1 row is classic or a
v1-era granular name. That is not a coincidence, and it is measurable.
Verified 2026-08-20, with a scoped token holding classic
read:confluence-user and read:confluence-space.summary and no content scope.
The same permission, asked for over each API version:
| granted scope | v1 route | v2 route | ||
|---|---|---|---|---|
read:confluence-user |
GET /wiki/rest/api/user/current |
200 | GET /wiki/api/v2/users/me |
401 |
read:confluence-space.summary |
GET /wiki/rest/api/space/{key} |
200 | GET /wiki/api/v2/spaces/{id} |
401 |
Same token, same resource, same request otherwise. The classic grant is real -- it returns data over v1 -- and is still rejected over v2. So a token granted only the classic names cannot read a page, because pages are v2.
This is why the older version of this list was wrong in a way that looked right.
It named read:confluence-content.all, write:confluence-content,
read:confluence-props and write:confluence-props: all classic, all plausible,
and none of them reaching the v2 routes that do the work. The props pair turns
out to be unnecessary outright, since a v2 page property is covered by
read:page:confluence/write:page:confluence rather than by a
property-specific scope.
GET /content/{id}/child/attachment, /child/page and /child/folder are
absent from the v1 OpenAPI document and from the published REST docs, while
the PUT/POST operations on the very same attachment path are present. They
still work -- markfluence uses all three -- but their scope cannot be looked up.
read:confluence-content.summary is the requirement, from two independent
sources that agree:
- Inferred from the sibling
GET /content/{id}/descendant/{type}, which is documented, is the same kind of read, and asks for exactly that (granular equivalent:read:content-details:confluence). - Corroborated by
pchuri/confluence-cli, which calls two of these same three endpoints (/child/page,/child/attachment) and listsread:confluence-content.summaryas required. It is not a guess on their part either: they added it after a user hit a 401 without it (#76, fixed in #78, "add read:confluence-content.summary to required scopes documentation").
Still not verified here. Confirming it directly needs a token granted that
scope and nothing else adjacent. If a service account can read pages but
children, export or attachment-list still 401, this row is the first
suspect.
It is a useful second source, but only for half the list, and the half it
misses is the half that matters most. It does pages over v1
(/rest/api/content), so its read:confluence-content.all and
write:confluence-content are the v1 content scopes. markfluence does pages
over v2 and needs read:page:confluence/write:page:confluence instead.
Copying its table wholesale reproduces exactly the mistake this section
documents.
Where it does agree, it agrees exactly: search:confluence,
readonly:content.attachment:confluence and write:confluence-file match what
the OpenAPI documents say, derived independently.
Do not copy its
read:hierarchical-content:confluence. That is a real scope, but forGET /pages/{id}/direct-children(v2), which is how confluence-cli lists folders. markfluence does not call it. Their code comment justifies the choice by asserting that folders "are not exposed by the v1/content/{id}/child/*endpoints" — which contradicts what we measured:GET /wiki/rest/api/content/{id}/child/folderreturns 200 withtype: "folder"rows (folders.md, verified 2026-08-13). Our measurement is first-hand and theirs is a comment, soread:folder:confluencestays on the list andread:hierarchical-content:confluencestays off it.
There is no introspection endpoint for a scoped API token, but the scope gate runs before routing and validation, which is enough to test one scope at a time:
- 401 — the scope gate rejected it. The scope is absent.
- any other 4xx (400, 403, 404, 415) — the request got past the gate and failed later. The scope is present.
So a request aimed at a deliberately bogus id is a safe probe: it reads
nothing, writes nothing, and creates nothing, while still reporting whether the
scope is held. POST /wiki/rest/api/content/999999999999/child/attachment
answering 403 rather than 401 proves write:confluence-file is on the token
without uploading a file.
Verified 2026-08-20 — this is how the missing scope on the first scoped service-account token was found, and it distinguished six held scopes from one absent one in a single pass.
The v1 rows also carry Beta-state granular scopes:
read:content-details:confluence (user lookup, search, attachment writes),
write:attachment:confluence (attachment writes),
read:attachment:confluence (download), and for the label writes
write:label:confluence — plus read:label:confluence on the POST, which asks
for both halves the way the property writes do. They are not used above, since
Current is what Atlassian recommends, but they are what a granular-only token
would need if Atlassian ever retires the v1 classic names.
Labels are the one place where the granular name already exists on the read
side as Current: v2 has read:label:confluence, used by GET /labels and the
space-label routes. markfluence does not need it, because the route it actually
calls is GET /pages/{id}/labels, which asks for read:page:confluence
instead. Worth knowing before "labels need a label scope" gets assumed from the
name.
No delete scope appears because (currently) markfluence never deletes anything — removing a label is a v1 content write, not a delete scope.