# Headless Helpdesky help center — integration spec for coding agents

Version: 1 (Content API v1). Canonical location: https://helpdesky.io/docs/headless.md

This document is self-contained. It tells a coding agent (or a developer) how to
render a Helpdesky help center inside a customer's own website — own header,
footer, navigation and CSS — using Helpdesky's public, read-only **Content API**.
The API returns every article as **finished HTML**; your site fetches it and places
it in its own layout. No Helpdesky JavaScript, no iframe, no account or API key.

Human-readable docs for the same API: https://helpdesky.io/docs/api#content-api

---

## 1. What you need from the customer

- **Help center slug** — the last path segment of the help center's Helpdesky address,
  shown in the Helpdesky dashboard under *Settings → Domain & Address → Helpdesky
  address* as `https://helpdesky.io/help/<slug>`. Example: for
  `https://helpdesky.io/help/acme` the slug is `acme`.
- **Base path** on the customer's site where the help center should live, e.g.
  `/help` (so articles appear at `https://www.example.com/help/<article-slug>`).
- The customer must **save that public base URL** (`https://www.example.com/help`) in the
  Helpdesky dashboard under *Settings → Domain & Address → Headless (your own templates)*.
  Until they do, the live address (§7) is not on their site and your routes redirect
  instead of rendering.

Nothing else. The Content API is unauthenticated and read-only; it only ever returns
**published** content.

---

## 2. Endpoints

Base URL: `https://helpdesky.io/api/content/v1`

| Method | Path | Returns |
| --- | --- | --- |
| `GET` | `/helpdesk/{slug}/articles/{articleSlug}` | One published article with rendered HTML, TOC, category, authors |
| `GET` | `/helpdesk/{slug}/categories` | Categories, each with its published articles (lean, no bodies), plus uncategorised articles |
| `GET` | `/helpdesk/{slug}/articles` | Flat list of all published articles (lean, no bodies) |
| `GET` | `/helpdesk/{slug}/search?q={text}` | Published articles matching `q` in title or body (lean) |
| `GET` | `/article.css` | Standalone stylesheet for the article HTML (see §4) |
| `OPTIONS` | any of the above | CORS preflight, `204 No Content` |

All responses:

- JSON, UTF-8 (`Content-Type: application/json; charset=utf-8`), except the stylesheet (`text/css`).
- `Access-Control-Allow-Origin: *` — fetch from the browser or from your server.
- `Cache-Control: public, max-age=60` plus an `ETag`; send `If-None-Match` to get `304 Not Modified`.
- Rate limit: **120 requests per minute per visitor IP** (`RateLimit-*` headers; `429` with a JSON
  `message` when exceeded). When your server fetches on behalf of a visitor, forward the visitor's
  address in `X-Forwarded-For` (see §6.4) so the limit applies per visitor, not to your server.
- Errors are JSON: `{ "message": "..." }`.

### 2.1 Article — `GET /helpdesk/{slug}/articles/{articleSlug}`

```json
{
  "helpdesk": { "name": "Acme Help", "slug": "acme", "url": "https://help.acme.com" },
  "title": "Getting started",
  "slug": "getting-started",
  "excerpt": "Set up your workspace in five minutes.",
  "url": "https://help.acme.com/getting-started",
  "html": "<p>Welcome…</p>\n<h2 id=\"create-a-workspace\">Create a workspace</h2>…",
  "toc": [
    { "id": "create-a-workspace", "text": "Create a workspace", "level": 2 },
    { "id": "invite-your-team", "text": "Invite your team", "level": 2 },
    { "id": "roles", "text": "Roles", "level": 3 }
  ],
  "numberHeadings": false,
  "category": { "name": "Basics", "slug": "basics", "url": "https://help.acme.com/category/basics" },
  "showAuthors": true,
  "organizationAsAuthor": false,
  "authors": [
    { "name": "Jane Doe", "avatarUrl": "https://…/jane.png", "url": "https://help.acme.com/author/jane-doe" }
  ],
  "createdAt": "2026-03-02T10:15:00.000Z",
  "updatedAt": "2026-09-12T08:40:12.000Z"
}
```

