---
title: Webhooks
description: Receive signed webhook deliveries from alerting, CI or other services, and run a markdown task that handles exactly the events it claimed.
canonical_url: https://hubzoid.com/docs/guides/webhooks
last_updated: 2026-09-27
---

# Webhooks

Receive signed webhook deliveries from alerting, CI or other services, and run a markdown task that handles exactly the events it claimed.

The generic webhook surface lets any system that can send an HTTP POST start work in a Hub: an alerting tool, a CI run, a form backend, an ERP automation. Hubzoid proves the delivery is authentic, stores it as a file in the hub's inbox, and a markdown task with `on_webhook:` handles it.

## When to use this

Use a webhook when an outside event should start the work, rather than a clock. The receiver never calls the model and never answers the sender with content, so a public endpoint only accepts, verifies and stores. The task that handles the event is trusted hub code and acts as its configured account, like any [markdown task](https://hubzoid.com/docs/guides/markdown-tasks#access).

Signed POST → Verify and dedupe → Event file in inbox → Task queued → Run handles events → Archived on DONE

A delivery is stored before any work starts, and archived only after a run that handled it finishes DONE.

## Set up an endpoint

1. **Configure the secret and name**

   ```bash title="my-hub/.env"
   WEBHOOK_INBOUND_SECRET=<a long random secret>
   WEBHOOK_INBOUND_NAME=alerting        # the endpoint name, default: webhook
   WEBHOOK_INBOUND_HMAC=false           # true: verify an HMAC signature instead
   ```

   The surface is on only when `WEBHOOK_INBOUND_SECRET` is set. Without a secret, the endpoint is not served.

2. **Start the hub with the webhook surface**

   ```bash
   hubzoid run my-hub --webhook
   ```

   Hubzoid starts an inbound server on a loopback port (`HUBZOID_INBOUND_PORT`, default `8100`) and the public port forwards `/webhooks/<hub>` to it. The endpoint is:

   ```text
   https://<host>/webhooks/<hub>/<name>
   ```

   `<hub>` is the hub folder name in lower case, with each run of characters other than letters and digits turned into one `-`, or the value of `HUBZOID_HUB_SLUG` when set. `<name>` is `WEBHOOK_INBOUND_NAME`. Locally, a hub in `my-hub/` with the settings above receives at `http://127.0.0.1:3080/webhooks/my-hub/alerting`. The route accepts `POST` only.

3. **Write the task that handles events**

   ```markdown title="schedule/alert-intake.md"
   ---
   on_webhook: alerting          # must match WEBHOOK_INBOUND_NAME
   timeout: 300
   max_rounds: 2
   max_turns: 20
   write: ["output/alerts/"]
   ---

   Handle each alert event file this run was given. They are listed above
   these instructions.

   - Read each file with `read_file`. The alert payload is under `body`.
   - Append one line per alert to `output/alerts/intake-<YYYY-MM-DD>.md`
     (date from `current_time`): time received, alert name, host and severity.
     Read the file first so you append rather than overwrite.
   - Your state file records the event file names already handled, so a
     retried run does not add a line twice.
   - You are done when every listed event has a line and is recorded in the
     state file.
   ```

   `on_webhook: true` is shorthand for the default name `webhook`. A task has `on_webhook:` or `schedule:`, never both. Every other frontmatter key from [Markdown tasks](https://hubzoid.com/docs/guides/markdown-tasks#frontmatter) applies, including `commit:`, `push:`, `model:`, `run:` and `run_as:`.

## Authentication

Every request is checked before anything else runs. A missing or wrong secret or signature gets `403` and nothing is stored.

| Mode | Set | The sender sends |
| --- | --- | --- |
| Shared secret (default) | `WEBHOOK_INBOUND_HMAC=false` | `Authorization: Bearer <secret>` or `X-Webhook-Secret: <secret>` |
| HMAC signature | `WEBHOOK_INBOUND_HMAC=true` | `X-Signature-256: sha256=<hex>` or `X-Hub-Signature-256: sha256=<hex>`, the HMAC-SHA256 of the raw request body keyed with the secret |

Comparisons are constant time. In shared secret mode a `?token=<secret>` query parameter is also accepted, but query strings end up in access logs and in the stored event, so send the secret in a header.

### GitHub

GitHub signs each delivery with `X-Hub-Signature-256` and identifies it with `X-GitHub-Delivery`, both of which Hubzoid reads. Set `WEBHOOK_INBOUND_HMAC=true`, then in the repository's webhook settings use the endpoint as the payload URL, choose the `application/json` content type and enter the same secret as `WEBHOOK_INBOUND_SECRET`.

## Responses

| Response | When |
| --- | --- |
| `200 ok` | The delivery was verified and stored. |
| `200 duplicate` | A repeat of a delivery already stored. It is not stored again. |
| `403 forbidden` | The secret or signature is missing or wrong. |
| `500 sink error` | The event could not be written. Nothing is recorded, so the sender's retry is stored. |
| `503 in progress` | Another copy of the same delivery is being stored at this moment. The sender should retry. |

A body that is not JSON is kept as text, never rejected.

## Deduplication

Providers retry deliveries, so Hubzoid stores each delivery once.

- When the request carries a delivery id, the first of `X-GitHub-Delivery`, `X-Delivery-Id`, `X-Webhook-Id`, `Idempotency-Key` or `X-Request-Id`, that id is remembered indefinitely and a repeat is answered `200 duplicate`.
- Without an id header, a body identical to one stored in the current or previous 10 minute window counts as a repeat. Identical events far apart are both accepted.
- A delivery is marked as seen only after it is stored. A crash while storing leaves no mark, so the provider's retry is accepted.

Seen markers live in `.inbound/dedup/` inside the hub.

## What a stored event looks like

Each verified delivery becomes one JSON file in `.inbound/webhooks/<name>/`, named `<epoch nanoseconds>-<random>.json` so that name order is arrival order. The file is written atomically, so a task never reads half an event.

```json title=".inbound/webhooks/alerting/1758790000123456789-1a2b3c4d.json"
{
  "surface": "webhook",
  "name": "alerting",
  "received_at": 1758790000.123,
  "query": {},
  "content_type": "application/json",
  "body": {"alert": "disk_full", "host": "store-2", "severity": "critical"}
}
```

Request headers are used for verification and deduplication and are not written to the file. When the event type travels in a header, give each sender its own query parameter in the URL, which is stored under `query`.

## How a task learns which events it owns

- A webhook task is due while `.json` files wait at the top level of its inbox and no run of it is queued or running. The scheduler checks every 30 seconds, and a due task waits while the hub is answering chat.
- When it queues a run, the scheduler claims every pending file. The run's id is `md:<task>:events-<YYYYMMDDTHHMM>-<hash>`, derived from the claimed files, so two schedulers that see the same batch queue one run.
- An agent task finds the claimed files listed in every round's prompt, with the instruction that any other file in the inbox arrived later and belongs to the next run.
- A `run:` script finds them in `HUBZOID_WEBHOOK_EVENTS`, one absolute path per line, and the account the run acts as in `HUBZOID_RUN_AS`.
- When the run finishes `done`, Hubzoid moves the claimed files into `.inbound/webhooks/<name>/.processed/`.
- A run that ends `error` or `incomplete` leaves them in place, so the task is queued again once that run has ended. Handling is at least once. Record what you handled in the state file.
- Events that arrive during a run wait for the next run and are never dropped.
- A manual `hubzoid schedule run` claims nothing, so the prompt has no list and the variable is not set.

A script task reads its events like this:

```python title="scripts/ingest_alerts.py"
import json
import os

for path in os.environ.get("HUBZOID_WEBHOOK_EVENTS", "").splitlines():
    with open(path) as fh:
        event = json.load(fh)
    alert = event["body"]
    ...
```

## Test a delivery

With the hub running with `--webhook` and the secret exported in your shell:

```bash
curl -X POST http://127.0.0.1:3080/webhooks/my-hub/alerting \
  -H "X-Webhook-Secret: $WEBHOOK_INBOUND_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"alert": "disk_full", "host": "store-2", "severity": "critical"}'
# ok

ls my-hub/.inbound/webhooks/alerting/        # the stored event
hubzoid schedule list my-hub                  # alert-intake  on webhook 'alerting'
```

Send the same command again and the answer is `duplicate`. Within about 30 seconds the task is queued. Follow it with `hubzoid schedule status my-hub` or on the agent's **Runs & schedules** tab in the Console, where it appears as `md:alert-intake`. After a `done` run the event file is under `.processed/`.

To test HMAC mode, sign the body yourself:

```bash
BODY='{"alert": "disk_full", "host": "store-2", "severity": "critical"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_INBOUND_SECRET" | sed 's/^.* //')
curl -X POST http://127.0.0.1:3080/webhooks/my-hub/alerting \
  -H "X-Signature-256: sha256=$SIG" \
  -H "Content-Type: application/json" \
  -d "$BODY"
```

## Behind a gateway

Under `hubzoid gateway`, the public edge forwards `/webhooks/<hub>` for every hub whose own `.env` configures an inbound surface. Run that hub's inbound server with `hubzoid inbound run <hub>`, or install the unit printed by `hubzoid inbound systemd <hub>`. Give each inbound hub its own `HUBZOID_INBOUND_PORT`. Set `HUBZOID_HUB_SLUG` only when two hubs share a folder name. See [Gateway](https://hubzoid.com/docs/deploy/gateway) and the [configuration reference](https://hubzoid.com/docs/reference/configuration).

## Next steps

- [Markdown tasks](https://hubzoid.com/docs/guides/markdown-tasks): Every frontmatter key, the round contract and scoped commits.
- [Operating runs](https://hubzoid.com/docs/guides/operating-runs): Pause a webhook task, cancel a run and troubleshoot deliveries.
- [Security model](https://hubzoid.com/docs/deploy/security-model): What the public port exposes and how each surface is authenticated.
