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 exampleissue_assign)X-Gitea-Delivery: a unique UUID per deliveryX-Gitea-Signature: HMAC-SHA256 of the raw request body, as a plain lowercase hex digestX-Hub-Signature-256: the same digest in GitHub's format, with asha256=prefixX-Hub-Signature, plusX-Gogs-*andX-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
- Docker on any machine (your laptop is fine for the test run)
- A public HTTPS URL for production, unless your Gitea and your receiver share a network. Two ways to get one:
- HTTPS for webhooks with nginx and Let's Encrypt if you have a public server
- Receiving webhooks through Cloudflare Tunnel if you do not want to open inbound ports
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_eventsis the webhook ID; it becomes the URL path (/webhook/gitea_events).module: logprints each verified payload to the container log. Zero dependencies for the first run; we swap in RabbitMQ at the end.hmacrecomputes the HMAC-SHA256 of the raw body and compares it against theX-Gitea-Signatureheader in constant time. Header lookup is case-insensitive, and both plain hex andsha256=-prefixed values are accepted, so pointingheaderatX-Hub-Signature-256works 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 andGITEA_WEBHOOK_SECRETdiffer, 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 = externalin the[security]section ofapp.ini). For a receiver on a private or loopback address, add for exampleALLOWED_HOST_LIST = external,private,loopback, or with Docker env configGITEA__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 namedpayloadand the signature covers that encoded body. Stick withapplication/jsonunless 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.