Webhook Event Routing
Webhook Event Routing
Section titled “Webhook Event Routing”Status: Active design.
Cross-references: ARCHITECTURE.md (system overview), LEASING.md (lease protocol), SECURITY.md (HMAC verification).
Why webhooks over cron polling
Section titled “Why webhooks over cron polling”The previous design used a cron trigger (*/5 * * * *) as the primary engine
trigger. Every 5 minutes, the worker iterated all managed repos, regardless of
whether anything changed. Problems:
| Issue | Cron polling | Webhooks |
|---|---|---|
| Latency | Up to 60 seconds | Sub-second (event-driven) |
| Compute waste | Runs a reconciliation tick every 5 minutes | Only processes repos with events |
| Scalability | O(repos) work per tick | O(events) work per tick |
| Stale state | Missed events accumulate | Events are processed immediately |
The cron trigger is retained as a reconciliation safety net only — it detects missed webhooks, fixes stale leases, and corrects inconsistent queue state.
Event types and actions
Section titled “Event types and actions”Forgejo/Gitea/Codeberg push webhook events to
POST /api/v1/webhooks/forgejo. The event router maps each event type to an
engine action via the LeaseDO processEvent protocol.
pull_request events
Section titled “pull_request events”| Event | Action | Details |
|---|---|---|
pull_request.opened |
Join queue | Add PR to merge queue |
pull_request.reopened |
Join queue | Re-add a reopened PR |
pull_request.ready_for_review |
Join queue | Mark draft → review (queue entry) |
pull_request.closed |
Leave queue | Remove PR from queue |
pull_request.cancelled |
Leave queue | Remove PR from queue |
pull_request.edited |
Ignore | Metadata change, no queue action |
pull_request.synchronize |
Ignore | Replaced by push event |
Route: _handlePullRequest() in LeaseDO.
opened/reopened/ready_for_review→_queueJoin()(double-staging guard)closed/cancelled→_queueLeave()
push events
Section titled “push events”| Event | Action | Details |
|---|---|---|
push (with prNumber) |
Re-evaluate gate | New commit on a queued PR → re-run gate |
push (no prNumber) |
Ignore | Not associated with a queued PR |
Route: _handlePush() in LeaseDO. When a new commit lands on a PR that’s
currently in the queue, the engine must re-evaluate the gate (the staging
branch needs the new commit cherry-picked).
Note: The push event includes the target branch in ref. For
pull_request.synchronize events, Forgejo also pushes to the PR’s source
branch — which triggers this push event. The router uses the pull_request
association in the payload to determine if the PR is queued.
check_run events
Section titled “check_run events”| Event | Action | Details |
|---|---|---|
check_run.completed (success) |
Gate passed | CI passed on staging branch |
check_run.completed (failure) |
Bounce | CI failed → remove PR from queue |
check_run.pending |
Ignore | CI still running |
check_run.started |
Ignore | CI just started |
Route: _handleCheckResult() in LeaseDO. The action field maps to
conclusion (success/failure). The event must include a prNumber to be
routed — otherwise it’s an unknown check and is ignored.
check_suite events
Section titled “check_suite events”Same routing as check_run. A check_suite completion aggregates the results
of its child check_runs.
Route: _handleCheckResult() in LeaseDO.
status events (legacy)
Section titled “status events (legacy)”| Event | Action | Details |
|---|---|---|
status (success) |
Gate passed | Legacy commit status |
status (failure) |
Bounce | Legacy commit status |
status (pending) |
Ignore | CI still running |
Route: _handleStatus() in LeaseDO. These are from older forge versions
or CI systems that use the legacy commit status API instead of check_run.
HMAC signature verification flow
Section titled “HMAC signature verification flow”Every webhook request includes a signature header. Verification prevents unauthorized events from entering the queue.
Request headers: X-Hub-Signature-256: sha256=<hex_digest> (Forgejo/Gitea) X-Gitlab-Token: <random> (GitLab — different mechanism) X-Gogs-Signature: <hex_digest> (Gogs — rare)
Worker receives event: 1. Extract tenant from request context (tenant ID embedded in URL or resolved from event payload via instance_url → forge_connection lookup) 2. Look up tenant's webhook_secret from Supabase (forge_connections.webhook_secret) 3. Compute HMAC-SHA256(secret, request.body) 4. Compare computed digest with header (constant-time) 5. If mismatch: return 401, log, drop event 6. If match: proceed to event routingTenant secret management: Each forge_connection row has a webhook_secret
column — a random 32-byte value set per tenant during onboarding. This secret
is used for HMAC verification and is never returned by API.
Rotation: If a tenant’s webhook secret is compromised, the tenant rotates it via the API. Old events signed with the previous secret are rejected — this is by design. The HMAC check is all-or-nothing per request.
Signature header format by forge
Section titled “Signature header format by forge”Forgejo/Gitea: Header: X-Hub-Signature-256 Format: sha256=<hex> Payload: raw request body Secret: stored in forge_connections.webhook_secret
Gitea 1.21+: Same as Forgejo (they share the same webhook format)
GitLab: Header: X-Gitlab-Token Format: plain token match (not HMAC) Secret: stored in forge_connections.webhook_secretEvent routing logic
Section titled “Event routing logic”The webhook handler (internal/api/ or index.mjs route) performs three steps:
1. HMAC verification └─→ 401 on failure → log, drop
2. Extract tenant scope └─→ From event payload → forge_connection → tenant_id └─→ 404 if tenant not found → log, drop
3. Dispatch to LeaseDO └─→ POST /event with {eventType, tenant, repo, action, prNumber, details} └─→ LeaseDO processes with acquire → route → releaseThe action field in the event payload maps to the engine action:
| Webhook event.action | Engine action | LeaseDO handler |
|---|---|---|
opened |
join |
_queueJoin() |
reopened |
join |
_queueJoin() |
ready_for_review |
join |
_queueJoin() |
closed |
leave |
_queueLeave() |
cancelled |
leave |
_queueLeave() |
(any) push |
reevaluate |
_queueReevaluate() |
completed (check_run) |
gate_passed / bounce |
_queueGateResult() |
success/failure (status) |
gate_passed / bounce |
_queueGateResult() |
LeaseDO protocol for webhooks
Section titled “LeaseDO protocol for webhooks”The LeaseDO processEvent endpoint is the contract between the webhook handler
and the lease manager:
POST /eventContent-Type: application/json
{ "eventType": "pull_request" | "push" | "check_run" | "check_suite" | "status", "tenant": "tenant_id", "repo": "owner/repo", "action": "opened" | "closed" | "success" | "failure" | ..., "prNumber": 42, "details": { "sha": "abc123", "conclusion": "success" | "failure", ... }}
Response (200):{ "status": "queued" | "left" | "gate_passed" | "bounced" | "already_queued" | "not_in_queue" | "ignored", "action_taken": "join" | "leave" | "gate_re_run" | "gate_passed" | "bounce" | "none", "prNumber": 42, "queueLength": 5}
Response (409):{ "error": "locked", "held_by": "worker"}Protocol: processEvent() calls acquire(), routes the event through the
appropriate handler, writes to LeaseDO storage, then calls release(). If
acquire() returns 409 (lease held), the event is dropped with a 409 response.
The cron reconciliation picks up the missed event on the next tick.
Cron reconciliation
Section titled “Cron reconciliation”The cron trigger (scheduled()) runs every 5 minutes and serves as a safety net
for missed or dropped webhook events. It does NOT execute the engine tick — it
only corrects state.
scheduled() every 5 minutes: 1. Fetch managed repos from Supabase (active connections + enabled repos) 2. For each repo: a. Try to acquire LeaseDO lease b. If acquired (200): - Read queue state from LeaseDO storage - Query forge API for actual PR state - Reconcile: fix stale entries, detect duplicates, clean bounced PRs - Release lease c. If locked (409): - Lease is held by an in-flight webhook event — skip this repo d. If storage error: - Log error, skip this repo (next tick retries) 3. Report reconciliation results to audit logReconciliation actions:
| Issue detected | Action |
|---|---|
| Stale lease (>90s, 3× TTL) | Release it — force-correct the DO state |
| Duplicate PR in queue | Remove duplicates |
| Bounced PR still in queue | Remove bounced entries |
| PR in queue but closed on forge | Remove — webhook was dropped |
| PR expected in queue but not found | Re-queue if auto-merge is scheduled |
Error handling
Section titled “Error handling”| Error | Response | Handling |
|---|---|---|
| HMAC verification failure | 401 | Log, drop event |
| Unknown tenant | 404 | Log, drop event |
| Bad request body | 400 | Log, drop event |
| Lease held (409) | 409 | Caller drops event; cron reconciliation catches it |
| Storage error | 500 | Log, cron reconciliation retries |
| Forge API unreachable | 502 (via engine) | Log, next tick retries |
Testing webhooks locally
Section titled “Testing webhooks locally”See DEVELOPMENT.md for local development setup. Use
webhook.site or ngrok to expose a local endpoint and configure your forge
instance to push events there for testing.