# Receive and verify webhooks

_Category: Guides and recipes_

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](https://developers.helpcenter.io/content/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](https://developers.helpcenter.io/content/articles-api) 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](https://developers.helpcenter.io/content/webhooks), with the secret given there.

## Step 6: Receive a real delivery

Register the public URL of your receiver (see [Webhooks](https://developers.helpcenter.io/content/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](https://developers.helpcenter.io/content/webhooks).

## Related

- [Webhooks](https://developers.helpcenter.io/content/webhooks)
