How to Receive Webhooks from Forgejo (Self-Hosted, Step by Step)

Receive and verify Forgejo webhooks on your own server: X-Forgejo-Signature HMAC-SHA256 validation, Gitea and GitHub compatibility headers, Docker setup, and RabbitMQ routing, verified against a real Forgejo 12.

By the end of this guide you will have a self-hosted endpoint that receives Forgejo webhooks, verifies the HMAC-SHA256 signature on every delivery, and routes the payloads to a log, RabbitMQ, or any other destination. Everything below was verified end to end against a real Forgejo 12 instance and the real Core Webhook Module Docker image: Forgejo itself sent the test deliveries, so the headers, signatures, and responses quoted here are exactly what you will get. The same steps apply whether you run your own instance or receive hooks from a project hosted on Codeberg.

How Forgejo webhooks work

When an event happens in a repository (a push, an issue, a pull request), Forgejo sends an HTTP POST to your webhook URL. These are the headers from an actual delivery:

  • X-Forgejo-Event: the event name (push, issues, pull_request, ...)
  • X-Forgejo-Event-Type: the specific event type
  • X-Forgejo-Delivery: a unique UUID per delivery
  • X-Forgejo-Signature: HMAC-SHA256 of the raw request body, as a plain lowercase hex digest
  • Compatibility copies for tooling built against other forges: X-Gitea-* and X-Gogs-* headers with the same values, X-GitHub-* event headers, X-Hub-Signature (sha1= prefixed) and X-Hub-Signature-256 (sha256= prefixed, same digest as the Forgejo signature)

Authentication: you set a secret in the webhook form, and Forgejo signs the raw body of every delivery with HMAC-SHA256 using that secret. Your receiver recomputes the digest over the exact bytes it received and compares in constant time. Because the same digest also ships in GitHub's sha256= format, a receiver built for GitHub webhooks works against Forgejo unchanged; this guide verifies against the native X-Forgejo-Signature header.

Delivery behavior: webhooks are sent asynchronously, and the webhook's page in Forgejo keeps a log of recent deliveries with full request and response details plus a redelivery button. There is also a Test Delivery button that sends a synthetic push event on demand.

Prerequisites

Step 1: Configure the receiver

Create a working directory with two files.

webhooks.json:

{
    "forgejo_events": {
        "data_type": "json",
        "module": "log",
        "hmac": {
            "secret": "{$FORGEJO_WEBHOOK_SECRET}",
            "header": "X-Forgejo-Signature",
            "algorithm": "sha256"
        }
    }
}

connections.json:

{}

What each parameter does:

  • forgejo_events is the webhook ID; it becomes the URL path (/webhook/forgejo_events).
  • module: log prints each verified payload to the container log. Zero dependencies for the first run; we swap in RabbitMQ at the end.
  • hmac recomputes the HMAC-SHA256 of the raw body and compares it against the X-Forgejo-Signature header in constant time. Header lookup is case-insensitive, and pointing header at X-Hub-Signature-256 works too since the sha256= prefix is handled automatically.
  • {$FORGEJO_WEBHOOK_SECRET} pulls the secret from an environment variable, so it never lives in a config file.

Generate a strong secret (or use our webhook secret generator):

openssl rand -hex 32

Step 2: Run it

docker run -d --name webhook-gateway -p 8000:8000 \
  -v "$PWD/webhooks.json:/app/webhooks.json:ro" \
  -v "$PWD/connections.json:/app/connections.json:ro" \
  -e FORGEJO_WEBHOOK_SECRET="your-generated-secret" \
  spiderhash/webhook:latest

Confirm it is up:

docker logs webhook-gateway
# ... INFO: Application startup complete.

The receiver now answers on http://localhost:8000/webhook/forgejo_events, with auto-generated API docs at http://localhost:8000/docs.

Step 3: Test it locally

You do not need Forgejo to test; you need a request signed the way Forgejo signs. Save a realistic push payload:

cat > push.json <<'EOF'
{"ref":"refs/heads/main","before":"28e1879d029cb852e4844d5e0e7bd6c8991f9e63","after":"bffeb74224043ba2feb48d137756c8971549fa4b","commits":[{"id":"bffeb74224043ba2feb48d137756c8971549fa4b","message":"Fix rate limiter cleanup\n","author":{"name":"John Smith","email":"[email protected]","username":"john"}}],"total_commits":1,"repository":{"full_name":"acme/api"},"pusher":{"login":"john"},"sender":{"login":"john"}}
EOF

