Push your spec from CI
The push model in short: your CI pipeline uploads the OpenAPI file to a Confluence page as an attachment, using your own Confluence API token. The OpenAPI Viewer macro, set to "always latest", renders whatever is attached. The app never connects to your repository and never stores a token or any other credential, which makes it work for private repositories and keeps it Runs on Atlassian.
Prerequisites
- A Confluence page with the macro. Create the page, add the OpenAPI Viewer macro, set Source to Attachment, and choose Always latest. Do the first upload by hand or with the call below, then select the file.
- The page ID. Open the page. The URL looks like
https://your-site.atlassian.net/wiki/spaces/ENG/pages/123456789/API+Reference. The number after/pages/is the page ID. - An Atlassian API token created by the account that will perform the upload. Create it at
https://id.atlassian.com/manage-profile/security/api-tokens. Store it only as a masked secret in your CI system, never in the repository. Tokens expire, so rotate before the expiry date. - Permission. That account must be able to add attachments to the page (edit access to the page and the space's attachment permission). Use a dedicated service account so uploads survive staff changes.
A note on scoped tokens. Atlassian also offers scoped API tokens. These need Confluence scopes that cover writing attachments and are generally used through the api.atlassian.com gateway rather than your site URL. We have not verified the call below with scoped tokens, so the examples use a standard API token against your site URL.
The REST call
curl --request PUT \
--user "[email protected]:$CONFLUENCE_API_TOKEN" \
--header "X-Atlassian-Token: nocheck" \
--form "[email protected]" \
--form "minorEdit=true" \
--form-string "comment=Updated from CI" \
"https://your-site.atlassian.net/wiki/rest/api/content/123456789/child/attachment"
- Method and path:
PUT /wiki/rest/api/content/{pageId}/child/attachment - Authentication: HTTP Basic, with your Atlassian account email as the user name and the API token as the password.
X-Atlassian-Token: nocheck: required. Without it Confluence rejects the upload with an XSRF error.- Body:
multipart/form-datawith afilepart.minorEdit=truesuppresses watcher notifications, andcommentappears in the version history.
Create or update, by file name
PUT on this endpoint means "create or update". If the page has no attachment with the same file name, one is created. If it has, the upload becomes a new version of that attachment, and nothing else on the page changes. Readers of an Always latest macro see it on their next page load. Keep the file name identical on every run, because a different name creates a second attachment.
A note on API versions
As far as we know, the Confluence Cloud v2 REST API does not offer an endpoint to upload attachments, and the v1 endpoint above is the documented, stable way to do it. Atlassian is moving other endpoints to v2, so check the Confluence Cloud REST documentation for changes. We will update this page if a v2 upload endpoint appears.
Ready-made pipelines
Copy-paste pipelines for GitHub Actions, GitLab CI, Bitbucket Pipelines, and plain shell are below. Each fails the job on a non-2xx response and never prints the token.
All four make the same call: PUT {CONFLUENCE_BASE_URL}/wiki/rest/api/content/{PAGE_ID}/child/attachment with a multipart file, minorEdit=true, a comment, and the header X-Atlassian-Token: nocheck. PUT creates the attachment, or adds a new version if the page already has an attachment with the same file name.
Variables
| Variable | Kind | Example | Notes |
|---|---|---|---|
CONFLUENCE_BASE_URL | secret | https://your-site.atlassian.net | Site URL without /wiki and without a trailing path |
CONFLUENCE_EMAIL | secret | [email protected] | Email of the Atlassian account that owns the token |
CONFLUENCE_API_TOKEN | secret (masked) | (never in the repo) | Create at id.atlassian.com > Security > API tokens. Tokens expire; rotate before expiry |
PAGE_ID | plain | 123456789 | The number after /pages/ in the page URL |
SPEC_PATH | plain | openapi/openapi.yaml | Path to the spec in the repo (.yaml, .yml, or .json) |
ATTACHMENT_NAME | optional | openapi.yaml | File name on the page. Defaults to the basename of SPEC_PATH. Keep it constant |
COMMENT | optional | Updated from CI | Version comment (the pipeline snippets add the commit SHA) |
Set the three secrets in your CI system's secret store (GitHub: repository secrets; GitLab: masked CI/CD variables; Bitbucket: secured repository variables). PAGE_ID and SPEC_PATH are set in the snippet itself; change them before use. The paths/changes/includePaths filter in each pipeline must match your SPEC_PATH.
Before you start
- Create the Confluence page, add the OpenAPI Viewer macro, choose Source = Attachment and "Always latest".
- Use a dedicated service account for the token. It needs permission to add attachments to that page.
- Run the pipeline once and confirm the attachment version increases on the page.
The pipelines
GitHub Actions
# .github/workflows/publish-openapi-to-confluence.yml
#
# Uploads the OpenAPI spec to a Confluence page whenever it changes on main.
# Repository secrets to create (Settings > Secrets and variables > Actions):
# CONFLUENCE_BASE_URL e.g. https://your-site.atlassian.net (no /wiki)
# CONFLUENCE_EMAIL
# CONFLUENCE_API_TOKEN
name: Publish OpenAPI spec to Confluence
on:
push:
branches: [main]
paths:
- openapi/openapi.yaml # keep in sync with SPEC_PATH below
workflow_dispatch:
permissions:
contents: read
concurrency:
group: publish-openapi-to-confluence
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Upload spec as Confluence attachment
env:
CONFLUENCE_BASE_URL: ${{ secrets.CONFLUENCE_BASE_URL }}
CONFLUENCE_EMAIL: ${{ secrets.CONFLUENCE_EMAIL }}
CONFLUENCE_API_TOKEN: ${{ secrets.CONFLUENCE_API_TOKEN }}
PAGE_ID: "123456789" # <- your page ID
SPEC_PATH: openapi/openapi.yaml # <- path in the repo
COMMENT: "Updated from GitHub Actions (${{ github.sha }})"
run: |
set -eu
NAME="${ATTACHMENT_NAME:-$(basename "$SPEC_PATH")}"
RESPONSE_FILE="$(mktemp)"
# Credentials are passed to curl on stdin so they never appear in
# the process list. GitHub also masks secrets in logs.
HTTP_CODE="$(
printf 'user = "%s:%s"\n' "$CONFLUENCE_EMAIL" "$CONFLUENCE_API_TOKEN" |
curl --silent --show-error --config - \
--request PUT \
--header 'X-Atlassian-Token: nocheck' \
--form "file=@${SPEC_PATH};filename=${NAME}" \
--form 'minorEdit=true' \
--form-string "comment=${COMMENT}" \
--output "$RESPONSE_FILE" \
--write-out '%{http_code}' \
"${CONFLUENCE_BASE_URL%/}/wiki/rest/api/content/${PAGE_ID}/child/attachment"
)"
case "$HTTP_CODE" in
2??) echo "Uploaded ${NAME} to page ${PAGE_ID} (HTTP ${HTTP_CODE})." ;;
*) echo "Upload failed: HTTP ${HTTP_CODE}" >&2; cat "$RESPONSE_FILE" >&2; exit 1 ;;
esac
GitLab CI
# .gitlab-ci.yml (add this job to your pipeline)
#
# Uploads the OpenAPI spec to a Confluence page whenever it changes on the
# default branch.
# CI/CD variables to create (Settings > CI/CD > Variables), all "Masked"
# and, if you can, "Protected":
# CONFLUENCE_BASE_URL e.g. https://your-site.atlassian.net (no /wiki)
# CONFLUENCE_EMAIL
# CONFLUENCE_API_TOKEN
# PAGE_ID numeric Confluence page ID
# GitLab only masks values of 8+ characters with no spaces; the API token
# qualifies. Do not enable `set -x` or `CI_DEBUG_TRACE`, which would print it.
publish-openapi-to-confluence:
stage: deploy
image:
name: curlimages/curl:8.10.1
entrypoint: [""]
variables:
SPEC_PATH: openapi/openapi.yaml # <- path in the repo
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
changes:
- openapi/openapi.yaml # keep in sync with SPEC_PATH
script:
- |
set -eu
NAME="${ATTACHMENT_NAME:-$(basename "$SPEC_PATH")}"
COMMENT="Updated from GitLab CI (${CI_COMMIT_SHORT_SHA})"
RESPONSE_FILE="$(mktemp)"
# Credentials are passed to curl on stdin so they never appear in
# the process list or the job log.
HTTP_CODE="$(
printf 'user = "%s:%s"\n' "$CONFLUENCE_EMAIL" "$CONFLUENCE_API_TOKEN" |
curl --silent --show-error --config - \
--request PUT \
--header 'X-Atlassian-Token: nocheck' \
--form "file=@${SPEC_PATH};filename=${NAME}" \
--form 'minorEdit=true' \
--form-string "comment=${COMMENT}" \
--output "$RESPONSE_FILE" \
--write-out '%{http_code}' \
"${CONFLUENCE_BASE_URL%/}/wiki/rest/api/content/${PAGE_ID}/child/attachment"
)"
case "$HTTP_CODE" in
2??) echo "Uploaded ${NAME} to page ${PAGE_ID} (HTTP ${HTTP_CODE})." ;;
*) echo "Upload failed: HTTP ${HTTP_CODE}" >&2; cat "$RESPONSE_FILE" >&2; exit 1 ;;
esac
Bitbucket Pipelines
# bitbucket-pipelines.yml
#
# Uploads the OpenAPI spec to a Confluence page whenever it changes on main.
# Repository variables to create (Repository settings > Pipelines >
# Repository variables). Mark CONFLUENCE_API_TOKEN as "Secured":
# CONFLUENCE_BASE_URL e.g. https://your-site.atlassian.net (no /wiki)
# CONFLUENCE_EMAIL
# CONFLUENCE_API_TOKEN (Secured)
# PAGE_ID numeric Confluence page ID
image: atlassian/default-image:4
pipelines:
branches:
main:
- step:
name: Publish OpenAPI spec to Confluence
condition:
changesets:
includePaths:
- "openapi/openapi.yaml" # keep in sync with SPEC_PATH below
script:
- export SPEC_PATH="openapi/openapi.yaml" # <- path in the repo
- export COMMENT="Updated from Bitbucket Pipelines (${BITBUCKET_COMMIT})"
- |
set -eu
NAME="${ATTACHMENT_NAME:-$(basename "$SPEC_PATH")}"
RESPONSE_FILE="$(mktemp)"
# Credentials are passed to curl on stdin so they never appear
# in the process list or the build log.
HTTP_CODE="$(
printf 'user = "%s:%s"\n' "$CONFLUENCE_EMAIL" "$CONFLUENCE_API_TOKEN" |
curl --silent --show-error --config - \
--request PUT \
--header 'X-Atlassian-Token: nocheck' \
--form "file=@${SPEC_PATH};filename=${NAME}" \
--form 'minorEdit=true' \
--form-string "comment=${COMMENT}" \
--output "$RESPONSE_FILE" \
--write-out '%{http_code}' \
"${CONFLUENCE_BASE_URL%/}/wiki/rest/api/content/${PAGE_ID}/child/attachment"
)"
case "$HTTP_CODE" in
2??) echo "Uploaded ${NAME} to page ${PAGE_ID} (HTTP ${HTTP_CODE})." ;;
*) echo "Upload failed: HTTP ${HTTP_CODE}" >&2; cat "$RESPONSE_FILE" >&2; exit 1 ;;
esac
curl (shell script)
#!/bin/sh
# Upload an OpenAPI spec to a Confluence Cloud page as an attachment.
# PUT creates the attachment, or adds a new version if one with the same
# file name already exists on the page.
#
# Required environment variables:
# CONFLUENCE_BASE_URL e.g. https://your-site.atlassian.net (no /wiki)
# CONFLUENCE_EMAIL Atlassian account email that owns the token
# CONFLUENCE_API_TOKEN Atlassian API token (keep it in a secret store)
# PAGE_ID numeric ID of the Confluence page
# SPEC_PATH path to the spec file, e.g. openapi/openapi.yaml
# Optional:
# ATTACHMENT_NAME file name on the page (default: basename of SPEC_PATH)
# COMMENT version comment (default: "Updated from CI")
#
# Do not add `set -x`: it would print the token.
set -eu
: "${CONFLUENCE_BASE_URL:?CONFLUENCE_BASE_URL is not set}"
: "${CONFLUENCE_EMAIL:?CONFLUENCE_EMAIL is not set}"
: "${CONFLUENCE_API_TOKEN:?CONFLUENCE_API_TOKEN is not set}"
: "${PAGE_ID:?PAGE_ID is not set}"
: "${SPEC_PATH:?SPEC_PATH is not set}"
if [ ! -f "$SPEC_PATH" ]; then
echo "Spec file not found: $SPEC_PATH" >&2
exit 1
fi
BASE_URL="${CONFLUENCE_BASE_URL%/}"
NAME="${ATTACHMENT_NAME:-$(basename "$SPEC_PATH")}"
COMMENT="${COMMENT:-Updated from CI}"
RESPONSE_FILE="$(mktemp)"
trap 'rm -f "$RESPONSE_FILE"' EXIT
# Credentials go to curl on stdin (printf is a shell builtin), so they never
# appear in the process list or in any log output.
HTTP_CODE="$(
printf 'user = "%s:%s"\n' "$CONFLUENCE_EMAIL" "$CONFLUENCE_API_TOKEN" |
curl --silent --show-error --config - \
--request PUT \
--header 'X-Atlassian-Token: nocheck' \
--form "file=@${SPEC_PATH};filename=${NAME}" \
--form 'minorEdit=true' \
--form-string "comment=${COMMENT}" \
--output "$RESPONSE_FILE" \
--write-out '%{http_code}' \
"${BASE_URL}/wiki/rest/api/content/${PAGE_ID}/child/attachment"
)"
case "$HTTP_CODE" in
2??)
echo "Uploaded ${NAME} to page ${PAGE_ID} (HTTP ${HTTP_CODE})."
;;
*)
echo "Upload failed: HTTP ${HTTP_CODE}" >&2
cat "$RESPONSE_FILE" >&2
echo >&2
exit 1
;;
esac
Behaviour and safety
- Fails on non-2xx. The HTTP status is captured and any response other than 2xx prints the status and response body to stderr and exits 1, which fails the job.
- Never echoes the token. Credentials are piped to curl through
--config -on stdin, not passed with-uon the command line, so they do not appear in the process list. Do not addset -x,curl -v, orCI_DEBUG_TRACE, which can print them. - Same file name every run. A different name creates a second attachment rather than a new version.
- Multi-file specs.
$refto other files is not resolved by the viewer in v1. Bundle first, for examplenpx @redocly/cli bundle openapi.yaml -o dist/openapi.yaml, and setSPEC_PATHto the bundled file. - Paths with
;or,inSPEC_PATHare not supported by curl's form syntax. Use a plain path. - Scoped API tokens are untested with this endpoint (see the note on scoped tokens above). The snippets assume a standard API token.
- Image pins. The GitLab and Bitbucket snippets use
curlimages/curl:8.10.1andatlassian/default-image:4. Update them to current versions on your own schedule.
Checking that it worked
A successful call returns HTTP 200 with a JSON body. On the page, confirm in the attachments view that the version number went up, then reload to see the new content.
Common failures
| Response | Likely cause |
|---|---|
| 401 | Wrong email or token, or the token has expired |
| 403 with "XSRF check failed" | The X-Atlassian-Token: nocheck header is missing |
| 403 otherwise | The account cannot add attachments to that page |
| 404 | Wrong page ID, wrong site URL, or the account cannot see the page |
| 413 | The file is larger than your site's maximum attachment size |
See Troubleshooting for rendering problems after a successful upload.