# Webhooks

_Category: REST API reference_

Webhooks tell your server about changes shortly after they happen. HelpCenter.io sends a signed `POST` request to your URL whenever an article or a category is created, published, updated or deleted, from the dashboard or the API, and when your help center's visibility changes. Use them to keep a search index, a cache or another system in sync without polling.

Webhooks are available on every plan. You manage them through the API only: there is no screen for them in the dashboard.

## Endpoints

| Method and path | What it does |
| --- | --- |
| `POST /v1/webhooks` | Register a webhook. |
| `GET /v1/webhooks` | List your webhooks. |
| `DELETE /v1/webhooks/{webhookId}` | Delete a webhook. |

All three need a **Read & write** API key, or an OAuth token with the `webhooks.manage` scope. A Read only key gets `403 Forbidden` with the code `insufficient_scope`. A webhook belongs to one help center: the one of the API key that registers it, or the one an OAuth app names with the `X-HCio-Site` header (see [Choose which help center a request acts on](https://developers.helpcenter.io/content/choosing-a-help-center)).

## The webhook object

| Field | Type | Description |
| --- | --- | --- |
| `id` | integer | The webhook's ID. |
| `url` | string | Where deliveries go. |
| `site_id` | integer | The help center whose events it receives. |
| `active` | boolean | Always `true`. A webhook is never switched off automatically, even when deliveries keep failing. |
| `last_delivered_at` | string or null | When a delivery last succeeded, in ISO 8601 UTC. `null` until the first one. |
| `created_at` | string | When it was registered, in ISO 8601 UTC. |
| `secret` | string | The key that signs deliveries. Only in the response that registers the webhook. |

## Register a webhook

POST`/v1/webhooks`

Register a URL to receive every event of your help center. The secret appears in this response only.

| Field | Type | Description |
| --- | --- | --- |
| `url` | string | Required. An `http://` or `https://` URL at a public address, up to 191 characters. Use `https://` so deliveries travel encrypted. |
| `secret` | string | 16 to 191 characters. Leave it out and HelpCenter.io generates a random 48-character secret of letters and digits. |

- **Store the secret now.** The response to this request is the only place it appears, whether you sent it or it was generated.
- **Every webhook receives every event** of its help center. There is no event filter: an `events` field, or any other field, is ignored. Read the event name in each delivery and skip the ones you don't need.
- **Private, loopback, link-local and other reserved addresses are refused** with `422 Unprocessable Entity`, whether you give an IP address or a hostname that resolves to one, so `localhost` and addresses on your office network don't work. To receive deliveries on your computer while you develop, see [Receive and verify webhooks](https://developers.helpcenter.io/content/receive-and-verify-webhooks).
- A hostname that doesn't resolve yet is accepted, but deliveries to it are skipped until it does.
- A URL or a secret longer than 191 characters passes validation and then fails with `500 Internal Server Error`, and nothing is registered.
- The same URL can be registered twice: it then receives every event twice.
- A webhook can't be edited. To change its URL or secret, register a new webhook, let your receiver accept both secrets, then delete the old webhook.

## List webhooks

GET`/v1/webhooks`

List your webhooks, without their secrets. last_delivered_at shows the last successful delivery.

Webhooks come oldest first, all in one response, with `meta.items_count`. Secrets are never listed.

An API key lists the webhooks of its help center. With an OAuth token, the list can hold webhooks of several help centers: use `site_id` to tell them apart. `last_delivered_at` helps you spot a receiver that stopped answering.

## Delete a webhook

DELETE`/v1/webhooks/{webhookId}`

Delete a webhook. Deliveries to it stop, including retries that are still waiting.

Deletion is permanent, and deliveries still waiting for a retry are dropped. You can delete the webhooks you can list.

## Events

| Event | Sent when |
| --- | --- |
| `article.created` | An article is created as a draft. |
| `article.published` | An article is created as published, or a draft is published. |
| `article.updated` | Any other change to an article: an edit, unpublishing it, publishing its staged changes, restoring it from the Trash. A change that only counts a view is not sent. |
| `article.deleted` | An article is moved to the Trash, and again when it is deleted for good from the Trash one by one. |
| `category.created` | A category is created. |
| `category.updated` | A category is saved, including when categories are reordered. |
| `category.deleted` | A category is deleted. |
| `site.updated` | The help center's visibility, IP allow-list or suspension changes. Use it to drop content you cached while it was public. |
| `site.deleted` | The help center is being deleted. |

Some changes send no event. Watch for them another way if they matter to you:

- Staging an edit (see [Staged changes and versions](https://developers.helpcenter.io/content/staged-changes-api)). Publishing the staged changes sends `article.updated`.
- Reordering the articles in a category with `PUT /v1/categories/{categoryId}/articles/order`. Reordering them in the dashboard sends `article.updated` for each article whose position changed.
- Articles and subcategories moved when their category is deleted: only `category.deleted` is sent. One exception: deleting a category with the API sends `article.updated` for an article that was in several categories and is left in only one.
- Moving several articles to the Trash at once in the dashboard. Moving one article to the Trash sends `article.deleted`.
- Emptying the Trash.
- Reader comments and team notes.

Imports and `POST /v1/articles/bulk` send one event per article.

## What a delivery looks like

Each delivery is a `POST` with a JSON body. This one was sent when a draft was published:

```
POST /webhooks/helpcenter HTTP/1.1
Host: example.com
Content-Type: application/json
Content-Length: 346
X-HCio-Signature: sha256=6655fa6850115216a871e47826d2953df33c518a823ae02e706d6c619309dc1a
X-HCio-Event: article.published
X-HCio-Delivery: f5f24b4d-6b3b-4286-87d6-b02a44afca7f

{"event":"article.published","occurred_at":"2026-09-30T08:23:43+00:00","site_id":380,"helpcenter_id":"a2ca2ddb-86c1-4657-9ec3-327a8ca50554","data":{"type":"article","id":742,"category_id":327,"title":"Download an invoice","slug":"download-an-invoice","published":true,"visibility":"public","locale":"en","updated_at":"2026-09-30T08:23:43+00:00"}}
```

The body is compact JSON on one line, with slashes and non-ASCII characters left unescaped. The webhook's secret here was `replace-with-a-long-random-secret`: use this body and signature to test your verification code.

| Header | Value |
| --- | --- |
| `X-HCio-Signature` | `sha256=` and the HMAC-SHA256 of the raw body, keyed with the webhook's secret, in lowercase hexadecimal. |
| `X-HCio-Event` | The event name. It is not signed: trust the `event` in the verified body. |
| `X-HCio-Delivery` | A new ID for every attempt, retries included. Don't use it to recognize a repeated delivery. |
| `Content-Type` | `application/json` |

There is no timestamp header, and the `User-Agent` is not one to rely on: recognize deliveries by their signature.

### The body

| Field | Description |
| --- | --- |
| `event` | The event name, for example `article.published`. |
| `occurred_at` | When the change happened, in ISO 8601 UTC. It stays the same on retries. |
| `site_id` | The help center's ID. |
| `helpcenter_id` | The help center's UUID. |
| `data` | What changed. Its `type` is `article`, `category` or `site`. |

For an article, `data` holds `type`, `id`, `category_id`, `title`, `slug`, `published` (a boolean), `visibility`, `locale` and `updated_at`:

- `title` and `slug` are in your help center's default language, which `locale` names. Other languages and the article's text are not included: fetch the article with the [Articles](https://developers.helpcenter.io/content/articles-api) endpoints when you need them.
- `category_id` is `null` when the article has no category or sits in several categories.
- `visibility` is `public`, `link_only` (anyone with the link) or `private`.
- `article.deleted` carries the article as it was when it was deleted.

A category's `data` holds `type`, `id`, `parent_id` (`null` for a top-level category), `name` in your default language, and `updated_at`:

```
{"event":"category.updated","occurred_at":"2026-09-30T08:23:43+00:00","site_id":380,"helpcenter_id":"a2ca2ddb-86c1-4657-9ec3-327a8ca50554","data":{"type":"category","id":327,"parent_id":null,"name":"Invoices and receipts","updated_at":"2026-09-30T08:23:43+00:00"}}
```

A help center's `data` holds `type`, `id` and `visibility`: `public`, `private` or `password`. It doesn't say whether the help center is suspended or restricted to some IP addresses: after a `site.updated`, read `publicly_accessible` with `GET /v1/sites` (see [Sites and account](https://developers.helpcenter.io/content/sites-and-account-api)).

```
{"event":"site.updated","occurred_at":"2026-09-30T08:23:43+00:00","site_id":380,"helpcenter_id":"a2ca2ddb-86c1-4657-9ec3-327a8ca50554","data":{"type":"site","id":380,"visibility":"private"}}
```

## Verify the signature

Check every delivery before you act on it:

1. Read the raw request body, exactly as received. Don't parse the JSON and encode it again: the bytes can change, and the signature would no longer match.
2. Compute the HMAC-SHA256 of those bytes, keyed with the webhook's secret, as lowercase hexadecimal, and put `sha256=` in front.
3. Compare the result with the whole `X-HCio-Signature` header in constant time. If they differ, or the header is missing, answer `401` and ignore the delivery.

The signature covers the body only. `occurred_at` is inside it, so you can reject old deliveries as replays, but keep the window generous: retries carry the original `occurred_at`, and the last one comes about 8 minutes after the first attempt. For complete receivers in Node.js, Python and PHP, see [Receive and verify webhooks](https://developers.helpcenter.io/content/receive-and-verify-webhooks).

## Timeouts and retries

- Deliveries are sent in the background, shortly after the change is saved.
- Your receiver has **10 seconds** to answer. Answer first and do slow work afterward.
- Any status below `400` counts as delivered and updates `last_delivered_at`. That includes `3xx`: redirects are not followed, so a receiver that redirects never gets the delivery, yet the webhook looks healthy. Register the final URL.
- A status of `400` or higher, a timeout or a connection error counts as a failure. HelpCenter.io tries again after 10 seconds, 30 seconds, 2 minutes and 5 minutes. After the fifth failed attempt, that delivery is dropped. The webhook stays active, and nobody is notified.
- A retry has the same body and signature as the first attempt, and a new `X-HCio-Delivery`.
- The same change can arrive more than once, and deliveries can arrive out of order. A repeated delivery has the same signature, so keep the signatures you have handled and skip repeats. To put changes in order, compare `data.updated_at`.
- The address is checked again before every delivery. If the URL now resolves to a private address, or to no address at all, that delivery is skipped without retries.

## Troubleshooting

**"The request could not be accepted."** The API answers `422` and names the problem under `errors`: usually a URL that isn't a public address ("The url must be a publicly reachable address"), a scheme other than `http` or `https`, or a secret shorter than 16 characters.

**Signatures never match.** Compute the signature over the raw body, before any JSON parsing, with the secret of this webhook, and compare the whole header value, `sha256=` included. A framework that parses JSON before your code runs usually offers a way to read the raw body.

**No deliveries arrive.** Check that the webhook belongs to the help center you are changing (list it with the key you use), that your URL is reachable from the internet and doesn't redirect, and that its hostname resolves.

**The same event arrives twice.** Either a retry reached you after your receiver had already handled the first attempt, or the same URL is registered twice: list your webhooks and delete the duplicate.

## Errors

| Status | When |
| --- | --- |
| `401 Unauthorized` | The key or token is missing or not valid: `{"status": "unauthorized"}`. |
| `403 Forbidden` | `insufficient_scope`: a Read only key, or an OAuth token without `webhooks.manage`. |
| `404 Not Found` | "Webhook subscription not found.": no webhook with this ID that you can manage. |
| `422 Unprocessable Entity` | The `url` or `secret` is not valid. `errors` names the field. |
| `500 Internal Server Error` | The `url` or `secret` is longer than 191 characters. |

## Related

- [Receive and verify webhooks](https://developers.helpcenter.io/content/receive-and-verify-webhooks)
- [Articles](https://developers.helpcenter.io/content/articles-api)
- [Scopes](https://developers.helpcenter.io/content/scopes)