| Field | Type | Meaning |
| --- | --- | --- |
| `helpdesk.name` / `helpdesk.slug` | string | Help center name and slug |
| `helpdesk.url` | string | The help center's **live canonical base URL** (headless address on your site, subfolder address, custom domain or Helpdesky address). See §7. |
| `title` | string | Article title (plain text; use it for `<h1>` and `<title>`) |
| `slug` | string | The article's **current** slug. If it differs from the slug you requested, the article was renamed — see §6.3 |
| `excerpt` | string | Short plain-text summary, may be `""`. Use for meta description and list teasers |
| `url` | string | The article's live canonical URL (`helpdesk.url + "/" + slug`). See §7 |
| `html` | string | The complete article body as HTML, already rendered and sanitised for your origin (§3) — safe to inject as is. Every `<h2>`/`<h3>` has a unique `id`. Every URL in it is **absolute** (§3) |
| `toc` | array | Table of contents built from those headings: `{ id, text, level }` with `level` 2 or 3. Link to `#id` |
| `numberHeadings` | boolean | `true` when the hosted page numbers the H2 headings; add the `numbered-headings` class to the wrapper (§4) |
| `category` | object or `null` | `{ name, slug, url }` of the article's category |
| `showAuthors` | boolean | Whether the help center shows authors for this article. When `false`, `authors` is `[]` — render nothing |
| `organizationAsAuthor` | boolean | `true` when the organisation (help center name) is credited instead of people |
| `authors` | array | Display list: `{ name, avatarUrl, url }` (`avatarUrl` is an absolute URL or `null`). For the organisation it is one entry with the help center name and `avatarUrl`/`url` `null` |
| `createdAt` / `updatedAt` | string | ISO 8601 timestamps |

Status codes: `200`; `404` for an unknown help center, an unknown slug **or a draft**
(drafts are indistinguishable from unknown slugs); `410` for an article that was
permanently deleted; `429` rate limited.

### 2.2 Categories — `GET /helpdesk/{slug}/categories`

```json
{
  "helpdesk": { "name": "Acme Help", "slug": "acme", "url": "https://help.acme.com" },
  "categories": [
    {
      "name": "Basics",
      "slug": "basics",
      "description": "First steps",
      "icon": "rocket",
      "url": "https://help.acme.com/category/basics",
      "articles": [
        {
          "title": "Getting started",
          "slug": "getting-started",
          "excerpt": "Set up your workspace in five minutes.",
          "url": "https://help.acme.com/getting-started",
          "createdAt": "2026-03-02T10:15:00.000Z",
          "updatedAt": "2026-09-12T08:40:12.000Z"
        }
      ]
    }
  ],
  "uncategorized": [ { "title": "…", "slug": "…", "excerpt": "…", "url": "…", "createdAt": "…", "updatedAt": "…" } ]
}
```

- Categories are in the display order of the hosted help center; articles inside a
  category are in the hosted order too (most viewed first). Categories without
  published articles are included with `articles: []`.
- `icon` is the name of the icon chosen in the dashboard (e.g. `folder`, `rocket`,
  `book`); map it to your own icon set or ignore it.
- `uncategorized` lists published articles that are in no category.

### 2.3 Articles — `GET /helpdesk/{slug}/articles`

```json
{
  "helpdesk": { "name": "Acme Help", "slug": "acme", "url": "https://help.acme.com" },
  "articles": [
    {
      "title": "Getting started",
      "slug": "getting-started",
      "excerpt": "Set up your workspace in five minutes.",
      "url": "https://help.acme.com/getting-started",
      "createdAt": "2026-03-02T10:15:00.000Z",
      "updatedAt": "2026-09-12T08:40:12.000Z",
      "category": { "name": "Basics", "slug": "basics", "url": "https://help.acme.com/category/basics" }
    }
  ]
}
```

Same lean article shape as inside categories, plus `category` (`null` when uncategorised).
Use it to build sitemaps and static-site routes.

### 2.4 Search — `GET /helpdesk/{slug}/search?q={text}`

```json
{
  "helpdesk": { "name": "Acme Help", "slug": "acme", "url": "https://help.acme.com" },
  "query": "workspace",
  "articles": [ { "title": "…", "slug": "…", "excerpt": "…", "url": "…", "createdAt": "…", "updatedAt": "…", "category": { "name": "…", "slug": "…", "url": "…" } } ]
}
```

