Guide to Gitea Webhooks: Features and Best Practices
Gitea is a self-hosted Git service with its own CI system, Gitea Actions. Webhooks are how a Gitea instance tells other systems what happened, such as a push to main, a new pull request or a finished workflow run, so a deploy pipeline or chat bot can react straight away.
This guide covers Gitea's generic webhook type: where webhooks are configured, the headers and signature on each delivery, the event list and payloads, and the best practices for receiving Gitea webhooks in production. Gitea's built-in Slack, Discord, Teams and other integrations send service-specific bodies and are not covered here.
What are Gitea webhooks?
A Gitea webhook is a target URL plus a set of events. When one of those events happens, Gitea sends an HTTP request to the URL. For the generic Gitea type, that is normally a POST with a JSON body shaped like GitHub's webhooks: an action where one applies, the repository, the sender and the object that changed.
Each delivery also carries headers that name the event and identify the attempt. If you set a secret on the webhook, X-Gitea-Signature holds the hex HMAC-SHA256 of the JSON payload, and GitHub-style X-Hub-Signature-256 carries the same digest with a sha256= prefix.
Because Gitea is self-hosted, some delivery behaviour, such as the timeout and which hosts webhooks may reach, is set by the instance administrator.
Gitea webhook features
| Feature | Details |
|---|---|
| Scopes | Repository, organisation, user and system, plus admin default webhooks copied into new repositories |
| Methods | POST (normal) or GET |
| Content types | application/json, or application/x-www-form-urlencoded with the JSON in a payload field |
| Event headers | X-Gitea-Event (group) and X-Gitea-Event-Type (exact trigger) |
| Delivery ID | X-Gitea-Delivery, a UUID per attempt |
| Signature header | X-Gitea-Signature (also X-Hub-Signature-256 with sha256=, and SHA-1 X-Hub-Signature) |
| Scheme | HMAC-SHA256, lowercase hex, no prefix |
| Signed content | The JSON payload, which is the raw body only for POST + application/json |
| Secret | Set by you per webhook; optional |
| Extra auth | Optional custom Authorization header, independent of the secret |
| Branch filter | Glob filter for ref events (create, delete, push) |
| Timeout | [webhook] DELIVER_, 5 seconds by default |
| Retries | No automatic retries documented; manual Redelivery from the webhook page |
| Testing | Test Delivery sends a synthetic push |
Common events
Gitea groups its events the same way as the webhook settings page.
| Group | Events |
|---|---|
| Repository | create, delete, fork, push, wiki, repository, release, package, status |
| Issue | issues, issue_assign, issue_label, issue_milestone, issue_comment |
| Pull request | pull_request, pull_request_assign, pull_request_label, pull_request_milestone, pull_request_comment, pull_, pull_, pull_, pull_request_sync, pull_ |
| Workflow | workflow_run, workflow_job |
The settings page also lists pull_request_review. It has no payload of its own: selecting it subscribes you to the approved, rejected and comment review events.
Most bodies carry an action, for example opened, closed, reopened, edited or deleted for issues and pull_request, assigned or unassigned for the assignment events, and queued, waiting, in_progress or completed for the workflow events. create, delete, fork and push have no action, and status reports its state in state (pending, success, error or failure).
A few payloads worth knowing:
| Event | Key fields |
|---|---|
push | ref (such as refs/heads/main), before, after, compare_url, commits, total_commits, head_commit, pusher |
pull_request | action, number, pull_request, changes (on edits) |
pull_ | action: "reviewed", review with review.type of approved |
issue_comment | action, issue, comment, is_pull (true when the comment is on a pull request) |
workflow_run | action, workflow, workflow_run |
X-Gitea-Event versus X-Gitea-Event-Type
Several subscriptions share one event group. An issue assignment, for example, arrives with:
X-Gitea-Event: issues
X-Gitea-Event-Type: issue_assign
Route on X-Gitea-Event-Type when you need the exact trigger. X-Gitea-Event is the broader group, which matches what GitHub-oriented tooling expects in X-GitHub-Event.
Setting up Gitea webhooks
- Open the repository's webhook settings at
/:username/:reponame/settings/hooks. Organisation, user and site administration settings have equivalent pages. - Add a webhook of the Gitea type.
- Enter the Target URL and keep HTTP Method as
POST. - Set POST Content Type to
application/json. Form-encoded andGETdeliveries sign the JSON payload, not the body you receive, so they fail verification. - Enter a Secret. Generate a long random value and store it server-side, for example as
GITEA_WEBHOOK_SECRET. - Optionally set an Authorization Header if your endpoint also requires application-level authentication.
- Optionally set a Branch Filter, such as
mainor{main,release/*}. - Under Trigger On, choose Push Events, All Events or a custom selection, make sure Active is checked, and save.
Open the webhook again and click Test Delivery to send a synthetic push. If the repository has no commits, Gitea generates a fake commit for the test. Recent Deliveries shows the request and response for each attempt.
If the instance administrator has restricted outgoing requests with a host allow list, your receiver's host must be on it. Ask the administrator if test deliveries fail before reaching your server.
Securing Gitea webhooks
Gitea signs the JSON payload, which is the raw request body only for a POST with application/json. Compute an HMAC-SHA256 of the raw body with your secret, encode it as lowercase hex and compare it with X-Gitea-Signature in constant time. Do this before parsing JSON.
const crypto = require('crypto');
const express = require('express');
function verifyGiteaSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/gitea', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.GITEA_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!verifyGiteaSignature(req.body, req.get('x-gitea-signature'), secret)) {
return res.sendStatus(401);
}
const eventType = req.get('x-gitea-event-type');
const deliveryId = req.get('x-gitea-delivery');
const payload = JSON.parse(req.body.toString('utf8')); // POST + application/json only; form deliveries won't verify
// Queue { eventType, deliveryId, payload } for processing.
res.sendStatus(204);
});
The same check in Python:
import hashlib
import hmac
def verify_gitea_signature(raw_body: bytes, signature_header, secret: str) -> bool:
if not signature_header or not secret:
return False
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
received = signature_header.strip().lower().encode("utf-8")
return hmac.compare_digest(received, expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_gitea_signature(raw_body, request.headers.get("x-gitea-signature"),
# os.environ["GITEA_WEBHOOK_SECRET"])
If you already have a GitHub verifier, it works against X-Hub-Signature-256, which carries the same digest as sha256=<hex>. Ignore the SHA-1 X-Hub-Signature.
Without a secret, Gitea still sends the signature headers, but with empty values. Older Gitea versions also put the secret inside the JSON body; current versions do not, so never rely on a body field for authentication. In Hookdeck, leave verification off if you didn't set a secret.
Make Gitea webhooks production-ready. Hookdeck Event Gateway verifies
X-Gitea-SignatureonPOST+application/jsondeliveries, accepts each one within Gitea's timeout, and queues it until your service has processed it.
Gitea webhook limitations and pain points
Short timeout, no documented retries
The Problem: Deliveries fail during a slow deploy and are never sent again.
Why It Happens: Gitea's default delivery timeout is 5 seconds, and its docs describe no automatic retries. A failed delivery stays in Recent Deliveries until someone redelivers it by hand.
Workarounds:
- Verify, enqueue and respond straight away.
- Check Recent Deliveries after an outage and use Redelivery for the failures.
- Self-hosted administrators can raise
DELIVER_TIMEOUT, though a faster receiver is the better fix.
How Hookdeck Can Help: Hookdeck answers Gitea immediately and stores each delivery, then retries towards your service on your schedule. An outage on your side no longer means a manual redelivery session.
Grouped event names
The Problem: Handlers fire for the wrong trigger, such as treating a label change as an edited issue.
Why It Happens: X-Gitea-Event and X-GitHub-Event carry the group (issues, pull_request), not the exact trigger.
Workarounds:
- Route on
X-Gitea-Event-Type, then on the body'saction.
How Hookdeck Can Help: Hookdeck filters can match on headers as well as the body, so each event type can go to its own destination.
Branch filter does not cover every event
The Problem: A webhook filtered to main still sends issue and release events.
Why It Happens: The branch filter applies only to events with a git ref, such as create, delete and push. Events without a ref ignore it.
Workarounds:
- Use Trigger On to subscribe only to the events you want, and check
refin your handler.
How Hookdeck Can Help: A Hookdeck filter on ref and the event type header can drop what you don't need before it reaches your service.
Duplicates from redelivery
The Problem: The same change is processed twice.
Why It Happens: Redelivery replays an earlier delivery, and X-Gitea-Delivery identifies the attempt, not the underlying event.
Workarounds:
- Make handlers idempotent using the object's identity and state, such as the push's
afterSHA or the pull request number andaction. See our guide to webhook idempotency.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeats based on the fields you choose.
Best practices
Always set a secret
A webhook without a secret sends empty signature headers and can be forged by anyone who knows the URL. Set one on every webhook and verify it on every request.
Use POST with application/json and verify the raw body
Verify the bytes before JSON.parse. A form-encoded delivery's body is payload=<url-encoded JSON>, but the signature still covers the JSON payload, so the body won't match. The same goes for GET deliveries.
Subscribe narrowly
Prefer a custom event selection over All Events, and add a branch filter for ref events. Fewer deliveries means less load and fewer chances to hit the timeout.
Acknowledge fast and process asynchronously
With a 5 second default timeout and no documented retries, slow work in the request path loses events. See why to process webhooks asynchronously.
Conclusion
Gitea's generic webhooks carry a lowercase hex HMAC-SHA256 of the JSON payload in X-Gitea-Signature, name the exact trigger in X-Gitea-Event-Type, and use GitHub-style bodies. Set a secret, verify before parsing, route on the event type header, and respond inside the 5 second default timeout, because Gitea does not document automatic retries.
Hookdeck Event Gateway sits in front of your receiver, verifies each Gitea POST + application/json delivery, and retries towards your service, so you don't have to redeliver missed events by hand.
Get started with Hookdeck for free and handle Gitea webhooks reliably in minutes.