Skip to content

Webhook Ingress & Tenancy

Webhook Providers

WebHooker ingests webhooks from multiple forges through the same POST /webhook endpoint; the provider is auto-detected from the request headers, so point every forge's webhook at {BASE_URL}/webhook.

ProviderEvent headerSignature headerSignature formatSecret
GitHubX-GitHub-EventX-Hub-Signature-256sha256=<hex> HMAC-SHA256GITHUB_WEBHOOK_SECRET
GiteaX-Gitea-EventX-Gitea-Signatureplain hex HMAC-SHA256GITEA_WEBHOOK_SECRET

Delivery-id dedup uses X-GitHub-Delivery (GitHub) or X-Gitea-Delivery (Gitea) when present.

Gitea payloads are normalized to the same internal shape as GitHub events, so routes, filters, and the 28 formatters work unchanged. Unknown or unmapped Gitea events fall back to the generic formatter. Repository/commit/user links are derived from the payload's repository.html_url, so they point at your Gitea instance.

Global Endpoint (POST /webhook)

The global endpoint verifies payloads against the operator's global secrets (GITHUB_WEBHOOK_SECRET, GITEA_WEBHOOK_SECRET) and dispatches into all routes. GitHub App installations deliver here; use installationId on groups to keep tenants isolated.

Per-Group Endpoint (POST /webhook/{groupId})

Every group can opt into its own webhook ingress with an independent secret (generated from the group page — Webhook endpoint panel, owner role). Payloads are verified against the group's secret instead of the global ones, and only that group's routes are eligible. This is how SaaS users configure Gitea, classic GitHub, or custom webhooks without sharing (or knowing) the operator's secrets.

  • Supported for any provider: GitHub (X-Hub-Signature-256), Gitea (X-Gitea-Signature), custom (X-WebHooker-Signature)
  • The secret is a 64-char hex string; regenerate from the console invalidates the old one immediately
  • Delivery-id dedup keys are provider- and tenant-scoped (delivery:{provider}:{groupId}:{id})
  • When the group has no secret (or no longer exists) the endpoint returns 404

Custom Webhooks

Post arbitrary JSON to POST /webhook/{groupId} (or the global endpoint) with the body signed as X-WebHooker-Signature: sha256=<hmac-sha256 hex of the raw body> using the group's secret. The payload becomes a custom event that flows through the normal route pipeline — create a route with event: custom (there is a console template) and it dispatches to that route's targets, records send_logs, and appears in the group's webhook log channel.

Replay Protection

Custom webhooks support optional replay protection via two extra headers alongside the signature:

  • X-WebHooker-Timestamp — Unix seconds the request was sent
  • X-WebHooker-Nonce — a unique, unpredictable value per request (e.g. a UUID)

When both headers are present, the signature is computed over {timestamp}.{nonce}.{rawBody} instead of the raw body, and the request is accepted only if:

  1. The timestamp is within ±5 minutes of the server clock (rejects replays and clock-drift abusers)
  2. The nonce has never been seen before (stored in KV for 10 minutes; a replayed nonce is rejected)
bash
input="${timestamp}.${nonce}.${body}"
signature="sha256=$(printf '%s' "$input" | openssl dgst -sha256 -hmac "$secret" -hex | sed 's/.*= //')"

When the headers are omitted, WebHooker falls back to the legacy body-only signature, so existing senders keep working unchanged.

Payload schema:

json
{
  "title": "Deploy failed",
  "description": "Prod rollout failed at 12:03 UTC",
  "color": "red",
  "url": "https://ci.example.com/runs/42",
  "repo": "acme/widget",
  "author": {
    "name": "alice",
    "iconUrl": "https://…/alice.png",
    "url": "https://github.com/alice"
  },
  "fields": [{ "name": "Env", "value": "prod", "inline": true }],
  "footer": "my-monitor",
  "deliveryId": "alert-123"
}
FieldTypeDescription
titlestringMessage title (falls back to "Custom message")
descriptionstringOptional message body
colorstringOptional embed color: a word (red, green, yellow, blue, purple, orange, cyan, gray) or #rrggbb
urlstringOptional link for the title
repostringOptional owner/repo; prefixes the title and is used as the footer
authorobjectOptional { name, iconUrl, url }
fieldsobject[]Optional embed fields { name, value, inline }
footerstringOptional footer override
deliveryIdstringOptional id for sender-side dedup (retries)

GitHub App Tenant Isolation

When the GitHub App is installed, its events arrive at the global endpoint for every installation. To keep tenants apart, bind each group to the installation id that should feed it: "installationId": 12345678. The id is visible in the App's installation webhook payload (installation.id) or on the GitHub App installation page URL. Events from any other installation are rejected for that group even if its owners list is empty. Groups without installationId keep the legacy behavior (owners filtering).

Binding is auto-configured — the GitHub App's Setup URL should point to {BASE_URL}/auth/github/install. Right after a user installs the App, the browser lands there (the page requires a signed-in admin session — not signed in users are redirected through the OAuth flow first) and they choose where the installation binds: a new group (inst-{installationId}, default) or any existing group they own (owner role checked again on submit; POST /auth/github/install/bind performs the provisioning). No manual id entry is needed. As a fallback (e.g. when the Setup URL is not configured), the installation.created webhook event creates/binds the group automatically — existing groups whose owners match the installing account are bound, otherwise a dedicated inst-{installationId} group is created. Then just add routes/members in the console.

To show the installing account's login on the choice page, set GITHUB_APP_ID and GITHUB_PRIVATE_KEY — see Secrets.

Released under the MIT License.