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 |
|---|---|
| Register a webhook. |
| List your webhooks. |
| 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).
The webhook object
Field | Type | Description |
|---|---|---|
| integer | The webhook's ID. |
| string | Where deliveries go. |
| integer | The help center whose events it receives. |
| boolean | Always |
| string or null | When a delivery last succeeded, in ISO 8601 UTC. |
| string | When it was registered, in ISO 8601 UTC. |
| string | The key that signs deliveries. Only in the response that registers the webhook. |
Register a webhook
/v1/webhooksRegister a URL to receive every event of your help center. The secret appears in this response only.
Field | Type | Description |
|---|---|---|
| string | Required. An |
| 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
eventsfield, 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, solocalhostand addresses on your office network don't work. To receive deliveries on your computer while you develop, see 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
/v1/webhooksList 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
/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 |
|---|---|
| An article is created as a draft. |
| An article is created as published, or a draft is published. |
| 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. |
| An article is moved to the Trash, and again when it is deleted for good from the Trash one by one. |
| A category is created. |
| A category is saved, including when categories are reordered. |
| A category is deleted. |
| The help center's visibility, IP allow-list or suspension changes. Use it to drop content you cached while it was public. |
| 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). 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 sendsarticle.updatedfor each article whose position changed.Articles and subcategories moved when their category is deleted: only
category.deletedis sent. One exception: deleting a category with the API sendsarticle.updatedfor 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 |
|---|---|
|
|
| The event name. It is not signed: trust the |
| A new ID for every attempt, retries included. Don't use it to recognize a repeated delivery. |
|
|
There is no timestamp header, and the User-Agent is not one to rely on: recognize deliveries by their signature.
The body
Field | Description |
|---|---|
| The event name, for example |
| When the change happened, in ISO 8601 UTC. It stays the same on retries. |
| The help center's ID. |
| The help center's UUID. |
| What changed. Its |
For an article, data holds type, id, category_id, title, slug, published (a boolean), visibility, locale and updated_at:
titleandslugare in your help center's default language, whichlocalenames. Other languages and the article's text are not included: fetch the article with the Articles endpoints when you need them.category_idisnullwhen the article has no category or sits in several categories.visibilityispublic,link_only(anyone with the link) orprivate.article.deletedcarries 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).
{"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:
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.
Compute the HMAC-SHA256 of those bytes, keyed with the webhook's secret, as lowercase hexadecimal, and put
sha256=in front.Compare the result with the whole
X-HCio-Signatureheader in constant time. If they differ, or the header is missing, answer401and 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.
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
400counts as delivered and updateslast_delivered_at. That includes3xx: 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
400or 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 |
|---|---|
| The key or token is missing or not valid: |
|
|
| "Webhook subscription not found.": no webhook with this ID that you can manage. |
| The |
| The |