Webhooks - Forgejo Webhooks for CI Triggers
Status: Active Last Updated: 2026-08-26 Category: CI/CD - Integration Prerequisites: forgejo-installation, woodpecker-installation Time: 1 hour Tags: forgejo, webhooks, woodpecker, security, debugging
Summary
Webhooks are how Forgejo tells Woodpecker (and anything else in your stack) that something happened: a push landed, a PR opened, a tag was cut. This article covers webhook anatomy, configuring them with secrets, the payload shapes that matter for CI, and a systematic approach to debugging failed deliveries.
π― What You'll Learn
By the end of this article, you'll be able to:
- β Explain how Woodpecker auto-registers webhooks via OAuth vs manual setup
- β Configure Forgejo webhooks with shared secrets
- β
Read push/PR/tag payloads and map them to pipeline
whenfilters - β Debug delivery failures from both ends (Forgejo + receiver)
- β Verify signatures and lock down who can edit webhooks
Context / Why This Matters
Every push-triggered pipeline you've run so far exists because of one HTTP POST. When "CI didn't run," the root cause is almost always at the webhook layer β wrong URL, secret mismatch, TLS failure, or an event type you didn't subscribe to. Understanding deliveries turns flaky CI triggers into diagnosable systems.
Implementation / Core Content
How Woodpecker Registers Its Own Webhook
When you enable a repo through Woodpecker's UI using the Forgejo OAuth integration, Woodpecker creates the webhook for you on POST /repos/{owner}/{repo}/hooks. It points to:
https://ci.example.com/api/hooks?repo=acme-corp/api-service
and subscribes to push, pull_request, tag events. If it's missing or broken, first re-sync from Woodpecker: Repo β Settings β "Repair repository" β this recreates hooks without touching config.
Manual registration is only needed for non-Woodpecker receivers (alerting scripts, deploy bots):
Repo β Settings β Webhooks β Add Webhook β Forgejo/Gitea:
| Field | Value |
|---|---|
| Target URL | e.g. https://deploy-bot.internal/hooks/forgejo |
| Method | POST |
| Trigger On | Custom events⦠(pick explicitly) |
| Secret | Random β₯32 chars: openssl rand -hex 32 |
Event Types That Matter for CI
| Event | Typical use |
|---|---|
push |
Build/test on every commit; deploy on default branch |
pull_request |
PR validation (opened, synchronize, reopened) |
tag / release |
Versioned builds, image publishes |
issue_comment |
Chat-ops style /retest commands |
repository |
Sync org-level automation when repos change |
Payload Anatomy (push)
Trimmed but representative:
{
"ref": "refs/heads/main",
"before": "a1b2c3...",
"after": "d4e5f6...",
"compare_url": "https://git.example.com/acme-corp/api-service/compare/a1b2c3...d4e5f6",
"commits": [
{
"id": "d4e5f6...",
"message": "fix: handle empty queue\n\nPipeline-Run: full",
"author": { "name": "Alice", "email": "alice@example.com" }
}
],
"head_commit": { "id": "d4e5f6..." },
"repository": { "full_name": "acme-corp/api-service", "default_branch": "main" },
"sender": { "login": "alice" },
"secret_hash": "<hmac-sha256 hex>"
}
Woodpecker maps these to pipeline metadata: CI_COMMIT_BRANCH, CI_COMMIT_SHA, CI_PIPELINE_EVENT β which your when: blocks filter on:
when:
event: [push]
branch: main # from payload .ref == refs/heads/main
Tag pushes arrive as ref: refs/tags/v1.4.0 β event: tag, branch unset.
Securing Deliveries
Forgejo signs each request with your configured secret as X-Forgejo-Signature / X-Gitea-Signature (HMAC-SHA256 of the raw body). Verify server-side:
import hmac, hashlib
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
assert hmac.compare_digest(expected, headers["x-forgejo-signature"])
Checklist:
- Always set a secret β even on internal networks.
- Use HTTPS targets; self-signed certs need the "Disable SSL verification" toggle only inside trusted networks, never for prod deploys.
- Restrict webhook editing: only repo admins can change them, so keep team permission levels tight (user-management).
Debugging Deliveries
Forgejo records every attempt. Repo β Settings β Webhooks β click the hook β Recent Delivery History shows timestamp, status code, and full request/response bodies.
Reading the history like an engineer:
2026-08-26 09:14:02 POST https://ci.example.com/api/hooks?... β 200 OK β
2026-08-26 09:15:10 POST ... β timeout β
2026-08-26 09:16:44 POST ... β 403 β
200/204β delivered; if CI still didn't run, look at the receiver's logs next.timeoutβ target down or slow; check reverse proxy and service health.403/401β secret mismatch or auth middleware rejecting.TLS handshake errorβ certificate not trusted by Forgejo's container.- No delivery listed at all β event wasn't subscribed; edit trigger events.
Replay any past delivery with the Redeliver button after fixing the cause β no dummy commits needed.
Receiver-side quick capture for custom endpoints:
# One-off listener to inspect real payloads
nc -l -p 9999 | tee last-webhook.txt
# Or with more structure:
docker run --rm -p 8080:8080 weshigbee/webhook-listener
Then point a test webhook at http://build-host:9999/ and push a trivial commit to a scratch repo.
Practical Examples
Example 1: Diagnose "pipeline didn't run"
- Open the repo webhook history in Forgejo. No entries β event not subscribed or hook deleted; run Woodpecker's "Repair repository".
- Entry with 200 but no run β check Woodpecker repo settings: is the branch excluded? Is
.woodpecker.yamlpresent on that ref? - Entry with non-2xx β fix per status table above, then Redeliver.
Example 2: Deploy bot webhook with secret verification
Minimal Flask receiver:
from flask import Flask, request, abort
import hmac, hashlib, os
app = Flask(__name__)
SECRET = os.environ["WEBHOOK_SECRET"].encode()
@app.post("/hooks/forgejo")
def hook():
sig = request.headers.get("X-Forgejo-Signature", "")
expected = hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(403)
data = request.json
if data["ref"] == "refs/heads/main":
print(f"deploying {data['after']}") # kick off deploy job
return "", 204
Common Pitfalls & Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| Webhook returns 403 | Secret mismatch between Forgejo and receiver | Re-copy the secret; watch for trailing whitespace/newline |
| Deliveries time out intermittently | Receiver behind slow DNS or proxy buffering | Increase Forgejo timeout; check proxy access logs |
| CI ignores pushes to feature branches | Hook fine, but Woodpecker when: filters branch |
Adjust pipeline filters; see woodpecker-pipeline-testing |
x509: certificate signed by unknown authority |
Forgejo container lacks CA bundle | Mount host CAs or use publicly trusted certs (letsencrypt-automation) |
| Duplicate pipeline runs | Two hooks registered (auto + manual) | Delete the manual duplicate in Forgejo webhook list |
| Tag build runs twice (push + tag) | Both events subscribed and pipeline matches both | Scope when: blocks precisely |
| Payload body unreadable in receiver | Body consumed before signature check | Read raw bytes once, verify HMAC, then parse JSON |
Next Steps / Ops Actions
- Test pipelines end-to-end once triggers work: woodpecker-pipeline-testing
- Gate merges on CI results: branch-protection
- Monitor webhook health alongside CI metrics: ci-monitoring
Sources & Related Articles
External references consulted:
- https://forgejo.org/docs/latest/user/webhooks/
- https://woodpecker-ci.org/docs/usage/webhooks (conceptual equivalent)
Related knowledge-base articles:
Change Log
2026-08-26
- Initial creation covering webhook configuration, secrets/signatures, payload anatomy, and delivery debugging.