REST API reference

Webhooks

Export
Download Markdown Use with AI

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

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.

  • 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). 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 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).

{"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.

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.

Was this article helpful?