Webhooks
Let another system start work in your Hub with a signed HTTP POST, handled by a Python code workflow or by a Markdown task.
A webhook lets any system that can send an HTTP POST start work in your Hub: an alerting tool, a CI run, a ticket system, a form backend. Hubzoid checks that the delivery is genuine and stores it before any work starts. The receiver never calls the model and never answers the sender with content, so a public endpoint only accepts, checks and stores.
In this guide you send a test event to the my-hub starter and watch a workflow handle it. Then you set up the simpler inbox that a markdown task reads.
Choose a kind
There are two ways to receive webhooks, and a Hub can use both.
| Named webhook workflow | Generic webhook inbox | |
|---|---|---|
| Handled by | A code workflow with @workflow(on_webhook=...) | A markdown task with on_webhook: |
| Declared in | workflows/settings.yaml, one secret per webhook | The Hub's .env and channels/webhook.yaml |
| Checked with | A shared-secret header, a timestamped HMAC or GitHub's signature | A shared secret or an HMAC signature of the body |
| Delivery | One stored event per sender key, retried by Hubzoid, with alerts and an explicit redrive | One file per delivery in an inbox folder, archived after a run that finishes DONE |
| Per Hub | Several, each with its own name and secret | One endpoint name |
Use a named webhook workflow when the handling must be exact, or when repeated deliveries of one event must start only one run. Use the inbox when instructions for the agent are enough.
The run that handles an event acts as its configured account, like any other team run. See who a run acts as.
What you need
- The
my-hubstarter from the quickstart, running withhubzoid run my-hubon the default port 3080. curlin a second terminal.
Named webhook workflows
- Signed POST
- Checked
- Stored as one event
- Workflow run
- Retried on failure
- Succeeded, or failed with an alert
Declare the webhook
webhooks:
ticket-updated:
verify: header
header: X-Webhook-Secret
event_key: "{ticket.id}:{ticket.updated_at}"Which webhook names are allowed
A name uses lowercase letters, digits, - and _, and starts with a letter or digit. whatsapp, telegram and the Hub's generic webhook name (webhook by default) are refused, because the inbox server already serves them.
Set its secret
WEBHOOK_SECRET_TICKET_UPDATED=<a long random secret>The variable is WEBHOOK_SECRET_ followed by the name in upper case, with - written as _. Without it, every delivery is refused. To send the test below, export the same value in your second terminal:
export WEBHOOK_SECRET_TICKET_UPDATED=<the same secret>Write the workflow
from hubzoid import hub, step, workflow
@step(max_attempts=3)
def label_ticket(ticket_id: str, label: str) -> None:
... # update your ticket system, so that a repeat changes nothing
@workflow(on_webhook="ticket-updated", concurrency=2, concurrency_key="ticket.id")
def ticket_updated():
ticket = hub.event.body["ticket"]
label = hub.call_llm(f"One word for the topic of this ticket: {ticket['subject']}")
label_ticket(ticket["id"], label)
return {"ticket": ticket["id"], "label": label}One workflow handles each webhook. Restart hubzoid run my-hub so the Hub reads the declaration and the workflow.
When the workflow engine starts
The Hub's engine starts for a declared webhook even when scheduled code workflows are off.
Send a test event
curl -X POST http://127.0.0.1:3080/webhooks/my-hub/ticket-updated \
-H "X-Webhook-Secret: $WEBHOOK_SECRET_TICKET_UPDATED" \
-H "Content-Type: application/json" \
-d '{"ticket": {"id": "T-1042", "updated_at": "2026-10-14T09:30:00Z", "subject": "Lamp arrived damaged"}}'Hubzoid answers as soon as the event is stored, with its id:
{"status":"accepted","event_id":"wh:..."}How a webhook address is formed
The address is https://<host>/webhooks/<hub>/<name> on the public port. <hub> is the Hub's folder name in lower case, with each run of other characters turned into one -, or HUBZOID_HUB_SLUG when set.
Watch it run
Open Settings → Agents → My Hub → Workflows. Below the workflows, Webhooks lists ticket-updated with its address, the workflow it starts, its check and the last 24 hours of events by state. hubzoid schedule status my-hub shows the run from the terminal.
Verification
verify sets how Hubzoid checks that a delivery is genuine: a shared-secret header by default, a timestamped HMAC or GitHub's signature.
What the sender sends for each check, and what happens on a settings mistake
verify | The sender sends |
|---|---|
header (default) | The secret in the header named by header, X-Hook-Secret by default. |
hmac | In signature_header, sha256= and the hex HMAC-SHA256 of <timestamp>.<raw body> keyed with the secret. In timestamp_header, the Unix time in seconds, within five minutes of the server's. timestamp_header is required. |
github | GitHub's X-Hub-Signature-256. In the repository's webhook settings, use the endpoint as the payload URL, choose the application/json content type and enter the same secret. |
Comparisons take constant time, and query parameters are never credentials. Other signing schemes are not checked, and a body that is a JSON array is refused, so set such a sender to post a JSON object with the secret header.
A mistake in workflows/settings.yaml, such as invalid YAML or an unknown verify, turns workflows off for that Hub, with the error in workflow health. Chat and other Hubs keep running, and the Hub's webhook addresses answer 503 until the file is fixed and the Hub restarts.
Responses
A 200 answer means the event is stored, not that it was handled. Any 503 asks the sender to retry.
Every response code and when it is sent
| Response | When |
|---|---|
200 with "status": "accepted" and the event id | The event is stored. This does not mean it was handled. |
400 | The body is not JSON, or its length is invalid. |
401 | The secret or signature is missing or wrong. |
404 | No webhook with that name is declared. |
409 | The same event key was accepted before with different content. An alert is raised. |
413 | The body is larger than 256 KiB. |
422 | The body is JSON but not an object. |
503 | The workflow engine is not ready, no workflow handles the webhook, the settings are invalid or the event could not be stored. The sender should retry. |
Events and repeats
Each event is one stored record, keyed by the Hub, the webhook and the sender's event key. A repeat with the same key and body starts nothing.
How the event key is chosen, and what the run reads as hub.event
event_key, a template filled from fields of the body, such as{ticket.id}:{ticket.updated_at}.- Without it, or when a field is missing, the first delivery id header:
X-GitHub-Delivery,X-Delivery-Id,X-Webhook-Id,Idempotency-KeyorX-Request-Id. - Otherwise a hash of the body, with a warning in the log.
A repeat with the same key and body attaches to the stored event, even after it finished, and starts nothing. Put what changes with each occurrence in the body and in event_key, such as the time the change happened.
The run reads its event as hub.event, with id, key, body, safe headers (the content type, delivery ids and X-GitHub-Event), webhook, received_at and attempt. It is None for a manual or scheduled run of the same workflow, so handle that case if the workflow has other triggers.
Retries, redrive and concurrency
Hubzoid retries a failed event itself, then marks it failed and raises an alert. You retry a failed event with hubzoid schedule redrive.
Retry timing, redrive, code changes, concurrency and deadlines
- Retries. Hubzoid retries a failed event itself: three attempts by default, after 60 and 300 seconds (
webhook_retry_delays: [60, 300]inworkflows/settings.yaml, at most 9 delays). Then the event is marked failed and an alert is raised. - Redrive. After checking what the event's earlier attempts already did, retry it with
hubzoid schedule redrive <event-id> --hub my-hub.hubzoid schedule deliveries my-hublists events and alert deliveries without their payloads. Cancelling a webhook run fails its event, which then waits for a redrive. - Code changes. An event that never started runs on the current code, found by its webhook name. An event whose attempt had started under the old code is failed visibly for a redrive, never replayed silently on changed code.
- Concurrency.
concurrency=Nlimits how many runs of the workflow go at once, andconcurrency_key="ticket.id"runs events with the same value one at a time.max_executor_threads(32 by default) inworkflows/settings.yamlbounds the Hub's worker threads. A backlog on one webhook workflow does not hold up another. - Deadline. A webhook run has a 15-minute deadline unless
timeout=orworkflow_timeoutsays otherwise. - Pausing. Failed webhook runs never pause the workflow and do not count toward pausing its schedule.
- At least once. A step that changes something outside the Hub can run again after a crash. Use
hub.event.keyas the idempotency key in the other system, or check its current state before writing.
In Settings, a webhook with failures expands to show each failed event with Copy redrive command. Settings never shows payloads, headers or digests, and has no redrive button.
Event and digest records do not expire, so a sender's late retries stay deduplicated. Include the operational store in backups.
Markdown tasks and the generic webhook
The generic webhook stores each checked delivery as a file in the Hub's inbox, and a markdown task with on_webhook: handles it.
- Signed POST
- Checked and deduplicated
- Event file in the inbox
- Task queued
- Run handles the events
- Archived on DONE
Set 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: check an HMAC signature insteadWithout WEBHOOK_INBOUND_SECRET, the endpoint is not served.
Turn the inbox on
Add this file to the Hub's channels/ folder, then restart hubzoid run my-hub:
version: 1
id: webhook
provider: webhook
enabled: trueHubzoid starts an inbox server on a loopback port (HUBZOID_INBOUND_PORT, default 8100) as part of the Hub, and the public port forwards /webhooks/<hub> to it. With the settings above, the address is http://127.0.0.1:3080/webhooks/my-hub/alerting. It 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 short for the default name webhook. A task has on_webhook: or schedule:, never both. Every other key from markdown tasks applies.
Send a test delivery
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"}'The answer is ok. Send the same command again and the answer is duplicate. The event file is in my-hub/.inbound/webhooks/alerting/. Within about 30 seconds the task is queued, and it appears in Settings as md:alert-intake. After a done run, the event file moves to .processed/.
Inbox authentication
Every request is checked before anything else runs. A missing or wrong secret or signature gets 403 forbidden and nothing is stored.
Shared secret and HMAC modes, GitHub, and how to test a signature
| 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 body keyed with the secret |
Comparisons take 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.
For GitHub, set WEBHOOK_INBOUND_HMAC=true. 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.
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"Inbox responses
The inbox answers 200 ok for a stored delivery and 200 duplicate for a repeat.
Every inbox response
| Response | When |
|---|---|
200 ok | The delivery was checked 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. The sender should retry. |
A body that is not JSON is kept as text, never rejected.
Deduplication
A repeat of a delivery already stored is answered 200 duplicate and not stored again.
How the inbox recognises a repeat
- 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 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, so a crash while storing leaves no mark and the provider's retry is accepted.
Seen markers live in .inbound/dedup/ inside the Hub.
What a stored event looks like
Each delivery becomes one JSON file in .inbound/webhooks/<name>/.
File naming, and why headers are not stored
Files are named so that name order is arrival order, and written in one go so a task never reads half an event. Request headers are used for checking 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 address, which is stored under query.
{
"surface": "webhook",
"name": "alerting",
"received_at": 1758790000.123,
"query": {},
"content_type": "application/json",
"body": {"alert": "disk_full", "host": "store-2", "severity": "critical"}
}Which events a run handles
A run handles every event file waiting when it is queued. Files move to .processed/ only after a run that finishes done.
When a webhook task is due, how it finds its files, and what happens on failure
- A webhook task is due while
.jsonfiles wait in 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 waiting file. An agent task finds them listed in every round's prompt. A
run:script finds them inHUBZOID_WEBHOOK_EVENTS, one path per line, and the run's account inHUBZOID_RUN_AS. - When the run finishes
done, Hubzoid moves the claimed files into.processed/. A run that endserrororincompleteleaves them, so the task runs again. Handling is at least once, so 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.
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"]
...With several Hubs
When hubzoid run starts several Hubs, the public port forwards /webhooks/<hub> for every Hub with a webhook, WhatsApp or Telegram channel.
Inbox ports and Hub names with several Hubs
Each Hub's inbox server runs and restarts with its Hub, on a loopback port chosen automatically. A HUBZOID_INBOUND_PORT in a Hub's .env is still honoured. Set HUBZOID_HUB_SLUG only when two Hubs share a folder name. See running several Hubs.
If something goes wrong
| What you see | What to do |
|---|---|
{"detail":"Invalid webhook authentication"} (401) | The secret or signature does not match. Check WEBHOOK_SECRET_<NAME> in the Hub's .env, the value you sent and the webhook's verify settings. |
{"detail":"Unknown webhook"} (404) | The name in the address is not under webhooks: in workflows/settings.yaml, or the Hub has not restarted since you added it. |
{"detail":"Workflow engine is not ready"} (503) | No workflow declares on_webhook for this name, or the engine is still starting. Check the workflow and restart the Hub. |
| A webhook event shows as failed | Its attempts ran out, or its run was cancelled. Check its side effects, then hubzoid schedule redrive <event-id> --hub my-hub. |
forbidden from the inbox | Check WEBHOOK_INBOUND_SECRET and whether WEBHOOK_INBOUND_HMAC matches how the sender signs. |
| Inbox events are stored but no task runs | The task's on_webhook: name differs from WEBHOOK_INBOUND_NAME, or the task is disabled or paused. |
More problems and fixes
| What you see | What to do |
|---|---|
{"detail":"Workflow settings are invalid"} (503) | workflows/settings.yaml has a mistake. Fix it and restart the Hub. |
{"detail":"Expected a JSON object"} (422) | The sender posted an array or a plain value. Post one JSON object. |
Next steps
Code workflows
Steps, inputs, reports and alerts for the workflow a webhook starts.
Markdown tasks
Every frontmatter key, the round rules and scoped commits.
Operating runs
Redrive events, cancel runs, read alerts and troubleshoot deliveries.
Security model
What the public port exposes and how each surface is checked.

