Run your releases and your manual from your own tools. A creator key lets a build script or a bot make projects, upload files, publish a release, post a development update, push a whole documentation set and read the analytics of your creator site, and a signed webhook tells your tools when a release is published or a member opens a support request.
Make a key
Open your creator dashboard, go to Developers and choose Make a key. Give it a name, pick what it may do, and copy the key. It starts with cak_ and is shown once. Only a fingerprint is kept, so a lost key cannot be looked up: revoke it and make another. Only an owner or an administrator of the creator can make keys.
A key belongs to one creator, so a request never says which creator it is for. It acts with the role of the team member who made it and can never do more than its scopes allow. If that member leaves the team, the key stops working.
Send it on every request:
curl "https://hangaraddons.com/api/creator-api/v1/creator" \
-H "Authorization: Bearer cak_your_key_here"
X-Api-Key: cak_... works too.
Scopes
| Scope | What it allows |
|---|---|
read:projects | Read projects, releases and updates. Every key has this one. |
write:projects | Create and change projects. |
write:releases | Create and change releases, upload their files, publish and unpublish. |
write:updates | Create and change development updates. |
read:analytics | Read the analytics of the creator site. |
read:documentation | List and read documentation sets and their pages. |
write:documentation | Import, upload, change and delete documentation pages. It reads documentation too. |
A request that needs a scope the key does not have is answered with 403. A missing or revoked key is answered with 401.
Limits
- 120 read requests and 30 write requests a minute for each key. A
429comes with aRetry-Afterheader. - 10 keys and 5 webhook addresses for each creator.
- A file is sent in pieces of up to 25 MB.
- A documentation import is up to 500 pages and 25 MB of JSON, or a ZIP of up to 50 MB. See Documentation below.
Endpoints
All of them are under https://hangaraddons.com/api/creator-api/v1 and answer JSON. Ids are GUIDs.
| Method and path | Scope | What it does |
|---|---|---|
GET /creator | read:projects | The creator the key belongs to, and the key's scopes. |
GET /projects | read:projects | Your projects. Add ?status=Released to filter. |
GET /projects/{projectId} | read:projects | One project in full. |
POST /projects | write:projects | Make a project. |
PUT /projects/{projectId} | write:projects | Change a project. |
GET /projects/{projectId}/releases | read:projects | The releases of a project. |
GET /releases/{releaseId} | read:projects | One release with its files and where it is published. |
POST /projects/{projectId}/releases | write:releases | Make a release. It starts as a draft. |
PUT /releases/{releaseId} | write:releases | Change a release. |
PUT /uploads/{uploadId}/chunks/{index} | write:releases | Send one piece of a file. |
POST /releases/{releaseId}/files | write:releases | Put the pieces together as a file of the release. |
DELETE /releases/{releaseId}/files/{fileId} | write:releases | Remove a file. |
POST /releases/{releaseId}/publish | write:releases | Publish to your site, to Hangar Addons, or both. |
POST /releases/{releaseId}/unpublish | write:releases | Take it down again. |
GET /updates | read:projects | Your development updates. |
POST /updates | write:updates | Post a development update. |
PUT /updates/{updateId} | write:updates | Change an update. |
GET /analytics?days=30 | read:analytics | Visits, page views, downloads and sources. days is 7, 30, 90 or 365. |
GET /documentation | read:documentation | Your documentation sets. Add ?projectSlug= for one project. |
GET /documentation/{setId}/pages | read:documentation | The pages of a set in reading order, without their text, each with a markdownSha256. |
GET /documentation/{setId}/pages/{pageSlug} | read:documentation | One page with its Markdown. |
PUT /documentation | write:documentation | Import a whole set from JSON. Add ?dryRun=true to preview. |
PUT /documentation/upload | write:documentation | Import a whole set from a ZIP of Markdown files, or from one Markdown file. |
PUT /documentation/{setId}/pages/{pageSlug} | write:documentation | Make or change one page. |
DELETE /documentation/{setId}/pages/{pageSlug} | write:documentation | Delete a page that has no pages under it. |
Every record is checked against your creator by the id stored with it, so a key cannot reach another creator's records even with a guessed id. It gets 403.
Make a project
curl -X POST "https://hangaraddons.com/api/creator-api/v1/projects" \
-H "Authorization: Bearer cak_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"name": "Fokker F27",
"summary": "A turboprop airliner with a full cockpit.",
"status": "InDevelopment",
"categoryId": "5f6c0b0e-1f5b-4d8e-9d34-0b8a9f6f1a10",
"simulatorIds": []
}'
The answer is { "id": "...", "slug": "fokker-f27" }.
Publish a release
- Make the release. It is a draft that only your team can see.
curl -X POST "https://hangaraddons.com/api/creator-api/v1/projects/$PROJECT_ID/releases" \
-H "Authorization: Bearer cak_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "version": "1.2.0", "releaseName": "Winter update", "changelogMarkdown": "- Fixed the autopilot", "status": "Draft" }'
The answer is { "id": "<releaseId>" }. Versions look like 1.2.0, or 1.2.0-beta.1 for a prerelease. A project cannot have the same version twice.
- Send the file in pieces. Choose a new GUID as the upload id, then send each piece, counting from 0:
curl -X PUT "https://hangaraddons.com/api/creator-api/v1/uploads/$UPLOAD_ID/chunks/0" \
-H "Authorization: Bearer cak_your_key_here" \
--data-binary @piece-0
- Put the pieces together as a file of the release:
curl -X POST "https://hangaraddons.com/api/creator-api/v1/releases/$RELEASE_ID/files" \
-H "Authorization: Bearer cak_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "uploadId": "<uploadId>", "totalChunks": 3, "fileName": "fokker-f27-1.2.0.zip", "contentType": "application/zip" }'
- Publish:
curl -X POST "https://hangaraddons.com/api/creator-api/v1/releases/$RELEASE_ID/publish" \
-H "Authorization: Bearer cak_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "creatorSite": true, "hangarAddons": true }'
The answer says what happened on each target:
{ "status": "Released", "creatorSite": "Published", "hangarAddons": "Published", "hangarState": "InReview" }
hangarState is Live, Scheduled or InReview. A release to Hangar Addons can wait in review before it goes live, the same as an upload on the website. The request returns right away. Search, the activity feed, notifications and webhooks follow a moment later.
A development update
curl -X POST "https://hangaraddons.com/api/creator-api/v1/updates" \
-H "Authorization: Bearer cak_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "title": "Development update 14", "contentMarkdown": "The cockpit is done.", "status": "Published" }'
Analytics
GET /analytics?days=30 answers with totals, a row for each day, and the busiest projects, releases and pages. A line that fewer than five events are behind is folded into other, and the sources and countries are shown the same way, so nobody can be picked out of a small group. The numbers are counted once per browser session and crawlers are left out.
Documentation
Push a manual from your own tools: a script, a static site generator or a build step. One call writes a whole documentation set, so nobody has to click through the editor. A set is found by its address (its slug) and a page by its slug inside the set, so sending the same import again updates the pages in place. Page ids, links and search results stay the same, and a page that did not change is not touched.
Make a key with write:documentation for importing. A key with only read:documentation can list and read.
Import JSON
curl -X PUT "https://hangaraddons.com/api/creator-api/v1/documentation?dryRun=true" \
-H "Authorization: Bearer cak_your_key_here" \
-H "Content-Type: application/json" \
-d @manual.json
{
"name": "Pilot manual",
"slug": "manual",
"projectSlug": "fokker-f27",
"mode": "merge",
"status": "Published",
"pages": [
{ "slug": "intro", "title": "Introduction", "markdown": "Welcome to the **Fokker F27**." },
{ "slug": "setup", "title": "Setup", "parentSlug": "intro", "sortOrder": 0, "markdown": "See [the intro](intro)." }
]
}
| Field | Meaning |
|---|---|
name | The name of the set. Optional. A new set without a name is named after its slug. |
slug | The address of the set. Optional. Without it the address is made from the name, or is documentation. Use the same one every time. |
projectSlug | Put the set under one of your projects. Leave it out for documentation of everything you make. |
mode | merge (the default) adds and changes the pages you send and keeps the others. replace also removes the pages that are not in the import. |
status | Draft (the default) or Published, for the pages that do not have their own. A page shows on your site only when it and every page above it are published. |
pages[].slug | Required. Lowercase letters, numbers and single hyphens, up to 80, and unique in the set. This is what matches a page from one import to the next. |
pages[].title | Required, up to 150 characters. |
pages[].markdown | Up to 100000 characters. |
pages[].parentSlug | The page this one goes under. In a merge, leave it out to keep the page where it is, and send an empty string to put it at the top. In a replace a page without one is at the top. |
pages[].sortOrder | A number from 0. Leave it out to follow the order of the list. |
pages[].status | Draft or Published for this page. |
Pages can go 5 levels deep. The whole import is checked before anything is saved and then saved in one step, so it happens completely or not at all. Every problem comes back in one answer, each with its path:
{
"title": "Validation failed",
"errors": {
"pages[1].slug": ["The address 'intro' is already used by pages[0]."],
"pages[3].parentSlug": ["There is no page 'nowhere' in this import or in the documentation."]
}
}
With dryRun=true nothing is saved. The answer has the same shape as a real one, with valid, problems and warnings in place of an error, and lists what would happen:
{
"dryRun": true,
"valid": true,
"setSlug": "manual",
"setIsNew": false,
"mode": "replace",
"added": [{ "slug": "setup", "title": "Setup" }],
"updated": [{ "slug": "intro", "title": "Introduction", "changes": ["markdown"] }],
"removed": [{ "slug": "old-page", "title": "Old page" }],
"unchanged": 12,
"images": { "referenced": 0, "toUpload": 0, "existing": 0 },
"problems": [],
"warnings": []
}
Run it with dryRun=true first, and then without it. The real answer lists the pages of the set with their ids. The change goes through the same steps as saving in the editor: the search index, the page cache and your audit log, which names the key.
Upload a ZIP or a Markdown file
curl -X PUT "https://hangaraddons.com/api/creator-api/v1/documentation/upload" \
-H "Authorization: Bearer cak_your_key_here" \
-F file=@manual.zip -F slug=manual -F mode=replace -F status=Published
The form fields are the same as the JSON fields above, and the file is in the field file. It also takes ?dryRun=true. A single .md file makes one page.
The layout of the ZIP:
manual.zip
index.md the page "index", at the top
getting-started.md a page at the top
guide/
_index.md the page for the folder "guide"
01-install.md under "guide", first
02-use.md under "guide", second
images/
cockpit.png
reference/
overview.md "reference" has no index file, so it gets a page that lists its pages
- A folder is a page, and the pages of its files go under it. The page of a folder is its
_index.mdorindex.md(not both). A folder with neither gets a page with a list of links to what is in it. Anindex.mdat the top of the ZIP is an ordinary page. - The file name is the address:
getting-started.mdisgetting-started. A number in front (01-) sets the order and is left out of the address. When two files would get the same address, each gets its folders in front (guide-overview,reference-overview) and the answer says so. Setslugin the front matter to choose your own. - The title is the first heading of the file, which is then not repeated in the text, or the file name.
- Hidden files and folders (starting with a dot),
__MACOSXand files that are not Markdown or pictures are left out, and the answer says so.
Front matter is an optional block of key: value lines between two --- lines at the top of a file:
---
title: Installing the aircraft
order: 1
parent: guide
status: Published
slug: install
---
| Key | Meaning |
|---|---|
title | The title of the page. |
order | A number. Pages are ordered by it, then by name. |
parent | The slug of the page to go under, instead of the folder. |
status | Draft or Published. draft: true and draft: false work too. |
slug | The address of the page. |
Other keys are ignored.
Pictures that a page points to with a relative link () are uploaded to your media library and the link is changed to the stored picture. PNG, JPEG, WebP and GIF are accepted, up to 15 MB each. A picture that is already in your library is not uploaded again, so importing again does not fill the library. SVG is not accepted. Pictures on other sites are left as they are.
Links to other pages of the ZIP ([install](01-install.md), [home](../index.md#top), [the guide](guide/)) are changed to links to those pages. Links to files that are not in the ZIP are left as they are, and the answer lists them as notes.
The ZIP is checked before it is read. It is refused as a whole if a path is absolute or climbs out with .., if it holds a symbolic link, if a file is packed far tighter than real content, or if it has more than 2000 files, a Markdown file over 1 MB, or more than 120 MB once unpacked.
What the site shows
The pages are drawn with GitHub flavored Markdown: headings, lists, task lists, tables, fenced code blocks, quotes, bold, italic, strikethrough, links and pictures. The notes in the answer point out what is not drawn:
- Raw HTML is shown as plain text. Use Markdown.
- Indented code blocks are not code. Use fences.
- Notes and warnings of other generators (
!!!,:::) and template tags ({% %},{{< >}}) show as plain text. - The title of a page is shown as its heading. A heading with a single
#in the text is drawn as a section heading under it.
A link to another page of the same set can be written as [text](slug), [text](slug#part) or with a path and .md. The reader gets a link to that page on whichever address your site is shown at. A link that names no page is shown as plain text.
Read back and change one page
curl "https://hangaraddons.com/api/creator-api/v1/documentation" -H "Authorization: Bearer cak_your_key_here"
curl "https://hangaraddons.com/api/creator-api/v1/documentation/$SET_ID/pages" -H "Authorization: Bearer cak_your_key_here"
curl "https://hangaraddons.com/api/creator-api/v1/documentation/$SET_ID/pages/setup" -H "Authorization: Bearer cak_your_key_here"
The list of pages has the id, slug, title, parentSlug, sortOrder, status, length and a markdownSha256 of the text for each page, so a script can see which pages differ from its own files without downloading them. To change one page:
curl -X PUT "https://hangaraddons.com/api/creator-api/v1/documentation/$SET_ID/pages/setup" \
-H "Authorization: Bearer cak_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "markdown": "New text.", "status": "Published" }'
A field that is left out keeps what the page has, and a page that does not exist yet is made. DELETE on the same path deletes a page that has no pages under it.
An example script
A build that makes a folder of Markdown files can send it in a few lines:
set -e
KEY=cak_your_key_here
API=https://hangaraddons.com/api/creator-api/v1/documentation
zip -r manual.zip docs -x "*.DS_Store"
curl -sS -X PUT "$API/upload?dryRun=true" -H "Authorization: Bearer $KEY" -F file=@manual.zip -F slug=manual -F mode=replace
curl -sS -X PUT "$API/upload" -H "Authorization: Bearer $KEY" -F file=@manual.zip -F slug=manual -F mode=replace -F status=Published
The same from Node 20 or newer, sending JSON:
import { readFileSync } from "node:fs";
const pages = JSON.parse(readFileSync("pages.json", "utf8"));
const answer = await fetch("https://hangaraddons.com/api/creator-api/v1/documentation", {
method: "PUT",
headers: { Authorization: "Bearer " + process.env.HANGAR_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ slug: "manual", mode: "replace", status: "Published", pages })
});
const result = await answer.json();
if (!answer.ok) {
console.error(JSON.stringify(result.errors, null, 2));
process.exit(1);
}
console.log(result.added.length + " added, " + result.updated.length + " changed, " + result.removed.length + " removed");
A request that goes too fast is answered with 429. Writes are limited to 30 a minute for each key, and an import counts as one.
Webhooks
Add a webhook address on the Developers page and choose the events it gets. Each event is posted to your address as JSON, signed with the secret shown once when you make it.
| Event | When |
|---|---|
release.published | A release of one of your projects was published, once for each place it was published to. |
ticket.created | A member opened a support request. The data has visibility, which is Public when the member asked in public and Private otherwise. |
ping | You pressed Test on the Developers page. |
{
"id": "evt_2c1f4e0a7b6d4a",
"type": "release.published",
"createdAt": "2026-11-08T14:02:11Z",
"creator": { "id": "9a3f...", "name": "Awesome Aircraft Development", "slug": "awesomeaircraft" },
"data": {
"releaseId": "0b6f...",
"projectId": "5d0a...",
"project": "Fokker F27",
"projectSlug": "fokker-f27",
"version": "1.2.0",
"releaseName": "Winter update",
"isPrerelease": false,
"target": "CreatorSite",
"publishedAt": "2026-11-08T14:02:10Z",
"siteUrl": "/c/awesomeaircraft/projects/fokker-f27"
}
}
Every post carries these headers:
| Header | What it is |
|---|---|
X-Hangar-Event | The event type. |
X-Hangar-Delivery | A GUID for this delivery. The same event is never sent twice to one address. |
X-Hangar-Timestamp | Seconds since 1970 when it was signed. |
X-Hangar-Signature | v1= and the lowercase hex of an HMAC SHA256 of {timestamp}.{body}, made with your whsec_ secret. |
Check the signature on the raw body, and refuse a timestamp that is more than 5 minutes old, so a captured post cannot be sent again later.
import { createHmac, timingSafeEqual } from "node:crypto";
function isFromHangarAddons(secret, headers, rawBody) {
const timestamp = Number(headers["x-hangar-timestamp"]);
if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
return false;
}
const expected = "v1=" + createHmac("sha256", secret).update(timestamp + "." + rawBody).digest("hex");
const given = String(headers["x-hangar-signature"] ?? "");
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
Answer with any 2xx status. A failed post is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then given up. An address that fails 15 times in a row is switched off, and the Developers page says why. You can rotate the secret there at any time, and see the last deliveries.