Guides and recipes

Receive and verify webhooks

Export
Download Markdown Use with AI

Build an endpoint that accepts HelpCenter.io webhook deliveries, checks that each one really comes from HelpCenter.io, and handles every change once. This guide builds a complete receiver in Node.js, Python and PHP, and shows how to test it before any real delivery arrives.

Before you start

  • A webhook and its secret. Register one with POST /v1/webhooks and a Read & write API key (see Webhooks). The secret appears only in that response. Keep it on your server, for example in an environment variable named HELPCENTER_WEBHOOK_SECRET, and never in code that runs in a browser.

  • A public URL. HelpCenter.io delivers only to public addresses: localhost and private network addresses are refused. While you develop, expose your local server through a tunneling tool that gives it a public HTTPS address, and register that address.

  • Node.js 18 or later, Python 3.8 or later, or PHP 8 or later. The receivers below use no packages.

Step 1: Verify the signature

Every delivery carries an X-HCio-Signature header: sha256= followed by the HMAC-SHA256 of the raw request body, keyed with your webhook's secret, in lowercase hexadecimal. Recompute it and compare:

  • Use the body exactly as it arrived, before any JSON parsing. Parsing and encoding it again can change the bytes, and then the signature no longer matches. If your framework parses JSON for you, read the raw body instead: express.raw() in Express, request.get_data() in Flask, $request->getContent() in Laravel.

  • Compare the whole header value, sha256= included, in constant time.

  • Answer 401 when the header is missing or doesn't match, and ignore the delivery.

const crypto = require('node:crypto');

const SECRET = process.env.HELPCENTER_WEBHOOK_SECRET;

function isValidSignature(rawBody, header) {
  const expected = Buffer.from(
    'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex')
  );
  const received = Buffer.from(String(header || ''));
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
import hashlib
import hmac
import os

SECRET = os.environ["HELPCENTER_WEBHOOK_SECRET"].encode()


def is_valid_signature(raw_body, header):
    expected = "sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), (header or "").encode())
