← Support

API reference

Everything HelpCCMS exposes, and nothing it does not. Two surfaces:

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

collectionThe collection id. Copy it from the register in the editor.
keyThe 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

localeLanguage 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

StatuscodeWhat it means
404COLLECTION_NOT_FOUNDNo collection with that id.
404KEY_NOT_FOUNDNo topic in that collection carries this key.
404NOT_PUBLISHEDThe topic exists but has never been published.
409AMBIGUOUS_KEYTwo 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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Create 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.

MethodPathPurpose
POST/api/deploy/targetsCreate 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}/testSend a test delivery
POST/api/deploy/targets/{id}/deployDeploy 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 PATCH and 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:

  1. Sign the raw body, not a re-serialised object. Parse it after you verify. JSON.parse followed by JSON.stringify can reorder keys and change bytes.
  2. Compare in constant time. A plain === leaks, through timing, where the first byte differs.
  3. 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

What may change without warning

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.