Compute the signature over the exact bytes of the file and send it as plain hex, exactly like Forgejo does:

SIG=$(openssl dgst -sha256 -hmac "your-generated-secret" -hex push.json | awk '{print $NF}')

curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:8000/webhook/forgejo_events \
  -H "Content-Type: application/json" \
  -H "X-Forgejo-Event: push" \
  -H "X-Forgejo-Signature: $SIG" \
  --data-binary @push.json

Expected result:

{"message":"200 OK"}
HTTP 200

Now prove the verification works. A tampered or wrong signature:

{"detail":"Invalid HMAC signature"}
HTTP 401

And a request with no signature header at all:

{"detail":"Missing X-Forgejo-Signature header"}
HTTP 401

Those are the exact responses the gateway returns. docker logs webhook-gateway shows the accepted payload.

Step 4: Create the webhook in Forgejo

In your repository: Settings > Webhooks > Add Webhook > Forgejo, then:

  • Target URL: https://your-domain/webhook/forgejo_events
  • HTTP Method: POST
  • POST Content Type: application/json
  • Secret: the same value you passed as FORGEJO_WEBHOOK_SECRET
  • Trigger On: pick the events you want; push events is a good start
  • Branch filter: leave * unless you want a subset

Webhooks can also be created at the organization or user level, or via the API (POST /api/v1/repos/{owner}/{repo}/hooks). Save, then open the webhook again and press Test Delivery. Forgejo sends a synthetic push event immediately; you should see it in docker logs webhook-gateway, and Recent Deliveries keeps every request and response with a redeliver option.

Troubleshooting

  • 401 {"detail":"Invalid HMAC signature"}: the Secret field in Forgejo and FORGEJO_WEBHOOK_SECRET differ, or something between Forgejo and the gateway modified the body. The signature covers the raw bytes; a proxy that re-encodes the payload breaks it.
  • 401 {"detail":"Missing X-Forgejo-Signature header"}: the signature headers are sent with empty values when no Secret is configured on the webhook. Set the Secret and redeliver.
  • Forgejo refuses your URL or the delivery fails instantly on a private address: by default only external addresses are allowed as webhook targets (ALLOWED_HOST_LIST in app.ini). For a receiver on a private or loopback address, allow it explicitly; with Docker env config, FORGEJO__webhook__ALLOWED_HOST_LIST=external,private,loopback (verified: this guide's run used exactly that mechanism), then restart Forgejo.
  • Migrating tooling from Gitea or GitHub: nothing changes. The delivery carries X-Gitea-Signature (same plain hex digest) and X-Hub-Signature-256 (sha256= prefixed) alongside the Forgejo headers, so existing verifiers keep working while you switch the header name at your own pace.

Going to production

Printing to logs is not a pipeline. Swap the module for a real destination; here is the RabbitMQ variant, verified end to end (a real Forgejo test delivery landed as a message in the forgejo_events queue):

webhooks.json:

{
    "forgejo_events": {
        "data_type": "json",
        "module": "rabbitmq",
        "connection": "rabbitmq_local",
        "module-config": {
            "queue_name": "forgejo_events"
        },
        "hmac": {
            "secret": "{$FORGEJO_WEBHOOK_SECRET}",
            "header": "X-Forgejo-Signature",
            "algorithm": "sha256"
        }
    }
}

connections.json:

{
    "rabbitmq_local": {
        "type": "rabbitmq",
        "host": "{$RABBITMQ_HOST:localhost}",
        "port": "{$RABBITMQ_PORT:5672}",
        "user": "{$RABBITMQ_USER:guest}",
        "pass": "{$RABBITMQ_PASS:guest}"
    }
}

The same pattern works for 17 other destinations: archive deliveries to S3, publish to Kafka, or fan out to several at once with webhook chaining. Built-in rate limiting and retry handling cover the operational edges. The full authentication reference, including HMAC validation and 11 other methods, is in the docs.

Subscribe to Free Webhook Tool

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe