API reference
Everything HelpCCMS exposes, and nothing it does not. Two surfaces:
- Delivery is public and read-only. No key, no account, no SDK. This is how your product gets its text.
- Deploy targets is authenticated. This is how you set up push, so we send the text to you instead of your product asking for it.
If you are integrating for the first time, start at the developer page. That page walks you through one key end to end. This one is the reference you come back to.
Conventions
Base address: https://www.helpccms.com
All responses are JSON unless stated otherwise. Error responses carry both a
human-readable error and a stable code; write your handling against code.
A machine-readable version of this page lives at
/api/openapi.json. If you are pointing a coding agent at
this integration, give it that.
Nothing about a published topic changes without a publish. A draft is never delivered. If a key returns 404, either it does not exist or it has not been published yet, and those are deliberately the same answer to the outside world.
Delivery
GET /api/deploy/{collection}/{key}
Return the published content for one key.
Path parameters
collection | The collection id. Copy it from the register in the editor. |
key | The topic's address, for example billing.payment-status. Copy it from the register too. URL-encode it if it contains characters that need it. |
An address belongs to exactly one topic. That is enforced when it is set, not when you ask for it, which is why a request never has to deal with "two topics answer to this". A topic without an address is not deliverable at all: it can still appear in a collection page, but there is no key to request it by.
Query parameters
locale | Language code. Defaults to en. |
200 response
{
"key": "billing.payment-status",
"title": "When a payment fails",
"short_desc": "What to do when your card is declined.",
"html": "<p>Your card was declined…</p>",
"text": "Your card was declined…",
"updated_at": "2026-08-11T09:12:04.881Z"
}You get the same content twice, in two shapes. A tooltip wants text; a help panel
wants html. Neither is a conversion of the other after the fact: both come out of
the same published snapshot.
short_desc is the author's one-line summary of the topic, or null if they did not
write one. It is deliberately not part of html or text: it describes the topic
rather than being part of it, so putting it in the body would make every reader read a
paraphrase of what they are about to read anyway. Use it for a link preview, a search
result, or the first line of a collapsed panel. If you want it above the body, put it
there yourself.
Headers on a 200
ETag: "<checksum of the snapshot>"
Cache-Control: public, max-age=300, stale-while-revalidate=600
Access-Control-Allow-Origin: *Caching. Send the ETag back as If-None-Match and you get 304 Not Modified
with no body when the publication has not changed. The ETag is the checksum of the
snapshot, so identical publication means identical bytes means identical ETag.
CORS. Open to any origin, and OPTIONS is answered with 204. You can call
this straight from a browser. There is no key to leak, because there is no key.
Styling the HTML. The delivered HTML carries no colours and no inline styles: it
is your page, so it should look like your page. Elements you may want to hook into
carry machine-readable attributes. Notes and warnings come as
<div class="admonition admonition-warning" data-level="warning" data-label="Warning">,
where data-level is the DITA type (one of note, tip, important, remember,
restriction, attention, caution, danger, warning, fastpath, other) and
data-label is the word the author wants in front of it. The word is not baked into
the text, so you can render it with ::before, replace it, or leave it out.
Errors
| Status | code | What it means |
|---|---|---|
| 404 | COLLECTION_NOT_FOUND | No collection with that id. |
| 404 | KEY_NOT_FOUND | No topic in that collection carries this key. |
| 404 | NOT_PUBLISHED | The topic exists but has never been published. |
| 409 | AMBIGUOUS_KEY | Two topics answer to the same address. This should not happen; see below. |
That last one used to be reachable, and now should not be. Addresses are unique by construction: the database refuses a second topic with the same address, so the question "which of the two?" cannot arise at request time.
The error stays in the contract as a guard rail. If you ever see it, the constraint has been lost somewhere and we want to know. The alternative is worse: silently picking one of two topics and serving it as though it were the answer. We would rather fail loudly than silently pick one.
Error responses are sent with Cache-Control: no-store, so fixing the cause takes
effect immediately.
GET /api/deploy/assets/{topic}/{asset}
A stable address for an image inside a published topic. The html you receive
already points at these addresses; you do not normally build them yourself.
They exist because the delivered HTML used to contain signed storage URLs that expired. That broke two things at once: the payload was no longer stable, so the ETag lied, and a client holding a 304 kept dead image links forever.
Deploy targets (push)
Status: built, not switched on for customers yet. The routes exist and are tested. Treat this section as the contract we intend to honour, not as something you can rely on in production today.
Instead of your product asking, we send. You give us an endpoint; after every publication we post the content to it, signed.
All of these require a paid plan and an API token.
Authentication
Send the token as a bearer token:
Authorization: Bearer hcp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxCreate one in the editor, under Delivery. It is shown once. We store only a hash of it, so we cannot show it to you again and neither can anyone who reads our database. Lost it? Revoke it and make a new one.
A token belongs to a workspace, not to a person, and it opens exactly one thing: managing delivery targets. It cannot read or change your content, touch your subscription, or see your users. That is deliberate. A token that can do everything turns one leaked environment variable into an account takeover, and your adapter does not need any of it.
The one thing you cannot do with a token is manage tokens. That has to happen in the browser, signed in. A secret that can issue its own replacement cannot be revoked in any meaningful sense.
Reading needs nothing. The delivery endpoint above is public: your adapter can fetch published content without a token at all. You only need one to configure where we push.
| Method | Path | Purpose |
|---|---|---|
POST | /api/deploy/targets | Create a target |
GET | /api/deploy/targets?collection={id} | List the targets of a collection |
GET | /api/deploy/targets/{id} | Read one target |
PATCH | /api/deploy/targets/{id} | Change it, or rotate the secret |
DELETE | /api/deploy/targets/{id} | Remove it |
POST | /api/deploy/targets/{id}/test | Send a test delivery |
POST | /api/deploy/targets/{id}/deploy | Deploy now |
POST /api/deploy/targets
{
"collection": "<collection id>",
"name": "Production help panel",
"endpoint_url": "https://api.example.com/hooks/helpccms",
"auto_deploy": true
}endpoint_url must be a public https address. auto_deploy decides whether we
push on every publication or only when you ask.
The response contains the signing secret, and it starts with hcs_.
⚠️ The secret is shown once, here. After this it is stored encrypted and cannot be retrieved, not even by you. Lost it? Rotate with
PATCHand you get a new one. A secret you can look up is not a secret.
What your endpoint receives
This is the part worth reading carefully, because it is the part you have to implement.
Headers
X-HelpCCMS-Deployment-Id: <uuid>
X-HelpCCMS-Timestamp: <unix seconds>
X-HelpCCMS-Signature: <hex>Body
{
"deployment_id": "…",
"collection": "…",
"key": "billing.payment-status",
"locale": "en",
"checksum": "…",
"schema_version": 1,
"content": {
"key": "billing.payment-status",
"title": "When a payment fails",
"short_desc": "…",
"html": "…",
"text": "…",
"updated_at": "…"
}
}content is byte-for-byte what the pull route would have given you. One resolver
feeds both transports; if they ever disagree, that is a bug on our side.
Verifying the signature
HMAC-SHA256 over timestamp + . + the raw request body, hex-encoded, keyed
with your secret.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, headers, secret) {
const ts = headers["x-helpccms-timestamp"];
const sent = headers["x-helpccms-signature"];
const mine = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
const a = Buffer.from(sent, "utf8");
const b = Buffer.from(mine, "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}Three things that will bite you if you skip them:
- Sign the raw body, not a re-serialised object. Parse it after you verify.
JSON.parsefollowed byJSON.stringifycan reorder keys and change bytes. - Compare in constant time. A plain
===leaks, through timing, where the first byte differs. - Check the timestamp. Reject anything older than a few minutes, or a captured request stays valid forever.
Idempotency
We do not resend a publication you already have: if the checksum matches what we
last delivered to that target, we skip it. You will still receive the same
deployment_id at most once, so it is safe to use as a deduplication key on your
side too.
Versioning
What will not change inside version 1
- A field that exists keeps its name and its meaning.
- An error
codekeeps its meaning.errortext may be reworded. - A path that exists keeps answering.
schema_versionin the push body stays1.
What may change without warning
- New fields appear in responses. Ignore what you do not recognise; do not fail on it, and do not assume the set is closed.
- New optional query parameters and new endpoints.
- New error codes for cases that used to be a generic failure. Handle an unknown
codeas "something went wrong" rather than crashing.
What counts as breaking
Removing or renaming a field, changing what a field means, removing an endpoint, or changing a status code for an existing case. None of that happens inside version 1. When it becomes necessary, it arrives as a new path prefix, the old one keeps working for at least six months, and you get an email at the address on the account before the old one stops.
The honest small print: this is a young API, and this policy is a promise rather than a track record. What makes it enforceable is that the response shape is the same object the push transport sends, and both come out of one resolver. A quiet divergence would break our own dogfooding first.
What is not an API
Everything under /api that is not listed above serves the application itself. It
is authenticated, it is shaped for our own screens, and it changes when those
screens change. Do not build against it. If you need something that is not here,
that is a good conversation to have rather than a route to discover.