Webhooks#
Webhooks are how Ticket Board talks to the outside world. Subscribe a URL to a set of event types, receive an HMAC-signed POST every time a matching event fires, and use the delivery log to verify success. The whole pipeline runs in-process inside the FastAPI app; there is no external queue or broker in v1.
This page covers the operator surface: creating subscriptions, understanding the signing scheme, reading the delivery log, and what to expect when things fail.
What a webhook is#
A workspace-level subscription with three required fields:
| Field | Meaning |
|---|---|
url |
The HTTPS endpoint Ticket Board will POST to. Must be reachable from the Compose host's network. |
secret |
The HMAC-SHA256 signing key. Generate something with at least 32 bytes of entropy. The plaintext is returned once at create time and once on secret rotation; the server keeps a hash. |
event_types |
Allowlist of event type strings. Use ["*"] to subscribe to everything. |
Plus a handful of admin fields: is_active, created_by_user_id, last_success_at, last_failure_at, timestamps.
Only humans can create or manage webhooks. The MCP catalog has no webhook tools; bots cannot read or write subscriptions.
Creating a subscription#
From the UI#
- Navigate to
/settings/webhooks. - Click + New webhook.
- Fill in:
- URL — the receiver. Must be
https://. The Pydantic schema rejects any non-https://URL unconditionally — there is nohttp://-for-dev escape hatch. For local development, terminate TLS in front of your stub receiver withngrok,cloudflared, a self-signed cert, or any other reverse proxy. - Secret — paste or generate. Recommended:
python3 -c "import secrets; print(secrets.token_hex(32))". - Event types — multi-select picker grouped by domain (
ticket.*,wiki.*, etc.) plus anAll eventstoggle that sets["*"]. - Submit. The response includes the secret one more time so you can confirm what was stored; subsequent reads never echo the secret.
From the API#
curl -s -X POST http://localhost/api/v1/webhooks \
-H "Content-Type: application/json" \
-H "X-Requested-By: web" \
-b "tb_session=$YOUR_SESSION" \
-d '{
"url": "https://example.com/hooks/ticket-board",
"secret": "abc123...32bytes...",
"event_types": ["ticket.created", "ticket.state_changed"]
}'
Returns 201 Created with the full webhook including the secret in the response body. Record the secret now — GET /webhooks/{id} and PATCH /webhooks/{id} (without ?rotate_secret=true) will never echo it again.
Event types#
The full event taxonomy is documented in spec/07_observability.md#event-taxonomy. The shape of an event type is <domain>.<verb_past>, e.g. ticket.created. A subscription's event_types is the allowlist; an event fires for this webhook only if its type appears in the list (or the list is ["*"]).
Common subscriptions#
| Use case | Suggested event_types |
|---|---|
| Slack-style activity firehose | ["*"] (filter on the receiver side) |
| Build-trigger on tickets entering "In Progress" | ["ticket.state_changed"] (receiver filters on payload.to_state_name) |
| Notification on bot tool calls | ["bot.tool_invoked"] |
| Wiki page change tracking | ["wiki.created", "wiki.updated", "wiki.deleted"] |
| Audit forwarder | ["*"] with category=audit_only filtering on the receiver |
The payload of each event mirrors the audit_event.payload exactly. Receivers can replay your internal state from the webhook stream alone.
What is not forwarded#
- Personal sticky-note events (
audit_onlycategory for those). Sticky-note events for project notes are forwarded. - Internal sweeper rows (
system.sweeper_ran) — actually, these are emitted into the audit stream and will fire if subscribed. If you don't want them, filter on the receiver or don't include them inevent_types. audit.orphaned— these are defense-net rows; they fire if subscribed but are usually not what you want.
Payload shape#
{
"id": "01J5...",
"type": "ticket.created",
"occurred_at": "2026-06-13T14:22:08.123456Z",
"workspace_id": "01J0...",
"actor": {
"user_id": "01J1...",
"kind": "human",
"username": "admin"
},
"target": {
"kind": "ticket",
"id": "01J5...",
"display_id": "INFRA-42"
},
"payload": {
"display_id": "INFRA-42",
"type": "story",
"title": "Add user-search to backlog filters",
"state_id": "01J2...",
"priority": "medium",
"assignee_user_id": null,
"parent_ticket_id": null
},
"request_id": "01J5..."
}
Top-level fields:
| Field | Source |
|---|---|
id |
The webhook_delivery.id — unique per attempt. Use it as the receiver-side idempotency key. |
type |
The event_type (ticket.created, etc.). |
occurred_at |
audit_event.created_at in ISO-8601 UTC. |
workspace_id |
Always set; informational for multi-source receivers. |
actor |
{user_id, kind, username}. kind ∈ human | bot | system. |
target |
{kind, id, display_id?}. display_id populated for tickets. |
payload |
The event-specific data, identical to the audit row's payload. |
request_id |
Correlates with X-Request-Id returned to the original HTTP/MCP request that triggered the event. |
The id field is per-attempt: a retry of the same event is a different webhook_delivery row with a fresh id but the same request_id. If you receive two payloads with the same request_id and different ids, that's a retry; deduplicate on request_id if your handler is idempotent and you want exactly-once semantics.
Headers#
Every POST carries:
| Header | Value | Purpose |
|---|---|---|
Content-Type |
application/json; charset=utf-8 |
Standard JSON. |
X-Webhook-Id |
The subscription UUIDv7. | Identifies the subscription. |
X-Webhook-Event |
The event type, e.g. ticket.created. |
Receiver-side routing. |
X-Webhook-Delivery |
The webhook_delivery.id. |
Per-attempt idempotency key for the receiver. |
X-Webhook-Timestamp |
Unix seconds. | Replay protection. |
X-Webhook-Signature |
sha256=<hex> HMAC. |
Authenticity. |
User-Agent |
ticket-board/<version> |
Identifies us. |
Signature scheme (HMAC-SHA256 + timestamp)#
The signing scheme is Stripe-style: HMAC-SHA256 over f"{timestamp}.{raw_body}" using the subscription secret as the key.
Computing the signature#
signing_string = f"{X-Webhook-Timestamp}.{raw_request_body}"
expected = "sha256=" + hex(hmac_sha256(secret, signing_string))
The raw body is the JSON bytes exactly as transmitted — do not re-serialize before hashing on the receiver side; that's a common source of mismatch.
Verifying on the receiver#
import hmac
import hashlib
import time
def verify_webhook(headers: dict[str, str], raw_body: bytes, secret: str) -> bool:
timestamp = headers.get("X-Webhook-Timestamp", "")
signature = headers.get("X-Webhook-Signature", "")
# 1. Replay protection — reject if older than 5 minutes.
try:
if abs(time.time() - int(timestamp)) > 300:
return False
except ValueError:
return False
# 2. Compute the expected signature.
signing_string = f"{timestamp}.".encode() + raw_body
expected = "sha256=" + hmac.new(
secret.encode(), signing_string, hashlib.sha256
).hexdigest()
# 3. Constant-time compare.
return hmac.compare_digest(expected, signature)
The same shape in Node:
const crypto = require("crypto");
function verifyWebhook(headers, rawBody, secret) {
const timestamp = headers["x-webhook-timestamp"] || "";
const signature = headers["x-webhook-signature"] || "";
// Replay protection: 5 minutes.
if (Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10)) > 300) {
return false;
}
const expected =
"sha256=" +
crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
);
}
Replay protection window#
The 5-minute window is the recommended receiver-side check. It is not enforced server-side — Ticket Board just sets the timestamp; the receiver rejects if too old. If your clocks drift more than a few minutes, NTP-sync the receiver host or widen the window.
Retry policy#
A delivery is one attempt. A failed attempt may be retried up to three times (4 attempts total).
| Response | Outcome |
|---|---|
| 2xx | Success. webhook_delivery row written with delivered_at. webhook.last_success_at updated. Audit event webhook.delivery_succeeded. |
| 4xx (not 429) | Terminal failure. No retry. Audit event webhook.delivery_failed with will_retry=false. |
| 5xx | Retried. Backoff: 1 s, 5 s, 30 s. |
| 429 | Retried (treated as transient). Same backoff. |
| Network error / timeout | Retried. Same backoff. |
| All attempts failed | Terminal failure. The subscription stays active; deliveries for future events keep firing. |
Each attempt writes a webhook_delivery row. The latest row's next_retry_at is set on transient failures and NULL on terminal outcomes. The delivery log UI shows the full attempt history per event.
What "terminal" means#
A terminal failure means we will not retry that specific delivery automatically. The subscription itself remains active — the next event that matches will produce a fresh delivery attempt. If you want to halt delivery, deactivate the subscription (PATCH /webhooks/{id} with {"is_active": false}) or delete it.
Failure resilience (the v1 trade-off)#
Webhook delivery is in-process in v1. The implications:
- If the FastAPI process crashes between event commit and delivery, the pending in-process job is lost.
- The
webhook_deliveryrow is written per attempt, not before. A delivery that never started leaves no log row — from the receiver's perspective the event simply didn't fire. - The
audit_eventrow exists (it was written in the after-commit hook on a separate transaction). So the internal record of the event is intact; only the outbound notification is lost.
This trade-off is documented in spec/07_observability.md#failure-resilience. The future migration shape (a durable webhook_outbox table) is sketched in the same section; the v1 emitter contract is queue-shaped so a future migration doesn't touch service code.
For most homelab uses this is fine — a Compose stack restart while a webhook is firing is rare, and the loss is recoverable by replaying from the audit log if needed.
The delivery log#
Every attempt produces a webhook_delivery row. The UI at /settings/webhooks/{id}/deliveries shows them newest-first:
Per row:
- Attempt # — 1, 2, 3, or 4.
- Time — when the attempt happened.
- Event — the
event_type. - HTTP status — the target's response code, or empty if a network error occurred.
- Duration — wall-clock time of the POST.
- Error — short error message (network error, timeout, non-2xx response excerpt).
- Actions — Retry (re-fire the same payload) and View payload (expand the JSON body).
Manual retry#
The Retry action calls POST /api/v1/webhooks/{id}/deliveries/{delivery_id}/retry:
- Re-fires the same payload with a fresh signature and timestamp.
- Synchronous — returns the new delivery attempt's row.
- A delivery that has already succeeded cannot be retried: returns
409 resource.conflict(reason=delivery_already_succeeded). Use the original delivery's payload via "View payload" if you need to feed something else.
Manual retry is useful when:
- The receiver was down and you want to force a re-fire after fixing it.
- You're testing a receiver change and want to replay a known payload.
- A 4xx response was actually a transient receiver bug.
The retry produces a new webhook_delivery row; the audit log gets a webhook.delivery_retried event.
Rotating a webhook secret#
You supply the new plaintext secret; the server does not generate one for you. Send it in the body alongside the ?rotate_secret=true query flag:
NEW_SECRET=$(python3 -c "import secrets; print(secrets.token_hex(32))")
curl -s -X PATCH "http://localhost/api/v1/webhooks/$WEBHOOK_ID?rotate_secret=true" \
-H "Content-Type: application/json" \
-H "X-Requested-By: web" \
-b "tb_session=$YOUR_SESSION" \
-d "{\"secret\": \"$NEW_SECRET\"}"
The request body's secret field is required (min 16 bytes) when ?rotate_secret=true is set; sending {} returns 409 resource.conflict (reason=invariant_violation). The response echoes the new secret once so you can confirm what was stored. Update the receiver immediately; the next event will be signed with the new secret. There is no grace period; the old secret is immediately invalid.
The audit event webhook.secret_rotated carries no plaintext — the audit trail records that a rotation happened, not what the secret was.
Editing a subscription#
curl -s -X PATCH http://localhost/api/v1/webhooks/$WEBHOOK_ID \
-H "Content-Type: application/json" \
-H "X-Requested-By: web" \
-b "tb_session=$YOUR_SESSION" \
-d '{
"url": "https://new.example.com/hooks/tb",
"event_types": ["ticket.*"],
"is_active": true
}'
Fields you can change: url, event_types, is_active. Secret rotation requires the ?rotate_secret=true query flag (see above).
Changes are immediate. The next matching event uses the new URL and the new event filter.
Deleting a subscription#
DELETE /api/v1/webhooks/{id} performs a hard delete. The subscription is gone; no more events fire to that URL. Past webhook_delivery rows survive — the delivery log is append-only and never cascades.
If you want to halt without deleting (e.g. while debugging a receiver), set is_active = false instead.
End-to-end smoke test#
A quick verification path. The Pydantic schema rejects non-https:// URLs, so the stub receiver needs to be fronted with TLS — ngrok or cloudflared are the lowest-friction options for a homelab.
- Start a stub receiver locally:
- Expose it over HTTPS with
ngrok(any equivalent tunneller works):
Note the https://<id>.ngrok-free.app URL printed in the output.
-
Create a webhook pointing at
https://<id>.ngrok-free.app/hook(or any real https receiver URL). -
In the UI, edit any ticket's description (a
ticket.updatedevent fires). -
Check the stub receiver's log: a
POST /hookarrived withX-Webhook-SignatureandX-Webhook-Timestampheaders. -
In
/settings/webhooks/{id}/deliveries, the attempt showsstatus=succeededand HTTP 200.
This is the same flow as step 10 of the release smoke checklist.
Audit semantics#
Every webhook event lands in the audit log:
| Event | When |
|---|---|
webhook.created |
Subscription created. |
webhook.updated |
URL, event_types, or is_active changed. |
webhook.secret_rotated |
Secret rotation. |
webhook.deleted |
Subscription deleted. |
webhook.delivery_succeeded |
A delivery returned 2xx. |
webhook.delivery_failed |
A delivery returned non-2xx-non-429, or all attempts exhausted. Payload includes will_retry and the HTTP status. |
webhook.delivery_retried |
A manual retry was triggered. |
All are category=audit_only. None of them appear in the activity feed; this is admin/operational visibility.
Common questions#
Why don't webhooks have a "test" button? You can fire a real event and check the delivery log. A synthetic test would require a separate payload format and would not exercise the same code path; we'd rather you trust the real path.
My receiver is slow. Will Ticket Board time out? The dominant ceiling is a 30-second read timeout (5 s connect, 10 s write). A receiver that responds within 30 seconds succeeds; a slower one is treated as a transient failure and retried. If you need more than 30 seconds, design the receiver to ACK fast and process async.
Can I subscribe to events from one specific project only? Not at the subscription level — event_types is the only filter. Filter on the receiver side using the target and payload.project_id fields.
Why is the secret returned in the response on create? Because we need to give it to you once. Subsequent reads of the subscription do not echo it. If you lose it, rotate.
What if the receiver returns 429? Treated as transient. Retried with backoff (1 s, 5 s, 30 s). If you want stricter back-pressure handling, look at a future durable-queue migration (spec).
Can a bot register a webhook? No. Webhook management is humans-only. There is no create_webhook MCP tool.
API surface recap#
| Endpoint | Verb | Purpose |
|---|---|---|
/api/v1/webhooks |
GET | List subscriptions (secret never returned). |
/api/v1/webhooks |
POST | Create. Returns subscription with secret echoed once. |
/api/v1/webhooks/{id} |
GET | Detail (no secret). |
/api/v1/webhooks/{id} |
PATCH | Update url / event_types / is_active. ?rotate_secret=true rotates and returns new secret once. |
/api/v1/webhooks/{id} |
DELETE | Hard-delete. Past deliveries remain. |
/api/v1/webhooks/{id}/deliveries |
GET | Delivery log per subscription with filters (status, since, attempt_min). |
/api/v1/webhooks/{id}/deliveries/{delivery_id}/retry |
POST | Manual retry. Returns the new attempt row. |
Full details: spec/02_api_design.md#webhooks and spec/07_observability.md#webhook-delivery.
Next#
- Cross-reference webhook events with the Audit log — both come from the same emission point.
- If a delivery looks wrong, check Troubleshooting.