Skip to content

Webhook Event Routing

Status: Active design.

Cross-references: ARCHITECTURE.md (system overview), LEASING.md (lease protocol), SECURITY.md (HMAC verification).

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.

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.

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

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.

Same routing as check_run. A check_suite completion aggregates the results of its child check_runs.

Route: _handleCheckResult() in LeaseDO.

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.

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 routing

Tenant 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.

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_secret

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 → release

The 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()

The LeaseDO processEvent endpoint is the contract between the webhook handler and the lease manager:

POST /event
Content-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.

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 log

Reconciliation 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 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

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.