Case-insensitive match on title and body; `q` is required (`400` when missing or
blank) and truncated to 200 characters. Same lean shape as §2.3.

---

## 3. The HTML you receive

`html` is finished, server-rendered HTML — **not Markdown**. Do not run it through a
Markdown processor, and do not sanitise it with a library that strips `class`, `id`,
`target`, `rel`, `data-*` attributes or `<iframe>` elements: the vocabulary below
depends on them. Inject it as HTML (`innerHTML`, `dangerouslySetInnerHTML`,
`{@html}`, `v-html`, `|safe`, `{!! !!}`, `<%- %>` …).

It is already sanitised for a foreign origin before it leaves Helpdesky, with an
allowlist: no `<script>`, `<style>`, event-handler attributes (`on*`), inline `style`,
`javascript:`/unknown URL schemes, forms, `<object>`/`<embed>`, and no `<iframe>` other
than YouTube embeds (`www.youtube.com` / `www.youtube-nocookie.com`). Hand-written HTML
an author adds to an article arrives only as far as it fits that allowlist (headings,
text, lists, tables, images, `<details>`, `<figure>`, `<kbd>` … survive; anything
executable does not). Treat the result like content from your own CMS editors.

Everything in it is one of:

| Element | Markup | Notes |
| --- | --- | --- |
| Headings | `<h2 id="…">`, `<h3 id="…">` (also `h1`/`h4` without ids) | ids are unique, URL-safe slugs of the heading text (`-1`, `-2` suffixes on duplicates); they match `toc[].id` |
| Paragraphs, emphasis | `<p>`, `<strong>`, `<em>`, `<br>` | |
| Lists | `<ul>`, `<ol>` (may carry `start="n"`), `<li>` | The stylesheet replaces the browser markers with Helpdesky's accent-coloured bullets/numbers (`li::before`) |
| Links | `<a href="…">` | Absolute `http(s)` URLs, `mailto:`/`tel:` links and `#anchors` only — never relative paths. Links to other pages of the same help center (authors write them as `/other-article`) are resolved beneath the live address, so they are absolute URLs starting with `helpdesk.url + "/"` (e.g. `https://helpdesky.io/help/acme/other-article`, `https://help.acme.com/other-article`, `https://acme.com/help/other-article`); the help-center home is exactly `helpdesk.url + "/"`. Map that prefix onto your base path (§5 rule 10) |
| Inline code | `<code>` | |
| Code blocks | `<pre><code class="language-xyz">` | `language-*` is present when the author named a language; no syntax highlighting is applied — add a highlighter if you want one |
| Quotes | `<blockquote>` | |
| **Callouts** | `<div class="callout callout-info">…</div>` | Variants: `callout-info` (green), `callout-warning` (yellow), `callout-danger` (red). Content is one or more `<p>` |
| **Buttons** | `<a class="btn-block btn-block-primary" href="…" data-variant="primary" target="_blank" rel="noopener">Label</a>` | Variants: `btn-block-primary` (filled) and `btn-block-outline` (outlined). `target="_blank"` + `rel="noopener"` unless the author chose same-tab (`data-same-tab="true"`); `rel` may add `nofollow` (`data-nofollow="true"`) |
| **Action cards** | `<div class="action-card-wrap" data-action-card="1"><a class="action-card" href="…" target="_blank" rel="noopener" data-icon="rocket"><span class="action-card-icon" aria-hidden="true">…svg…</span><span class="action-card-arrow-wrap" aria-hidden="true">…svg…</span><span class="action-card-title">Title</span><span class="action-card-desc">Description</span></a></div>` | Two or more consecutive cards are wrapped in `<div class="action-card-grid">…</div>` (2-column grid, 1 column under 640px). The icon is inline SVG; `action-card-desc` is omitted when there is no description |
| **YouTube embeds** | `<div class="video-embed"><iframe src="https://www.youtube.com/embed/{id}" allowfullscreen …></iframe></div>` | 16:9 responsive box. Allow `www.youtube.com` in your `frame-src` CSP if you have one |
| Images | `<img src="…" alt="…">`, optionally `data-align="center"` / `data-align="right"` | Uploaded images have absolute URLs on Helpdesky's origin, `https://helpdesky.io/api/images/…` (authors may also embed images from other hosts). Allow `https://helpdesky.io` in your `img-src` CSP. Never rewrite these to your own host — your site does not serve them |
| Tables | `<table><thead><tr><th>…</th></tr></thead><tbody><tr><td>…</td></tr></tbody></table>` | Optionally pre-wrapped in `<div class="table-wrapper">` for horizontal scrolling |
| Horizontal rule | `<hr>` | |