$secret = (string) getenv('HELPCENTER_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_HCIO_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);

if ($secret === '' || ! hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

Step 2: Answer within 10 seconds

HelpCenter.io waits 10 seconds for your answer. Answer with a 2xx status as soon as the delivery is verified, and do the work afterward: the receivers below answer 204, then hand the event to a background task.

A status of 400 or higher, a timeout or a connection error makes HelpCenter.io try again after 10 seconds, 30 seconds, 2 minutes and 5 minutes; after the fifth failed attempt, the delivery is dropped. Don't answer with a redirect: a 3xx counts as delivered, and the redirect is not followed.

Step 3: Handle each change once

A retry repeats the body and the signature exactly, with a new X-HCio-Delivery header, so the same change can reach you twice. Keep the signatures of the deliveries you have handled, and answer 204 without doing the work again when one comes back. Keep them longer than retries last: the last one comes about 8 minutes after the first attempt.

Deliveries can also arrive out of order. Before you apply a change, compare data.updated_at with the value you stored, or fetch the current state from the API: a delivery carries IDs and a few fields, not the article's text, so fetch the article with the Articles endpoints when you need its content. After article.deleted, the article is in the Trash, or deleted for good, and the API answers 404 for it.

Step 4: Run the receiver

Each program verifies the signature, answers 204, skips repeated deliveries and prints every event. Replace the console.log, print or error_log calls with your own work. The list of deliveries already handled lives in memory (or in temporary files in PHP): use your database in production.

// server.js: receive HelpCenter.io webhooks (Node.js 18+, no dependencies)
const http = require('node:http');
const crypto = require('node:crypto');

const SECRET = process.env.HELPCENTER_WEBHOOK_SECRET;
const PORT = Number(process.env.PORT || 3000);
const seen = new Set(); // Deliveries already handled. Use your database in production.

function isValidSignature(rawBody, header) {
  const expected = Buffer.from(
    'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex')
  );
  const received = Buffer.from(String(header || ''));
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

function handleEvent(payload) {
  const { event, data } = payload;
  if (event.startsWith('article.')) {
    console.log(`${event}: article ${data.id} "${data.title}"`);
  } else if (event.startsWith('category.')) {
    console.log(`${event}: category ${data.id} "${data.name}"`);
  } else if (event.startsWith('site.')) {
    console.log(`${event}: help center ${data.id} is ${data.visibility}`);
  } else {
    console.log(`ignored ${event}`);
  }
}

const server = http.createServer((req, res) => {
  if (req.method !== 'POST' || req.url !== '/webhooks/helpcenter') {
    res.writeHead(404).end();
    return;
  }

  const chunks = [];
  req.on('data', (chunk) => chunks.push(chunk));
  req.on('end', () => {
    const rawBody = Buffer.concat(chunks);
    const signature = req.headers['x-hcio-signature'];

    if (!SECRET || !isValidSignature(rawBody, signature)) {
      res.writeHead(401).end('Invalid signature');
      return;
    }

    let payload;
    try {
      payload = JSON.parse(rawBody.toString('utf8'));
    } catch {
      res.writeHead(400).end('Invalid JSON');
      return;
    }

    // Answer first: HelpCenter.io waits 10 seconds at most.
    res.writeHead(204).end();

    // A retry repeats the body and the signature exactly.
    if (seen.has(signature)) return;
    seen.add(signature);

    setImmediate(() => handleEvent(payload));
  });
});

server.listen(PORT, () => {
  console.log(`Listening on http://localhost:${PORT}/webhooks/helpcenter`);
});
# server.py: receive HelpCenter.io webhooks (Python 3.8+, standard library only)
import hashlib
import hmac
import json
import os
import queue
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

SECRET = os.environ["HELPCENTER_WEBHOOK_SECRET"].encode()
PORT = int(os.environ.get("PORT", "3000"))
jobs = queue.Queue()
seen = set()  # Deliveries already handled. Use your database in production.
seen_lock = threading.Lock()


def is_valid_signature(raw_body, header):
    expected = "sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), (header or "").encode())


def handle_event(payload):
    event, data = payload["event"], payload["data"]
    if event.startswith("article."):
        print(f'{event}: article {data["id"]} "{data["title"]}"', flush=True)
    elif event.startswith("category."):
        print(f'{event}: category {data["id"]} "{data["name"]}"', flush=True)
    elif event.startswith("site."):
        print(f'{event}: help center {data["id"]} is {data["visibility"]}', flush=True)
    else:
        print(f"ignored {event}", flush=True)


def worker():
    while True:
        handle_event(jobs.get())


class WebhookHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/webhooks/helpcenter":
            self.send_response(404)
            self.end_headers()
            return

        raw_body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        signature = self.headers.get("X-HCio-Signature", "")

        if not is_valid_signature(raw_body, signature):
            self.send_response(401)
            self.end_headers()
            return

        try:
            payload = json.loads(raw_body)
        except ValueError:
            self.send_response(400)
            self.end_headers()
            return

        # Answer first: HelpCenter.io waits 10 seconds at most.
        self.send_response(204)
        self.end_headers()

        # A retry repeats the body and the signature exactly.
        with seen_lock:
            if signature in seen:
                return
            seen.add(signature)
        jobs.put(payload)

    def log_message(self, format, *args):
        pass  # Print only the events below.


threading.Thread(target=worker, daemon=True).start()
print(f"Listening on http://localhost:{PORT}/webhooks/helpcenter", flush=True)
ThreadingHTTPServer(("", PORT), WebhookHandler).serve_forever()
<?php
// webhook.php: receive HelpCenter.io webhooks (PHP 8+, no packages)
// Try it locally with: php -S localhost:3000 webhook.php

$secret = (string) getenv('HELPCENTER_WEBHOOK_SECRET');
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $path !== '/webhooks/helpcenter') {
    http_response_code(404);
    exit;
}

$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_HCIO_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);

