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

Receive and verify Gitea webhooks on your own server: HMAC-SHA256 signature validation, Docker setup, and routing to RabbitMQ, every command verified against a real Gitea instance.

By the end of this guide you will have a self-hosted endpoint that receives Gitea webhooks, verifies the HMAC-SHA256 signature on every delivery, and routes the payloads to a log, RabbitMQ, or any other destination. Every command below was verified against a real Gitea 1.27 instance and the real Core Webhook Module Docker image before publishing: Gitea itself sent the test deliveries, so the signatures, headers, and responses you see here are the ones you will get. The same setup works for Forgejo, which uses the identical webhook scheme.

How Gitea webhooks work

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

  • X-Gitea-Event: the event name (push, issues, pull_request, ...)
  • X-Gitea-Event-Type: the specific event type (for example issue_assign)
  • X-Gitea-Delivery: a unique UUID per delivery
  • X-Gitea-Signature: HMAC-SHA256 of the raw request body, as a plain lowercase hex digest
  • X-Hub-Signature-256: the same digest in GitHub's format, with a sha256= prefix
  • X-Hub-Signature, plus X-Gogs-* and X-GitHub-* copies of the above, for compatibility with tooling built for Gogs or GitHub

Authentication: you set a secret in the webhook form, and Gitea signs the raw body of every delivery with HMAC-SHA256 using that secret. Your receiver recomputes the signature and compares it in constant time. Two things worth knowing from the official docs: the secret is never sent in the payload body, and if you configure no secret, the signature headers are still present but empty, so an unsigned webhook looks valid-shaped while proving nothing. Always set a secret.

Delivery behavior: deliveries are sent asynchronously with a 5 second timeout by default (DELIVER_TIMEOUT in app.ini). Gitea keeps a per-webhook delivery log with full request and response details and lets you redeliver any of them manually; do not count on automatic retries, so a receiver that acknowledges fast and hands off to a durable queue is the safe pattern.

Prerequisites

Step 1: Configure the receiver

Create a working directory with two files.

webhooks.json:

{
    "gitea_events": {
        "data_type": "json",
        "module": "log",
        "hmac": {
            "secret": "{$GITEA_WEBHOOK_SECRET}",
            "header": "X-Gitea-Signature",
            "algorithm": "sha256"
        }
    }
}

connections.json:

{}

What each parameter does:

  • gitea_events is the webhook ID; it becomes the URL path (/webhook/gitea_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-Gitea-Signature header in constant time. Header lookup is case-insensitive, and both plain hex and sha256=-prefixed values are accepted, so pointing header at X-Hub-Signature-256 works too.
  • {$GITEA_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 GITEA_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/gitea_events, with auto-generated API docs at http://localhost:8000/docs.

Step 3: Test it locally

You do not need Gitea to test; you need a request signed the way Gitea 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:

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/gitea_events \
  -H "Content-Type: application/json" \
  -H "X-Gitea-Event: push" \
  -H "X-Gitea-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-Gitea-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 Gitea

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

  • Target URL: https://your-domain/webhook/gitea_events
  • HTTP Method: POST
  • POST Content Type: application/json
  • Secret: the same value you passed as GITEA_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 defined at the organization, user, or system level (same form, wider scope), or created via the API (POST /api/v1/repos/{owner}/{repo}/hooks).

Save, then open the webhook again and press Test Delivery. Gitea sends a synthetic push event immediately; you should see it in docker logs webhook-gateway, and the Recent Deliveries list on the webhook page keeps the full request and response of every delivery, with a redeliver button for debugging.

Troubleshooting

  • 401 {"detail":"Invalid HMAC signature"}: the Secret field in Gitea and GITEA_WEBHOOK_SECRET differ, or something between Gitea and the gateway modified the body. The signature is over the raw bytes; any proxy that re-encodes the payload breaks it.
  • 401 {"detail":"Missing X-Gitea-Signature header"}: the header arrived empty. Gitea sends the signature headers with an empty value when no Secret is configured on the webhook, so set the Secret and redeliver.
  • Gitea refuses your URL or the delivery fails instantly on a private address: by default Gitea only allows webhook targets on external addresses (ALLOWED_HOST_LIST = external in the [security] section of app.ini). For a receiver on a private or loopback address, add for example ALLOWED_HOST_LIST = external,private,loopback, or with Docker env config GITEA__security__ALLOWED_HOST_LIST=..., and restart Gitea.
  • Delivery shows as failed with a timeout: the default delivery timeout is 5 seconds. The gateway responds in milliseconds when the destination module is healthy, but if you point a module at a slow downstream, hand off to a queue instead (next section).
  • Using application/x-www-form-urlencoded: the payload arrives as a form field named payload and the signature covers that encoded body. Stick with application/json unless you have a specific reason.

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 Gitea test delivery landed as a message in the gitea_events queue):

webhooks.json:

{
    "gitea_events": {
        "data_type": "json",
        "module": "rabbitmq",
        "connection": "rabbitmq_local",
        "module-config": {
            "queue_name": "gitea_events"
        },
        "hmac": {
            "secret": "{$GITEA_WEBHOOK_SECRET}",
            "header": "X-Gitea-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, and because Gitea does not retry failed deliveries for you, acknowledging fast and queueing durably is exactly what keeps you from losing events. 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