Watch this video demo building a CI system with Github Workflows to publish Guides automatically.
This demo was recorded before the endpoint change in July 2026. The three-call workflow it shows is still accurate, but use the URLs in this article rather than the ones on screen.
PlusPlus has a GraphQL API for querying data via GraphQL. Separately, you can publish a new version of an existing Guide through the upload API described here. Technical teams commonly wire this into CI so that a Guide republishes on every commit.
This API adds versions to a Guide that already exists. It cannot create one — create the Guide in PlusPlus first, or use the v2 route below, which creates it for you. To create one in PlusPlus, see How to import Guides to PlusPlus.
Which route to use
The PlusPlus Developer API (v2) is the current way to publish Guide content programmatically, and the better starting point for new automation. It takes two calls instead of three, needs no special enablement, and can create the Guide and the upload session in a single request.
POST /api/v2/guides/uploads/— passguide_idto attach the upload to an existing Guide, or omit it and passnameto create a new hidden Guide alongside the upload session. The response carriesupload_urlandupload_id.PUT your ZIP package directly to
upload_urlwithContent-Type: application/zip. There is no confirmation call.GET /api/v2/guides/uploads/{upload_id}/status/— poll every 3 to 5 seconds. Polling is what triggers processing: the server dispatches the job the first time it detects your file has landed.statusmoves throughawaiting_uploadandprocessingtoready, at which pointlaunch_urlis populated. On failure,statusiserroredanderror_messagedescribes why.
Guides created this way start hidden. Once processing finishes, set is_hidden to false to publish.
Authenticate with a Bearer token from the API Tokens dashboard: Authorization: Bearer pp_your_token_here. See How to manage API tokens. Write requests are limited to 60 per minute.
The three-call flow described below is the older route. It still works, and it is worth knowing if you already have automation built on it, but it requires PlusPlus staff to enable a setting and uses a different token.
Before you start: the write API is off by default and only PlusPlus staff can turn it on. Contact Support to have Enable write API for Guide versions enabled for your instance. While it is off, the pre-sign and version calls both return HTTP 400 Feature not enabled.
The endpoint paths below contain codelabs because that remains the internal identifier for this content type. The customer-facing name is Guide.
Information required
To get started using the update API you need four pieces of information.
1. Your PlusPlus app domain
The domain at which you access your PlusPlus instance.
2. Developer token
To authenticate, you need a GraphQL token. As an Admin, open your own profile, select Edit, and copy the value under GraphQL Token. The token is tied to your user account and stays valid while you are an active user.
This is not the same credential as the v2 route above, which uses a Bearer token from the API Tokens dashboard.
Only Admins can see this field. See Setting Up and Managing Per-User Tokens.
Send the token as the bare value in the authorization header. Do not prefix it with Token .
3. Guide UUID
You need the UUID that uniquely identifies your published Guide. It appears in the URL when you view the Guide. For a Guide at https://demo.plusplus.app/a/guides/501a39e1-aaf8-4581-889c-5d6023824ee2_your-first-progressive-web-app, the UUID is 501a39e1-aaf8-4581-889c-5d6023824ee2 — the portion before the underscore.
4. Your Guide file
Zip the directory your Markdown lives in, along with any assets. You need to create this file yourself.
We will call these four pieces of information $DOMAIN, $TOKEN, $UUID, and $FILE.
API calls
PlusPlus selects one Markdown file from the zip:
A root-level
index.mdwins, whatever its capitalization.INDEX.mdcounts;nested/index.mdgets no special priority.Otherwise the least-nested
.mdfile wins.a/zeta.mdbeatsa/b/alpha.md.Where files tie on depth, the first in character order wins. Capitals sort ahead of lowercase, so
Zeta.mdbeatsalpha.md.README.mdis never selected, at any depth or capitalization.The extension must be exactly
.md.Guide.MDis ignored.__MACOSX/entries are skipped.
A zip containing no usable .md file fails with No valid markdown files found in the guide ZIP file.
Publishing takes three calls. A sample Python client that makes all three is in Sample client below.
Step #1 - Request a pre-signed POST
This step retrieves the information needed to make a POST call to our file storage system. POST to a URL that looks like https://{domain}/private_api/codelabs/upload/{uuid}/pre-sign/
For your call to be authorized, include a custom header carrying your developer token $TOKEN. You receive back a JSON dictionary. Via curl, this call and response look like (with some encryption details sanitized):
$ curl --header "authorization: $TOKEN" -X POST \
"https://$DOMAIN/private_api/codelabs/upload/$UUID/pre-sign/"
{"upload_data":{"url":"http://sanitized-url-from-server",
"fields":{"key":"1/uploads/default/e2e5796cdf484ba69bf059568d0be1a8",
"x-amz-algorithm":"AWS4-HMAC-SHA256",
"x-amz-credential":"sanitized…",
"x-amz-date":"20240423T162357Z",
"Policy":"sanitized",
"X-amz-signature":"sanitized}},
"read_url":"sanitized"}
Step #2 - Upload your file
The result of the pre-signed POST call is data to make another call.
POST to the upload_data.url returned by the previous endpoint and send your $FILE. To authenticate to this endpoint, pass all the upload_data.fields as headers to the request. With recent versions of curl you can save those to a text file and pull them into your curl POST request by prefacing the name of your text file containing your headers with an @ sign. This might look like:
$ curl \
--header "Content-Disposition: form-data; name="file";filename='$FILE'" \
-X POST $UPLOAD_URL \
-H @headers.txt \
--data-binary @$FILE
Because this call is pointed at our file storage system you do not need your authorization token. The authorization for this request is in the provided headers.
Step #3 - Updating the Guide version
Pass the read_url value you got back in the first step to PlusPlus to update your Guide:
$ curl --header "authorization: $TOKEN" \
-X POST "https://$DOMAIN/private_api/codelabs/upload/$UUID/version/" \
--data-urlencode "read_url=$READ_URL"
Encode the value. read_url is a pre-signed URL containing &, and an unencoded -d "read_url=$READ_URL" silently truncates it at the first parameter. The call still returns HTTP 200 and the version fails later, at build time, with nothing pointing at the cause.
JSON works too, but only with Content-Type: application/json and double-quoted keys. Without the header the body is parsed as a form and the call returns 400 No read_url specified.
Success
A successful call returns HTTP 200 with {"version": <id>}. That means the version was registered, not that it is live — the build runs afterward. If the build fails, the previous version stays live and the Guide's Updated … ago date does not move. The version value is an internal database ID, not the Version N shown in the UI.
Learners stay on the version they started. Inside the Guide, a banner reads You are currently on release rN. There is a newer release rM available. with a Switch to the latest release link. New assignments are made against the latest version.
A successful publish also updates the Guide's Updated … ago date and sets Updated by to the token's owner. A failed build leaves both unchanged.
Troubleshooting
Response | Cause |
403 | The token is invalid or the user cannot edit this Guide. The permission check runs before the feature gate, so a bad token fails this way even when the API is off. |
400 | Enable write API for Guide versions is off for your instance. Contact Support. |
400 | The body was not parsed. Usually JSON sent without |
500 on step 3 after step 1 succeeded | The |
200, but the Guide does not update |
|
Sample client
The following Python code has been tested with Python 3 and the requests library installed. Replace the four $VALUEs in the script to use your own token, on your app, against your Guide with your supplied zip file.
Save this file as client.py and run it. A successful run looks like:
$ python client.py
Success! See <url-to-your-guide>
Any errors in any of the three API calls result in error messages printed to stdout.
Sample client
import requests
token = $TOKEN # REPLACE
domain = $DOMAIN # REPLACE
codelab_id = $UUID # REPLACE
codelab_file = $FILE # REPLACE
url = f"https://{domain}/private_api/codelabs/upload/{codelab_id}/pre-sign/"
# Get the pre-signed post details
response = requests.post(url, headers={'authorization': token})
if response.status_code != 200:
print(response)
raise SystemExit("Failed to generate pre-signed post")
# Upload the zip file representing the guide version with the presigned post
data = response.json()
upload_data = data['upload_data']
response = requests.post(upload_data['url'],
data=upload_data['fields'],
files={'file': open(codelab_file, "rb")})
if response.status_code != 204:
print(response)
raise SystemExit("Failed to upload file")
# Try to update the version by pointing to the uploaded file
url = f"https://{domain}/private_api/codelabs/upload/{codelab_id}/version/"
response = requests.post(url,
headers={'authorization': token},
data={'read_url': data['read_url']})
if response.status_code != 200:
print(response)
raise SystemExit("Failed to update version")
url = f"https://{domain}/a/guides/{codelab_id}/"
print(f"Success! See {url}")