if ($secret === '' || ! hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

$payload = json_decode($rawBody, true);
if (! is_array($payload)) {
    http_response_code(400);
    exit('Invalid JSON');
}

// Answer first: HelpCenter.io waits 10 seconds at most.
http_response_code(204);
if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();
}

// A retry repeats the body and the signature exactly.
// Deliveries already handled: use your database in production.
$seenFile = sys_get_temp_dir() . '/hcio-webhook-' . hash('sha256', $signature);
if (file_exists($seenFile)) {
    exit;
}
touch($seenFile);

handleEvent($payload);

function handleEvent(array $payload): void
{
    $event = $payload['event'];
    $data = $payload['data'];

    if (str_starts_with($event, 'article.')) {
        error_log("{$event}: article {$data['id']} \"{$data['title']}\"");
    } elseif (str_starts_with($event, 'category.')) {
        error_log("{$event}: category {$data['id']} \"{$data['name']}\"");
    } elseif (str_starts_with($event, 'site.')) {
        error_log("{$event}: help center {$data['id']} is {$data['visibility']}");
    } else {
        error_log("ignored {$event}");
    }
}

Start it with your webhook's secret. Each listens on http://localhost:3000/webhooks/helpcenter:

export HELPCENTER_WEBHOOK_SECRET="your webhook secret"

node server.js                      # Node.js
python3 server.py                   # Python
php -S localhost:3000 webhook.php   # PHP, with its built-in server

In production, run the PHP receiver behind your web server with PHP-FPM, where fastcgi_finish_request() sends the answer before the work starts.

Step 5: Send yourself a test delivery

You can test the receiver before HelpCenter.io sends it anything. This script signs a delivery with your secret the way HelpCenter.io does, and posts it:

# Send a signed test delivery to your receiver.
SECRET="$HELPCENTER_WEBHOOK_SECRET"
BODY='{"event":"article.updated","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 or print an invoice","slug":"download-an-invoice","published":true,"visibility":"public","locale":"en","updated_at":"2026-09-30T08:23:43+00:00"}}'
SIGNATURE="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')"

curl -i -X POST "http://localhost:3000/webhooks/helpcenter" \
  -H "Content-Type: application/json" \
  -H "X-HCio-Event: article.updated" \
  -H "X-HCio-Signature: $SIGNATURE" \
  --data-binary "$BODY"

The receiver answers HTTP/1.1 204 No Content and prints the event:

article.updated: article 742 "Download or print an invoice"

Change one character of BODY after the signature is computed, and the receiver answers HTTP/1.1 401 Unauthorized. Sign exactly the bytes you send: echo adds a newline at the end, printf '%s' doesn't, and --data-binary sends the body unchanged.

To check your code against a delivery that HelpCenter.io itself signed, use the example body and signature in Webhooks, with the secret given there.

Step 6: Receive a real delivery

Register the public URL of your receiver (see Webhooks), then change something in your help center, for example create a draft article or rename a category. The delivery arrives shortly after the change is saved, and your receiver prints its event. Editing a published article on a help center that uses staged changes sends nothing until the changes are published.

Troubleshooting

Your receiver answers 401 to real deliveries. Check that HELPCENTER_WEBHOOK_SECRET holds the secret of this webhook: every webhook has its own, and a lost secret can't be shown again, so register a new webhook and delete the old one. Then check that nothing changes the body before you verify it: a framework that parses JSON, or a proxy that rewrites requests. Deliveries you refuse are retried and then dropped, so fetch the current state from the API once your receiver works again.

Deliveries time out. Answer before you do the work (step 2). HelpCenter.io waits 10 seconds.

The same change is handled twice. Keep the signatures of handled deliveries (step 3). If the same event arrives with two different signatures, the same URL is registered twice: list your webhooks and delete the duplicate.

Nothing arrives. See the troubleshooting in Webhooks.

Was this article helpful?