Authors may also hand-write HTML in articles; it arrives after the allowlist described
at the top of this section, with the same URL rules applied.

---

## 4. The stylesheet

```html
<link rel="stylesheet" href="https://helpdesky.io/api/content/v1/article.css">
…
<article class="helpdesky-article">
  <!-- html from the API -->
</article>
```

- URL: `https://helpdesky.io/api/content/v1/article.css` (`Content-Type: text/css`,
  CORS `*`, `Cache-Control: public, max-age=86400, stale-while-revalidate=604800`,
  `ETag`). Link it from your `<head>`; you may also self-host a copy.
- **Wrapper class:** every rule in the file is scoped under `.helpdesky-article`, so it
  cannot leak into the rest of your page, and your global styles do not need to
  change. Put the API `html` *inside* an element with that class — nothing else.
- **Modifiers** (add to the same wrapper element):
  - `numbered-headings` — when the article's `numberHeadings` is `true`: H2 headings get
    a numbered badge (`<article class="helpdesky-article numbered-headings">`).
  - `heading-accent-bars` — optional, draws the hosted template's short accent bar left
    of each H2.
- **Theming** — set these CSS custom properties on `:root`, on the wrapper, or on any
  ancestor; the stylesheet only reads them (it never declares them), so a plain
  `:root { … }` override works:

  | Property | Default | Used for |
  | --- | --- | --- |
  | `--helpdesky-accent` | `#3b82f6` | Links, buttons, action-card borders/icons, blockquote bar, numbered badges |
  | `--helpdesky-text` | `#1f2937` | Headings, strong text; body text is this colour at 67 % opacity |
  | `--helpdesky-font` | Inter, system-ui stack | Font family of the whole article |
  | `--helpdesky-radius` | `14px` | Corner radius of buttons, code blocks, callouts, images, embeds |
  | `--helpdesky-accent-from` / `--helpdesky-accent-to` | the accent colour | Optional gradient for numbered-heading badges and list markers (the hosted default theme uses `#3b82f6` → `#7c3aed`) |
  | `--helpdesky-code-bg` | `rgba(0, 0, 0, 0.04)` | Background of inline code and plain quotes |

  Example:

  ```css
  :root {
    --helpdesky-accent: #e11d48;
    --helpdesky-text: #111827;
    --helpdesky-font: "Source Sans 3", system-ui, sans-serif;
    --helpdesky-radius: 8px;
  }
  ```

  Dark backgrounds: set `--helpdesky-text` to a light colour and `--helpdesky-code-bg`
  to a light tint (e.g. `rgba(255, 255, 255, 0.08)`); backgrounds of code blocks and
  callouts are fixed, everything else derives from text and accent.

The stylesheet styles only the article body. Title, excerpt, breadcrumbs, author line,
table of contents, category and index pages are yours to design.

---

## 5. Integration rules (must)

1. Fetch article HTML from the Content API; **never** store or render article Markdown.
2. Inject `html` as HTML inside **one** wrapper element with class `helpdesky-article`
   (add `numbered-headings` when `numberHeadings` is `true`).
3. Include the stylesheet (link or self-hosted copy) on every page that renders article HTML.
4. Do not pass the HTML through a Markdown processor, a text escaper, or a sanitiser that
   removes classes, ids, `data-*`, `target`/`rel`, or `<iframe>`.
5. Render `title` as the page `<h1>` (the HTML does not contain the title) and `excerpt` as
   the meta description.
