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.
- Signed POST
- Verify and dedupe
- Event file in inbox
- Task queued
- Run handles events
- Archived on DONE
Set up an endpoint
Configure the secret and name
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 insteadThe 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 --webhookHubzoid 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
---
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.
| 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-KeyorX-Request-Id, that id is remembered indefinitely and a repeat is answered200 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.
{
"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
.jsonfiles 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 inHUBZOID_WEBHOOK_EVENTS, one absolute path per line, and the account the run acts as inHUBZOID_RUN_AS. - When the run finishes
done, Hubzoid moves the claimed files into.inbound/webhooks/<name>/.processed/. - A run that ends
errororincompleteleaves 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 runclaims nothing, so the prompt has no list and the variable is not set.
A script task reads its events like this:
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
Code workflows
Build a durable Python workflow with @workflow, @step and hub from the packaged Watchtower sample, choose the account it runs as, publish and email a report, and enable its schedule on the server that should run it.
Operating runs
Inspect, start, pause, resume and cancel scheduled work, read run status in the Console, drain before deploys and fix common scheduling problems.
