Skip to content

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#

  1. Navigate to /settings/webhooks.
  2. Click + New webhook.
  3. Fill in:
  4. URL — the receiver. Must be https://. The Pydantic schema rejects any non-https:// URL unconditionally — there is no http://-for-dev escape hatch. For local development, terminate TLS in front of your stub receiver with ngrok, cloudflared, a self-signed cert, or any other reverse proxy.
  5. Secret — paste or generate. Recommended: python3 -c "import secrets; print(secrets.token_hex(32))".
  6. Event types — multi-select picker grouped by domain (ticket.*, wiki.*, etc.) plus an All events toggle that sets ["*"].
  7. 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_only category 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 in event_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}. kindhuman | 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_delivery row 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_event row 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:

[Attempt #] [Time] [Event] [HTTP status] [Duration] [Error] [Actions]

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).
  • ActionsRetry (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.

  1. Start a stub receiver locally:
python3 -m http.server 9999
  1. Expose it over HTTPS with ngrok (any equivalent tunneller works):
ngrok http 9999

Note the https://<id>.ngrok-free.app URL printed in the output.

  1. Create a webhook pointing at https://<id>.ngrok-free.app/hook (or any real https receiver URL).

  2. In the UI, edit any ticket's description (a ticket.updated event fires).

  3. Check the stub receiver's log: a POST /hook arrived with X-Webhook-Signature and X-Webhook-Timestamp headers.

  4. In /settings/webhooks/{id}/deliveries, the attempt shows status=succeeded and 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.