6. Build the table of contents from `toc` (anchor `href="#" + id`); do not re-derive ids from the HTML.
7. Show authors only when `showAuthors` is `true`, by looping over `authors`.
8. Map `404` → your 404 page, `410` → your "removed" page (or 410), `429` → retry after the
   `RateLimit-Reset` seconds (serve stale content meanwhile).
9. Apply the redirect rule in §7 before rendering any article, category or index page.
10. Keep visitors on your site: links between articles arrive as absolute URLs under
    `helpdesk.url`. Before injecting, replace the prefix `helpdesk.url + "/"` with your
    `base + "/"` (a plain string replacement on `html`; the prefix cannot occur anywhere else).
    Leave every other URL unchanged — image URLs on Helpdesky's origin, external links, anchors.

---

## 6. Routes a headless site must serve

With `base` = the chosen base path (e.g. `/help`):

| Route | Data | Renders |
| --- | --- | --- |
| `base/` | `GET …/categories` | Index: each category (name, description, link to its category page) with its articles (title, excerpt, link); then `uncategorized` articles |
| `base/<article-slug>` | `GET …/articles/<article-slug>` | Article page: breadcrumb (index → category), `<h1>` title, optional author line, TOC from `toc`, the HTML in the `.helpdesky-article` wrapper, "last updated" from `updatedAt` |
| `base/category/<category-slug>` | `GET …/categories`, pick the matching `slug` | Category page: name, description, its article list. Unknown category slug → 404 |

Optional: `base/search?q=` backed by `GET …/search?q=`.

Match `base/category/<slug>` and `base/search` before `base/<article-slug>` — the hosted
help center resolves them in that order too.

### 6.1 404 behaviour

A `404` from the article endpoint (unknown slug **or draft**) must become a `404` page on
your site with a `404` status code — never a `200` placeholder, and never a redirect to
the index. Unknown category slugs likewise. A `410` from the API should be a `410` page.

### 6.2 Caching

- Responses are cacheable for 60 s and carry `ETag`s. Cache them on your side (in-memory,
  CDN or your framework's data cache) for **at least 60 s** and revalidate with
  `If-None-Match`; this keeps you far below the rate limit (120/min/IP) and makes pages
  fast even when Helpdesky is slow.
- Static-site generators: the flat `articles` list is the route manifest; rebuild (or
  revalidate on demand) when `updatedAt` changes.
- Do not cache `404`/`410` for longer than 60 s (an article may be published later).
- Serve stale content when a fetch fails or is rate limited; do not show an error page
  for content you have already rendered before.

### 6.3 Renamed articles

If the response's `slug` differs from the slug in the request, the article was renamed
and the old slug is a redirect. Answer with a **301** to `base/<response.slug>` instead
of rendering under the old URL.

### 6.4 Analytics

Each successful article fetch counts as one view in the help center's article analytics
(bot user agents are ignored; one view per visitor per article per 30 minutes). When you
fetch **server-side**, forward the visitor's `User-Agent` and IP (`X-Forwarded-For`)
headers on the API request so views are attributed per visitor rather than once per
server; cached responses do not count views.

---

## 7. Live address and the redirect rule

`url` on articles, `url` on categories and `helpdesk.url` are the help center's **live
address**, as configured in the Helpdesky dashboard under *Settings → Domain & Address*,
in this precedence:

1. a **headless address** on the customer's site — the base URL saved under *Headless (your
   own templates)*, e.g. `https://www.example.com/help`. It counts from the moment it is
   saved, so your pages render before and after *Check setup*;
2. otherwise an **active subfolder address** on the customer's site;
3. otherwise a **connected custom domain**, e.g. `https://help.example.com`;
4. otherwise the **Helpdesky address**, `https://helpdesky.io/help/<slug>`.

These URLs change when the customer changes that setting. Use them for the redirect rule:

> **Redirect rule.** Before rendering an article, a category or the index, take the host of
> the corresponding live URL (`url` for articles and categories, `helpdesk.url` for the index):
>
> - **your own host** → render the page;
> - **any other host** (including `helpdesky.io`, any `*.helpdesky.io` subdomain and a
>   custom domain of the help center) → respond with a **301 redirect to that URL** instead
>   of rendering. The customer has given the help center another home (removed
>   the headless address, connected a custom domain, chosen a subfolder on another site, or
>   gone back to the Helpdesky address). Helpdesky cannot serve redirects from your server,
>   so this rule is what keeps the old URLs on your site working and carries their SEO value
>   to the new home — with no code change and no redeploy.

