Skip to content

Inbound webhooks

Audience. Org admins wiring a forge or a custom caller to iterion, and operators reviewing the auth + rate-limit + audit story before opening /api/webhooks/* to public traffic.

This document covers inbound webhooks — external events arriving on /api/webhooks/<provider>/<id> to launch a bot. The mirror feature ("call me back when this run finishes") is documented in outbound-callbacks.md.

Prefer the Integrations tab for forge repos. The manual lifecycle below (mint a token, paste the URL + token into the forge) is the low-level path. For a GitLab/GitHub/Forgejo repo, the studio's Integrations tab connects the forge once (OAuth or PAT) and provisions the hook + token + this webhook config for you when you enable a bot — see forge-integrations.md. Such configs carry a provisioned_by marker, render read-only here, and reject direct delete/rotate (409) — manage them from Integrations. The manual path remains for the Generic JSON trigger and for webhooks you want to own by hand.

Four providers are wired: GitLab (incl. /revi re-review command), GitHub, Forgejo/Gitea, and a bot-agnostic Generic JSON endpoint (pkg/server/webhooks_routes.go:supportedProviders).

Lifecycle

  1. An org admin creates a webhook through the studio (Webhooks tab on /teams/<id>) or the API. Iterion mints an iwh_… token (32 bytes of randomness behind a recognisable prefix) and returns it exactly once alongside the new Config document (pkg/webhooks/token.go:MintToken).
  2. The admin pastes the inbound URL + token into the forge:
    • GitLab → Settings → Webhooks → URL https://…/api/webhooks/gitlab/<id> + Secret Token iwh_…
    • GitHub → Settings → Webhooks → Payload URL https://…/api/webhooks/github/<id> + Secret iwh_…
    • Forgejo/Gitea → Settings → Webhooks → Target URL + Secret iwh_…
    • Generic → any HTTP client, header X-Iterion-Webhook-Token: iwh_…
  3. From then on, each delivery is admitted through the middleware, parsed by the provider, dispatched to a bot, and recorded as a Delivery row. The token plaintext is not kept at rest — only a salted hash, the last 4 chars, and a SHA-256 fingerprint (pkg/webhooks/types.go:Config).

Rotate or revoke at any time: POST /api/teams/{id}/webhooks/{webhook_id}/rotate returns a fresh plaintext (also shown once) and updates the forge's "secret" field is then a manual step.

Auth modes — token vs HMAC

Iterion's middleware has two authentication modes, picked per provider to match how the forge actually signs the request (pkg/webhooks/types.go:SignatureMode, pkg/server/webhooks_routes.go:defaultSignMode).

ProviderDefault sign_modeWhat proves authenticityHeader iterion reads
GitLabtokenThe forge echoes the iwh_ plaintext verbatimX-Gitlab-Token (or X-Iterion-Webhook-Token)
GitHubhmac (forced)HMAC-SHA256 of the raw body, key = iwh_ plaintextX-Hub-Signature-256
Forgejo/Giteahmac (forced)HMAC-SHA256 of the raw bodyX-Forgejo-Signature (falls back to X-Gitea-Signature)
Generictoken (default; hmac opt-in)Header bearer token, or HMAC of bodyX-Iterion-Webhook-Token / X-Iterion-Webhook-Signature

The same iwh_… plaintext that's shown at create time is used in both modes — operators paste it once into the forge's "secret" field. For HMAC providers, iterion seals that plaintext at rest under an AAD bound to the webhook ID (pkg/webhooks/token.go:SealHMACSecret) so the same value can be reused on every delivery to recompute the signature without storing cleartext. Rotating the token reseals it.

Why per-provider, not per-org? GitHub and Forgejo's hooks only sign the body — they don't echo any token header at all, so an operator who picks token-mode for them would lock themselves out. GitLab's "Secret Token" field is exactly the bearer model. The middleware skips the header check entirely under sign_mode: hmac so the body bytes stay intact for the provider handler's signature recomputation (pkg/server/middleware_webhook.go:webhookAuth).

Per-provider behaviour

GitLab (POST /api/webhooks/gitlab/{id})

Single URL, two event kinds dispatched on X-Gitlab-Event (pkg/server/webhooks_gitlab.go):

  • Merge Request Hook — auto-review on open/reopen, and on the draft→ready transition. A draft MR never auto-launches (the author is still iterating — auto-running a bot wastes budget and churns an unfinished branch). GitLab has no dedicated ready action, so the trigger is the update whose changes.draft (or the deprecated work_in_progress) went true→false. Ordinary pushes (action update with an unchanged draft flag) deliberately do not re-trigger — auto-review on every push was found too noisy; cf. pkg/webhooks/gitlab/parser.go:IsReviewable.
  • Note Hook — on-demand re-review. Only acts when the note hangs off an open MR and its first non-whitespace token is exactly /revi. Quoting "please run /revi" mid-text does not trigger (anti-oscillation guard; pkg/webhooks/gitlab/note.go:IsReviewCommand).
  • Issue Hook — adding a trigger label (e.g. implement) launches the webhook's bot, same as GitHub issues (below). GitLab has no labeled action, so the parser diffs changes.labels (previous→current) and fires only on a freshly-added label that passes label_allowlist, on an OPEN issue (pkg/webhooks/gitlab/issue.go).

Default event allowlist: {merge_request, note} — both kinds reach a zero-config webhook (pkg/webhooks/match.go:MatchEvent). Operators who want only the auto-review path list ["merge_request"] explicitly; that disables /revi while keeping open/reopen.

Vars stamped on the run: pr_url, base_ref, scope_notes, post_to_board=false, pr_review_mode=inline, plus re_review=true for the note path. The webhook's LaunchVars override these.

GitHub (POST /api/webhooks/github/{id})

HMAC over the body, header X-Hub-Signature-256 (sha256=<hex>). Three event paths trigger; ping / push / everything else is silently filtered (returns 200 — a 4xx makes GitHub disable the webhook after repeated failures; pkg/server/webhooks_github.go):

  • pull_request with action opened, reopened, or ready_for_review → PR auto-review (Revi / review-pr). This lane is review-only: a PR-open NEVER auto-launches the mutating branch-improve loop (Billy) — see PR auto-lane: review, not mutate below. A draft PR never auto-launches (the draft flag is honoured on every action — the trigger is ready_for_review, which clears it). A fork PR (head branch in a different repo) is likewise never auto-launched: it is untrusted, so a repo collaborator must trigger a bot manually via the /command path — the anti budget-exhaustion boundary (pkg/webhooks/prforge/parser.go:IsReviewable + IsCrossRepo). A PR opened by iterion's own forge bot (another iterion bot's PR — see below) is also skipped.
  • issue_comment → the universal /command slash path (e.g. /featurly <prompt>, /billy), routed through the command registry.
  • issues with action labeled → launches the webhook's bot with the labeled issue turned into a feature task. The handler derives feature_prompt (issue title + body), open_mr=true, and source_issue_ref (the issue URL), so an implementer bot (featurly) implements the issue, opens a PR, and comments the PR URL back onto the issue. Scope which label fires with label_allowlist (below); re-applying the same label is an idempotent replay.
  • issues with action opened + auto_implement_on_open → the zero-touch lane, now author-gated: the issue AUTHOR must be trusted — on the static author_allowlist, OR author_association ∈ OWNER/MEMBER/COLLABORATOR (decoded from the payload, no API call), OR live CollaboratorPermissionmin_author_role (gitlab vocabulary, "" → developer ≡ write; needs a forge_token binding). Unknown = untrusted (fail-closed — this is the budget boundary against drive-by issues, unlike the fail-open org quotas). An untrusted author's delivery filters (200, visible reason) and the issue's board card parks with needs:approval for the operator's "Approve & triage". The labeled lane is NOT author-gated: applying the trigger label already requires triage+ rights on the forge — labeling IS the approval gesture.

The label path (GitHub issues and GitLab Issue Hook) routes through the same dispatcher sink as the /command path, so when a tenant cloud board is wired it also materialises a one-way tracking card for the issue — a read-only mirror linked back to the source issue via source_issue_ref (idempotent per issue). GitHub/GitLab stay the source of truth; iterion only writes back to them (PR + back-link comment). With a board coordinator running, the card is the unit of work the dispatcher executes; without one, the card is a tracking record and the run launches directly (pkg/server/invocation_dispatch.go:dispatchInvocation).

Forgejo / Gitea (POST /api/webhooks/forgejo/{id})

Same wire shape as GitHub-style PRs, two header spellings accepted: X-Forgejo-Signature (current) or X-Gitea-Signature (older Gitea deployments); same for X-Forgejo-Event / X-Gitea-Event. The signature header is treated as a hex digest with or without the sha256= prefix (pkg/server/webhooks_forgejo.go:forgejoSignatureHeader).

The same draft/fork guards as GitHub apply (shared prforge parser): a draft PR (pull_request.draft == true) never auto-launches, and a fork PR never auto-launches. Caveat: Forgejo/Gitea has no ready_for_review webhook action (marking a WIP PR ready arrives as edited, which does not auto-trigger), so on Forgejo the draft→ready re-trigger is on-demand — a collaborator reopens the PR or uses the /command path. The no-draft and no-fork guarantees hold regardless.

PR auto-lane: review, not mutate (Revi vs Billy)

The PR/MR open lane (GitHub pull_request, GitLab merge_request, Forgejo PR) is review-only. Opening a PR auto-launches the read-only reviewer Revi (review-pr) and nothing else — it never runs the mutating branch-improve loop Billy (branch-improve-loop). Two carve-outs sit around that rule (pkg/server/webhooks_github.go:handlePRForgeReview, pkg/server/webhooks_common.go:isIterionForgeBotAuthor):

  • Iterion-bot PRs are skipped. A PR opened by iterion's OWN forge bot (another iterion bot — Doki, Willy, Featurly… — pushing through the tenant's forge integration) is not auto-reviewed: it already converged inside its own loop, so re-reviewing it just wastes budget and adds noise. The author is matched against the tenant's provisioned forge connection — a GitHub/Forgejo App's <app_slug>[bot] login, or (GitLab) the connected bot account — not a generic [bot] suffix, so Dependabot / Renovate PRs stay reviewable. A human can still force a review with a manual /revi. Filtered as a clean 200 (visible reason).
  • Merge-queue auto-heal is preserved. A PR ejected from the GitHub merge queue for a healable reason (dequeued, NeedsAutoHeal) still dispatches Billy to rebase, resolve the conflict / fix the combined break, and re-enter the queue — a narrow, distinct trigger (same-repo + project/author allowlist + bot-permitted, one attempt per head SHA), unrelated to the review lane.

Billy on demand — /billy (alias /improve). To run Billy on a PR, a repo collaborator issues a /billy slash-command in a PR/MR comment. The command reuses the SAME authorization gate as every other /command / /revi (loop-guard + AuthorizedRepliers allowlist OR a repo permission ≥ the route's min_replier_role), so a non-collaborator cannot invoke it. Billy then commits its hardening onto the PR's own branch (or opens a separate PR with branch_improve_as_pr). When Revi has already reviewed that PR, the handler seeds Billy's run with Revi's most recent findings under the prior_review var — so Billy starts from that review instead of re-deriving it (best-effort: with no prior review, Billy reviews the diff from scratch; pkg/server/webhooks_handoff.go).

Generic (POST /api/webhooks/generic/{id})

Bot-agnostic: the caller picks which bot to launch by name (or relies on the webhook's default_bot_id / single-bot scope). Request shape (pkg/webhooks/generic/generic.go:Request):

json
{
  "bot": "review-pr",
  "vars": { "pr_url": "https://gitlab.local/group/repo/-/merge_requests/7" },
  "idempotency_key": "ci-build-42",
  "repo_url": "https://gitlab.local/group/repo.git",
  "repo_ref": "feature/x",
  "project_path": "group/repo"
}

Field bounds: vars is capped at 256 keys, each key must match [A-Za-z_][A-Za-z0-9_]{0,63}, each value at 4 KiB. Anything else is a 400 (generic: too many vars / bad var key / var value too large).

Curl example:

bash
curl -X POST https://iterion.example.com/api/webhooks/generic/<id> \
  -H "X-Iterion-Webhook-Token: $IWH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "bot": "review-pr",
    "vars": { "pr_url": "https://gitlab.local/group/repo/-/merge_requests/7" },
    "idempotency_key": "ci-build-42"
  }'

Var precedence. Body vars merge in first; then the webhook's configured LaunchVars override them. The operator is the security-critical knob — a malicious caller cannot escalate by renaming a key the org-admin has pinned (handleGenericWebhook in pkg/server/webhooks_generic.go).

Matching: project + event + author allowlists, bot scope

Every webhook carries four selection filters plus a bot-agnostic hold gate (pkg/webhooks/types.go:Config):

  • event_allowlist — provider-event names allowed; empty defaults to the provider's natural set (GitLab uses {merge_request, note}, the others use {pull_request}, and the GitHub/Forgejo issues path defaults to {issues}). A bare * matches everything.
  • label_allowlist — for the issues (labeled) path only: which freshly-applied label fires (e.g. ["implement"]). Empty = any label; case-insensitive; a bare * matches everything. No effect on the pull_request / issue_comment paths. On a provisioned webhook, set it through the integration (label_allowlist on POST/PATCH /api/teams/{id}/forge/repo-bots) rather than the webhook config: provisioning rebuilds that config from the manifests, so a narrowing living only there is dropped by the next bot-set change — and that regression is fail-open. An allowlist already PATCHed onto the config is adopted onto the integration by the next provision.
  • hold_labels — a bot-agnostic suppression set. When the triggering PR or issue carries any of these labels, the auto-launch lanes (PR-open review, merge-queue auto-heal, auto-implement-on-open) suppress the launch — whatever bot would have run — and record a filtered delivery. It is the operator's escape hatch to pause automation on one PR/issue without disabling the webhook; a human can still trigger a bot manually via a /command. Applies to all four auto-launch lanes (GitHub/Forgejo PR review + auto-heal, GitHub issue, GitLab MR review, GitLab issue). Case-insensitive; empty = off; a * entry holds everything. Fail-open: a minimal payload that doesn't carry the label set simply isn't suppressed. Unlike label_allowlist (which selects a bot), hold_labels vetoes them.
  • project_allowlistowner/repo patterns. Empty = every project the forge fires for. Supports * (any), owner/*, or exact paths.
  • author_allowlist — PR/MR author logins allowed to trigger a launch. Empty = any author. Case-insensitive; entries may be bot logins like dependabot[bot] / renovate[bot], so a webhook can react ONLY to a dependency bot's PRs and ignore human PRs on the same repo. A bare * opts back into allow-all. The matched author login is also stamped onto every review run as the pr_author var. (Applies to the PR/MR open path; comment//revi triggers use min_replier_role / authorized_repliers instead.) When auto-provisioning, a bot sets this from its manifest forge.webhook.author_allowlist; co-enabling a bot that reviews all authors re-opens the shared webhook. With a per-bot routing table (next section) this flat filter is superseded by each bot's own author_allowlist, kept per rule.
  • bot_ids + wildcard_bots — the only bots a delivery may launch. A wildcard (["*"] with wildcard_bots=true) must be declared explicitly so the studio + audit can flag it; the create endpoint logs a webhooks: wildcard-bot webhook warning at WARN level and the audit row carries wildcard_bots: true (pkg/server/webhooks_routes.go:normalizeBotScope).

For zero-config forge webhooks (no explicit default_bot_id, no single bot in scope) iterion auto-selects the review-pr bot — the same default that ships with the Revi catalog (pkg/server/webhooks_common.go:defaultWebhookBotReviewPR). The generic webhook deliberately does not apply this default — a bot-agnostic endpoint must pick deterministically, so missing-bot is a 400.

Per-bot routing — co-enabling several bots on one repo

The filters above describe a single-bot webhook. A repo usually wants several bots on the same delivery — a reviewer and a dependency guard — and a flattened config cannot express that: SelectBot() returns "" as soon as two bots are in scope, so the lane fell back to the hardcoded reviewer and the guard was lost entirely.

A webhook that an auto-provisioned integration has migrated therefore carries a per-bot routing table (BotRules, one entry per co-enabled bot). An inbound delivery then fans out to every bot whose own rule claims the event and admits the author — each with its own author filter, label filter, and launch vars, its own idempotency key, and a FRESH vars map (so two bots never share one publish grant) (pkg/webhooks/types.go:BotRule, pkg/server/webhooks_common.go:resolveForgeEventBots).

A bot's rule is materialised by the forge orchestrator from its manifest forge.webhook block:

  • events — the normalized event kinds this bot claims (e.g. pull_request, pull_request_comment). A command-only bot claims none and is routed only by its CommandMap.
  • author_allowlist — restricts THIS bot to PRs/MRs from these logins (empty = any author). Entries support a suffix wildcard: *renovate[bot] matches acme-renovate[bot] too, because self-hosting Renovate/Dependabot under an org GitHub App (the usual way to make their PRs trigger CI) renames the bot.
  • author_scope: exclusive — the authors in author_allowlist are MINE: provisioning adds them to every OTHER co-enabled bot's author_denylist, so a general reviewer stops double-reviewing the dependency PRs this guard owns — without the reviewer's manifest naming the guard. The default shared leaves the authors open to all. (Meaningful only with a non-empty author_allowlist.)
  • launch_vars — THIS bot's default run vars, kept per bot so two bots' vars cannot collide in one flat map.

A config written before this field existed carries no BotRules, and the legacy single-bot path — SelectBot/default_bot_id and its idempotency keys — is kept byte-identical; re-provisioning an unchanged bot set backfills the table without minting a token or calling the forge. One contract change follows from the fan-out: a delivery no enabled bot claims is now filtered (200) rather than 403 — a 4xx on a forge hook is what makes GitHub disable it.

Idempotency

Iterion durably dedupes deliveries via a unique index on (tenant_id, idempotency_key) — a duplicate insert returns ErrDuplicate and the handler replies 200 with {status:"duplicate", run_id, delivery_id} (pkg/server/webhooks_common.go:insertAndLaunchWebhook). The key space is path-disjoint so the same event id can't collide across paths — most paths carry a literal prefix (mr|, gh|, fj|, generic|), while the GitLab note path stays disjoint via its note:<note_id> subject segment rather than a prefix:

Key prefixIdentifying tupleBumps on
mr|(tenant, webhook, project_id, mr_iid, head_sha)a new push (new head SHA) → fresh launch
(none)(tenant, webhook, project_id, note:note_id)a new /revi comment → fresh launch
gh|(tenant, webhook, project_path, pr_number, head_sha)a new push → fresh launch
fj|(tenant, webhook, project_path, pr_number, head_sha)a new push → fresh launch
generic|(tenant, webhook, request.idempotency_key OR sha256(body))any change in dedup token or body → fresh launch

Terminal (non-launched) rows — invalid, filtered, quota_exceeded, rate_limited, launch_error — get a random UUID as their idempotency key so they never collide with the real dedup key (pkg/server/webhooks_common.go:recordTerminalWebhookDelivery). A retry of the same upstream event after a transient failure can therefore launch successfully.

Limits + admission

A delivery passes through these gates in order, each enforced by the middleware before the provider handler runs (pkg/server/middleware_webhook.go:webhookAuth):

StepOutcome on failHTTP
Resolve Config by URL idnot found / provider mismatch401 invalid webhook
Verify token (token-mode only)bad token401 invalid webhook token
Config not disabledenabled=false410 webhook disabled
Per-webhook token-bucket rate (default rate=1.0, burst=10)bucket empty429 with Retry-After
Per-org monthly call quota (default 10 000, override monthly_call_limit)quota exhausted429 monthly call quota exceeded
Org status activesuspended / read-only403 org suspended

Then the provider handler verifies the body (HMAC for github/forgejo, optional HMAC for generic), parses, applies the event/project/bot filters above, and finally hands off to insertAndLaunchWebhook which runs the launch-admission gate (org-level quotas / cost cap / concurrency / launch rate — see quotas-and-limits.md) before publishing the run.

A denial at the launch-admission step writes a launch_error delivery row carrying the stable denial reason (monthly_run_quota_exceeded, monthly_cost_cap_exceeded, …) and returns the standard launch-denial envelope to the forge — so the forge sees a 402/429 it can decide what to do with, not a synthetic 200.

Delivery audit + statuses

Every accepted request lands in webhook_deliveries with one of these statuses (pkg/webhooks/types.go status constants):

StatusMeaning
acceptedAuth/quota passed, awaiting launch result (intermediate state)
launchedRun published to the queue; run_id set, launched_at stamped
duplicateSame idempotency key replayed — run_id of the original launch is returned
filteredThe event didn't match event_allowlist / project_allowlist / author_allowlist / IsReviewable
invalidBad payload, missing token, bot not permitted by scope
rate_limitedPer-webhook bucket empty
quota_exceededPer-org or per-webhook monthly call quota exhausted
launch_errorThe launch-admission gate refused (cost cap / run quota / concurrency / org suspended) OR the runner publisher failed

Delivery rows never carry the raw payload — only a SHA-256 hash, the selected fields (event_kind, event_action, project_path, subject_id, subject_sha), the source IP, and (for launched rows) the resulting run_id. Read them at GET /api/teams/{id}/webhooks/{webhook_id}/deliveries (last 100 by default).

Webhook CRUD API

All routes are mounted under /api/teams/{id}/webhooks/… and require team admin (canManageTeam) for mutations; team membership (canViewTeam) for reads (pkg/server/webhooks_routes.go:registerWebhookRoutes).

MethodPathAuthPurpose
GET/api/teams/{id}/webhooksteam memberList webhooks for the team
POST/api/teams/{id}/webhooksteam adminCreate + mint token (returned once)
GET/api/teams/{id}/webhooks/{webhook_id}team memberRead a single webhook
PATCH/api/teams/{id}/webhooks/{webhook_id}team adminUpdate name/enabled/scope/rate/quota/vars/key_overrides
DELETE/api/teams/{id}/webhooks/{webhook_id}team adminRemove (deliveries kept for audit)
POST/api/teams/{id}/webhooks/{webhook_id}/rotateteam adminRotate token + re-seal HMAC secret
GET/api/teams/{id}/webhooks/{webhook_id}/deliveriesteam memberList recent deliveries (default 100)
POST/api/webhooks/{provider}/{id}webhook token / HMACPublic delivery endpoint, one per provider

The POST create response shape:

json
{
  "config": { "id": "…", "tenant_id": "…", "provider": "gitlab", "token_last4": "Vp3a",  },
  "token": "iwh_…"
}

The token field is the only way to recover the plaintext — once the response is closed, only the salted hash remains. The studio shows it inside a "copy now, you won't see it again" affordance.

key_overrides — pin a BYOK key per webhook

key_overrides maps a provider name ("anthropic", "openai", …) to a BYOK API-key id owned by the same team. Runs launched through this webhook then use that exact key for the named provider, overriding the org/user default in pkg/secrets/byok.go:Resolve. Use it to bill several webhooks for the same bot against different keys (e.g. one "production" webhook on the org's primary key, one "internal-CI" webhook on a sandbox key). Mismatched provider, missing key, or a key that belongs to another org are 400s (pkg/server/webhooks_routes.go:validateKeyOverrides).

launch_vars — pin run vars from the org config

Anything in launch_vars is merged into the run's variable map after the handler-derived vars, so the operator's keys always win. Useful for: e.g. forcing severity_threshold=high on a security webhook, or pinning pr_review_mode=summary regardless of what the forge said (the review-pr enum is inline|summary, default inline).

branch_improve_as_pr — how the branch-improvement bot lands its work

A boolean toggle (pkg/webhooks/types.go:Config.BranchImproveAsPR, patchable via the CRUD API) that changes how the branch-improvement bot (Billy) delivers its hardening on a PR it reviews. Default (false): Billy commits and pushes directly onto the PR's own source branch in place, so the author merges their PR and gets the improvements with it. true: Billy instead opens a separate PR targeting that source branch (routed through open_mr=true + mr_base=<source branch>), so the author reviews the bot's changes as an isolated diff before integrating — the right posture for a third-party contributor who should stay in control of their branch. Applied on the GitHub / GitLab / Forgejo PR and /revi comment paths (pkg/server/webhooks_github.go:branchImproveVars, pkg/server/webhooks_prforge.go:stampBranchImprovePushBack).

Observability

Every delivery bumps a label set on iterion_webhook_deliveries_total (provider, status) and pre-handler throttles bump iterion_webhook_throttled_total (provider, reason) (pkg/cloud/metrics/metrics.go). There are deliberately no tenant labels — cardinality discipline — so per-org accounting lives in Mongo (org_usage + webhook_deliveries), not Prometheus.

The starter PrometheusRule pack ships an alert on increase(iterion_webhook_throttled_total[1h]) > 50 that surfaces a noisy forge integration or an abusive caller. See charts/iterion/README.md for the full alert pack.