Hubzoid
Guides

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.

  1. Signed POST
  2. Verify and dedupe
  3. Event file in inbox
  4. Task queued
  5. Run handles events
  6. 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

Configure the secret and name

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.

Start the hub with the webhook surface

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:

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.

Write the task that handles events

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

ModeSetThe sender sends
Shared secret (default)WEBHOOK_INBOUND_HMAC=falseAuthorization: Bearer <secret> or X-Webhook-Secret: <secret>
HMAC signatureWEBHOOK_INBOUND_HMAC=trueX-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

ResponseWhen
200 okThe delivery was verified and stored.
200 duplicateA repeat of a delivery already stored. It is not stored again.
403 forbiddenThe secret or signature is missing or wrong.
500 sink errorThe event could not be written. Nothing is recorded, so the sender's retry is stored.
503 in progressAnother 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.

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

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:

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:

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 and the configuration reference.

Next steps

Read this page as Markdown

Choose which cookies Hubzoid can use. You can change this at any time from Cookie settings in the footer. Read the Cookie Policy for details.