"Your own host" is a constant in your code or configuration (`SITE_HOST`), never the
request's `Host` header. Compare hosts only (case-insensitive, ignore a leading `www.` and
the port); the path on your site may differ from the live path.

Consequence: until the customer has saved your base URL in Domain & Address, the live
address is still the Helpdesky address (or their custom domain) and your routes redirect
there instead of rendering. That is expected — ask them to save it, then purge your cache
of the API responses.

### 7.1 Before launch: save the headless address, then Check setup

Have the customer open *Settings → Domain & Address* in the Helpdesky dashboard and, under
**Headless (your own templates)**, enter the public base URL of your pages
(e.g. `https://www.example.com/help`) and click **Save**. From then on `url` points at
your site and your pages render.

Once your routes are deployed, the customer clicks **Check setup**: Helpdesky fetches one
published article at `base/<article-slug>` and passes when it answers 200 and the page
contains that article's title. The first pass makes your pages the canonical home of the
help center: the hosted copy at the Helpdesky address keeps serving but points its
canonical/OG/JSON-LD at your pages, a custom domain or an old subfolder address 301s to
them path-for-path, and the dashboard and widget link to them.

- A **subfolder address** cannot coexist with a headless address: Helpdesky refuses to
  save one while the other exists. If the help center has one, the customer removes it
  first (its old URLs keep redirecting).
- A **connected custom domain** may stay connected: once Check setup passes it becomes a
  previous address that only redirects to your pages.
- The hosted copy at the Helpdesky address stays online alongside your site, each with its
  own canonical URL (§8); it is what Check setup and crawlers reach.

---

## 8. SEO requirements

- `<title>`: article `title` (optionally suffixed with the help center or site name);
  `<meta name="description">`: `excerpt`.
- `<link rel="canonical">` on every article, category and index page pointing to the
  page's URL **on the customer's site** (not to helpdesky.io). The hosted copy at the
  Helpdesky address keeps its own canonical URL; your pages are the ones you want ranking
  for the customer's domain.
- Add every article URL (`base/<slug>`), every category URL and the index to the site's
  sitemap; use `updatedAt` as `<lastmod>`. The flat `articles` endpoint gives the full list.
- Server-render or statically generate the pages (SSR/SSG). Client-side-only rendering of
  the article HTML is acceptable for apps behind a login, not for public SEO pages.
- Do not add `noindex` to help pages; do not block `base/` in robots.txt.
- Optional but recommended: `Article`/`TechArticle` JSON-LD with `headline` = `title`,
  `dateModified` = `updatedAt`, `datePublished` = `createdAt`, `author` from `authors`
  (`Organization` when `organizationAsAuthor`), and `BreadcrumbList` for index → category → article.

---

## 9. Acceptance criteria (self-check)

- [ ] `base/` returns 200 and lists every category and article from `GET …/categories`.
- [ ] `base/<article-slug>` returns 200, contains the `title` in `<h1>` and `<title>`, the
      `excerpt` as meta description, and the API `html` inside exactly one
      `.helpdesky-article` element; the stylesheet is linked.
- [ ] An article containing a callout, a button, two action cards, a code block, a table,
      an image and a YouTube link renders them styled: coloured callout box, filled/outlined
      button, 2-column card grid, dark code block, bordered table, rounded image, 16:9 video.
      Compare against the same article on the hosted help center (`url` in the response).
