Guides

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 workflowGeneric webhook inbox
Handled byA code workflow with @workflow(on_webhook=...)A markdown task with on_webhook:
Declared inworkflows/settings.yaml, one secret per webhookThe Hub's .env and channels/webhook.yaml
Checked withA shared-secret header, a timestamped HMAC or GitHub's signatureA shared secret or an HMAC signature of the body
DeliveryOne stored event per sender key, retried by Hubzoid, with alerts and an explicit redriveOne file per delivery in an inbox folder, archived after a run that finishes DONE
Per HubSeveral, each with its own name and secretOne 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-hub starter from the quickstart, running with hubzoid run my-hub on the default port 3080.
  • curl in a second terminal.

Named webhook workflows

  1. Signed POST
  2. Checked
  3. Stored as one event
  4. Workflow run
  5. Retried on failure
  6. Succeeded, or failed with an alert
A 200 answer means the event is stored, not that it was handled. Hubzoid owns the retries.

Declare the webhook

my-hub/workflows/settings.yaml
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

my-hub/.env
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:

Terminal
export WEBHOOK_SECRET_TICKET_UPDATED=<the same secret>

Write the workflow

my-hub/workflows/tickets/main.py
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

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

Output
{"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
verifyThe sender sends
header (default)The secret in the header named by header, X-Hook-Secret by default.
hmacIn 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.
githubGitHub'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
ResponseWhen
200 with "status": "accepted" and the event idThe event is stored. This does not mean it was handled.
400The body is not JSON, or its length is invalid.
401The secret or signature is missing or wrong.
404No webhook with that name is declared.
409The same event key was accepted before with different content. An alert is raised.
413The body is larger than 256 KiB.
422The body is JSON but not an object.
503The 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
  1. event_key, a template filled from fields of the body, such as {ticket.id}:{ticket.updated_at}.
  2. Without it, or when a field is missing, the first delivery id header: X-GitHub-Delivery, X-Delivery-Id, X-Webhook-Id, Idempotency-Key or X-Request-Id.
  3. 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] in workflows/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-hub lists 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=N limits how many runs of the workflow go at once, and concurrency_key="ticket.id" runs events with the same value one at a time. max_executor_threads (32 by default) in workflows/settings.yaml bounds 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= or workflow_timeout says 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.key as 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.

  1. Signed POST
  2. Checked and deduplicated
  3. Event file in the inbox
  4. Task queued
  5. Run handles the 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 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: check an HMAC signature instead

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

my-hub/channels/webhook.yaml
version: 1
id: webhook
provider: webhook
enabled: true

Hubzoid 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

my-hub/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 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

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

Terminal
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
ResponseWhen
200 okThe delivery was checked 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. 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-Key or X-Request-Id, that id is remembered 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, 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.

my-hub/.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"}
}

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 .json files 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 in HUBZOID_WEBHOOK_EVENTS, one path per line, and the run's account in HUBZOID_RUN_AS.
  • When the run finishes done, Hubzoid moves the claimed files into .processed/. A run that ends error or incomplete leaves 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 run claims nothing.

A script task reads its events like this:

my-hub/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"]
    ...

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 seeWhat 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 failedIts attempts ran out, or its run was cancelled. Check its side effects, then hubzoid schedule redrive <event-id> --hub my-hub.
forbidden from the inboxCheck WEBHOOK_INBOUND_SECRET and whether WEBHOOK_INBOUND_HMAC matches how the sender signs.
Inbox events are stored but no task runsThe task's on_webhook: name differs from WEBHOOK_INBOUND_NAME, or the task is disabled or paused.
More problems and fixes
What you seeWhat 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

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.