Skip to main content

Updating Guides via API

Publish Guide content programmatically: the v2 Developer API upload route, the older three-call flow, authentication, troubleshooting, and a sample Python client.

Written by Michael Wallace

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.

  1. POST /api/v2/guides/uploads/ — pass guide_id to attach the upload to an existing Guide, or omit it and pass name to create a new hidden Guide alongside the upload session. The response carries upload_url and upload_id.

  2. PUT your ZIP package directly to upload_url with Content-Type: application/zip. There is no confirmation call.

  3. 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. status moves through awaiting_upload and processing to ready, at which point launch_url is populated. On failure, status is errored and error_message describes 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.md wins, whatever its capitalization. INDEX.md counts; nested/index.md gets no special priority.

  • Otherwise the least-nested .md file wins. a/zeta.md beats a/b/alpha.md.

  • Where files tie on depth, the first in character order wins. Capitals sort ahead of lowercase, so Zeta.md beats alpha.md.

  • README.md is never selected, at any depth or capitalization.

  • The extension must be exactly .md. Guide.MD is 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 Feature not enabled

Enable write API for Guide versions is off for your instance. Contact Support.

400 No read_url specified

The body was not parsed. Usually JSON sent without Content-Type: application/json.

500 on step 3 after step 1 succeeded

The authorization header carries a Token prefix. Send the bare value.

200, but the Guide does not update

read_url was truncated. Encode the value.

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}")

See also

Did this answer your question?