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_bymarker, 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
- An org admin creates a webhook through the studio (
Webhookstab on/teams/<id>) or the API. Iterion mints aniwh_…token (32 bytes of randomness behind a recognisable prefix) and returns it exactly once alongside the newConfigdocument (pkg/webhooks/token.go:MintToken). - The admin pastes the inbound URL + token into the forge:
- GitLab → Settings → Webhooks → URL
https://…/api/webhooks/gitlab/<id>+ Secret Tokeniwh_… - GitHub → Settings → Webhooks → Payload URL
https://…/api/webhooks/github/<id>+ Secretiwh_… - Forgejo/Gitea → Settings → Webhooks → Target URL + Secret
iwh_… - Generic → any HTTP client, header
X-Iterion-Webhook-Token: iwh_…
- GitLab → Settings → Webhooks → URL
- From then on, each delivery is admitted through the middleware, parsed by the provider, dispatched to a bot, and recorded as a
Deliveryrow. 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).
| Provider | Default sign_mode | What proves authenticity | Header iterion reads |
|---|---|---|---|
| GitLab | token | The forge echoes the iwh_ plaintext verbatim | X-Gitlab-Token (or X-Iterion-Webhook-Token) |
| GitHub | hmac (forced) | HMAC-SHA256 of the raw body, key = iwh_ plaintext | X-Hub-Signature-256 |
| Forgejo/Gitea | hmac (forced) | HMAC-SHA256 of the raw body | X-Forgejo-Signature (falls back to X-Gitea-Signature) |
| Generic | token (default; hmac opt-in) | Header bearer token, or HMAC of body | X-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 onopen/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 theupdatewhosechanges.draft(or the deprecatedwork_in_progress) wenttrue→false. Ordinary pushes (actionupdatewith 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 GitHubissues(below). GitLab has nolabeledaction, so the parser diffschanges.labels(previous→current) and fires only on a freshly-added label that passeslabel_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_requestwith actionopened,reopened, orready_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 (thedraftflag is honoured on every action — the trigger isready_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/commandpath — 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/commandslash path (e.g./featurly <prompt>,/billy), routed through the command registry.issueswith actionlabeled→ launches the webhook's bot with the labeled issue turned into a feature task. The handler derivesfeature_prompt(issue title + body),open_mr=true, andsource_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 withlabel_allowlist(below); re-applying the same label is an idempotent replay.issueswith actionopened+auto_implement_on_open→ the zero-touch lane, now author-gated: the issue AUTHOR must be trusted — on the staticauthor_allowlist, ORauthor_association∈ OWNER/MEMBER/COLLABORATOR (decoded from the payload, no API call), OR liveCollaboratorPermission≥min_author_role(gitlab vocabulary,""→ developer ≡ write; needs aforge_tokenbinding). 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 withneeds:approvalfor the operator's "Approve & triage". Thelabeledlane 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):
{
"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:
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/Forgejoissuespath defaults to{issues}). A bare*matches everything.label_allowlist— for theissues(labeled) path only: which freshly-applied label fires (e.g.["implement"]). Empty = any label; case-insensitive; a bare*matches everything. No effect on thepull_request/issue_commentpaths. On a provisioned webhook, set it through the integration (label_allowlistonPOST/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. Unlikelabel_allowlist(which selects a bot),hold_labelsvetoes them.project_allowlist—owner/repopatterns. 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 likedependabot[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 thepr_authorvar. (Applies to the PR/MR open path; comment//revitriggers usemin_replier_role/authorized_repliersinstead.) When auto-provisioning, a bot sets this from its manifestforge.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 ownauthor_allowlist, kept per rule.bot_ids+wildcard_bots— the only bots a delivery may launch. A wildcard (["*"]withwildcard_bots=true) must be declared explicitly so the studio + audit can flag it; the create endpoint logs awebhooks: wildcard-bot webhookwarning at WARN level and the audit row carrieswildcard_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 itsCommandMap.author_allowlist— restricts THIS bot to PRs/MRs from these logins (empty = any author). Entries support a suffix wildcard:*renovate[bot]matchesacme-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 inauthor_allowlistare MINE: provisioning adds them to every OTHER co-enabled bot'sauthor_denylist, so a general reviewer stops double-reviewing the dependency PRs this guard owns — without the reviewer's manifest naming the guard. The defaultsharedleaves the authors open to all. (Meaningful only with a non-emptyauthor_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 prefix | Identifying tuple | Bumps 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):
| Step | Outcome on fail | HTTP |
|---|---|---|
Resolve Config by URL id | not found / provider mismatch | 401 invalid webhook |
| Verify token (token-mode only) | bad token | 401 invalid webhook token |
| Config not disabled | enabled=false | 410 webhook disabled |
Per-webhook token-bucket rate (default rate=1.0, burst=10) | bucket empty | 429 with Retry-After |
Per-org monthly call quota (default 10 000, override monthly_call_limit) | quota exhausted | 429 monthly call quota exceeded |
Org status active | suspended / read-only | 403 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):
| Status | Meaning |
|---|---|
accepted | Auth/quota passed, awaiting launch result (intermediate state) |
launched | Run published to the queue; run_id set, launched_at stamped |
duplicate | Same idempotency key replayed — run_id of the original launch is returned |
filtered | The event didn't match event_allowlist / project_allowlist / author_allowlist / IsReviewable |
invalid | Bad payload, missing token, bot not permitted by scope |
rate_limited | Per-webhook bucket empty |
quota_exceeded | Per-org or per-webhook monthly call quota exhausted |
launch_error | The 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).
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /api/teams/{id}/webhooks | team member | List webhooks for the team |
POST | /api/teams/{id}/webhooks | team admin | Create + mint token (returned once) |
GET | /api/teams/{id}/webhooks/{webhook_id} | team member | Read a single webhook |
PATCH | /api/teams/{id}/webhooks/{webhook_id} | team admin | Update name/enabled/scope/rate/quota/vars/key_overrides |
DELETE | /api/teams/{id}/webhooks/{webhook_id} | team admin | Remove (deliveries kept for audit) |
POST | /api/teams/{id}/webhooks/{webhook_id}/rotate | team admin | Rotate token + re-seal HMAC secret |
GET | /api/teams/{id}/webhooks/{webhook_id}/deliveries | team member | List recent deliveries (default 100) |
POST | /api/webhooks/{provider}/{id} | webhook token / HMAC | Public delivery endpoint, one per provider |
The POST create response shape:
{
"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.