- [ ] An article with `numberHeadings: true` shows numbered badges before each H2.
- [ ] Every `toc` entry links to an element with that id on the page and the browser scrolls to it.
- [ ] `base/category/<category-slug>` returns 200 for each category and 404 for an unknown slug.
- [ ] An unknown or draft article slug returns a 404 status (not 200, not a redirect to the index).
- [ ] Requesting an old slug of a renamed article returns a 301 to the new slug.
- [ ] Each article/category/index page has a `<link rel="canonical">` pointing at its own URL on the customer's site.
- [ ] The sitemap includes every article, category and the index with `lastmod`.
- [ ] Uploaded images load from `https://helpdesky.io/api/images/…` (check the network panel: no image request goes to your own host) and no `src`/`href` in the injected HTML is a relative path.
- [ ] Links to other articles point at your own `base/<slug>` pages (rule 10), not at helpdesky.io.
- [ ] Redirect rule: with a live `url` on your own host the page renders; with a live `url` on any other host (including `helpdesky.io`) the route answers 301 to that URL (simulate by temporarily hard-coding such a URL in a test).
- [ ] API responses are cached for at least 60 s on your side; repeated page loads do not make repeated API calls within that window.
- [ ] Authors are rendered only when `showAuthors` is `true`.
- [ ] The HTML is not escaped (no visible `<p>` text) and not re-parsed as Markdown (callouts and action cards keep their classes).

---

## 10. Minimal example (Node / any framework)

```js
const HELPDESK = "acme";                 // help center slug
const API = `https://helpdesky.io/api/content/v1/helpdesk/${HELPDESK}`;
const SITE_HOST = "www.example.com";      // your own host
const BASE = "/help";

async function getJson(url, req) {
  const res = await fetch(url, {
    headers: {
      "User-Agent": req.headers["user-agent"] || "",
      "X-Forwarded-For": req.ip || "",
    },
  });
  if (!res.ok) return { status: res.status, data: null };
  return { status: 200, data: await res.json() };
}

// Returns a redirect target when the help center's live address is not on this site (§7)
function redirectTarget(liveUrl) {
  try {
    const host = new URL(liveUrl).hostname.toLowerCase().replace(/^www\./, "");
    return host === SITE_HOST.replace(/^www\./, "") ? null : liveUrl;
  } catch { return null; }
}

// Links between articles arrive as absolute URLs under helpdesk.url; keep visitors on this site (§5 rule 10)
function localizeLinks(html, helpdeskUrl) {
  return html.split(helpdeskUrl + "/").join(BASE + "/");
}

// GET /help/:slug
async function articlePage(req, res) {
  const { status, data } = await getJson(`${API}/articles/${encodeURIComponent(req.params.slug)}`, req);
  if (status === 404) return res.status(404).render("404");
  if (status === 410) return res.status(410).render("410");
  if (!data) return res.status(503).render("error");
  if (data.slug !== req.params.slug) return res.redirect(301, `${BASE}/${data.slug}`);   // §6.3
  const away = redirectTarget(data.url);
  if (away) return res.redirect(301, away);                                              // §7

  res.render("article", {
    canonical: `https://${SITE_HOST}${BASE}/${data.slug}`,
    article: { ...data, html: localizeLinks(data.html, data.helpdesk.url) },
    wrapperClass: "helpdesky-article" + (data.numberHeadings ? " numbered-headings" : ""),
  });
}
```

```html
<!-- article template -->
<head>
  <title><%= article.title %> · Acme Help</title>
  <meta name="description" content="<%= article.excerpt %>">
  <link rel="canonical" href="<%= canonical %>">
  <link rel="stylesheet" href="https://helpdesky.io/api/content/v1/article.css">
</head>
<body>
  <!-- your header -->
  <nav><a href="/help">Help</a> <% if (article.category) { %>› <a href="/help/category/<%= article.category.slug %>"><%= article.category.name %></a><% } %></nav>
  <h1><%= article.title %></h1>
  <% if (article.showAuthors) { %><p class="byline"><% article.authors.forEach(a => { %><span><%= a.name %></span> <% }) %></p><% } %>
  <% if (article.toc.length >= 2) { %>
    <ul class="toc"><% article.toc.forEach(t => { %><li class="level-<%= t.level %>"><a href="#<%= t.id %>"><%= t.text %></a></li><% }) %></ul>
  <% } %>
  <article class="<%= wrapperClass %>"><%- article.html %></article>
  <p>Last updated <%= new Date(article.updatedAt).toLocaleDateString() %></p>
  <!-- your footer -->
</body>
```

Index (`/help`) and category (`/help/category/:slug`) pages follow the same pattern with
`GET ${API}/categories`, applying the redirect rule to `helpdesk.url` and the category's
`url` respectively.
