Gareth Wilson Gareth Wilson

Guide to Gitea Webhooks: Features and Best Practices

Published


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

FeatureDetails
ScopesRepository, organisation, user and system, plus admin default webhooks copied into new repositories
MethodsPOST (normal) or GET
Content typesapplication/json, or application/x-www-form-urlencoded with the JSON in a payload field
Event headersX-Gitea-Event (group) and X-Gitea-Event-Type (exact trigger)
Delivery IDX-Gitea-Delivery, a UUID per attempt
Signature headerX-Gitea-Signature (also X-Hub-Signature-256 with sha256=, and SHA-1 X-Hub-Signature)
SchemeHMAC-SHA256, lowercase hex, no prefix
Signed contentThe JSON payload, which is the raw body only for POST + application/json
SecretSet by you per webhook; optional
Extra authOptional custom Authorization header, independent of the secret
Branch filterGlob filter for ref events (create, delete, push)
Timeout[webhook] DELIVER_TIMEOUT, 5 seconds by default
RetriesNo automatic retries documented; manual Redelivery from the webhook page
TestingTest Delivery sends a synthetic push

Common events

Gitea groups its events the same way as the webhook settings page.

GroupEvents
Repositorycreate, delete, fork, push, wiki, repository, release, package, status
Issueissues, issue_assign, issue_label, issue_milestone, issue_comment
Pull requestpull_request, pull_request_assign, pull_request_label, pull_request_milestone, pull_request_comment, pull_request_review_approved, pull_request_review_rejected, pull_request_review_comment, pull_request_sync, pull_request_review_request
Workflowworkflow_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:

EventKey fields
pushref (such as refs/heads/main), before, after, compare_url, commits, total_commits, head_commit, pusher
pull_requestaction, number, pull_request, changes (on edits)
pull_request_review_approvedaction: "reviewed", review with review.type of approved
issue_commentaction, issue, comment, is_pull (true when the comment is on a pull request)
workflow_runaction, 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

  1. Open the repository's webhook settings at /:username/:reponame/settings/hooks. Organisation, user and site administration settings have equivalent pages.
  2. Add a webhook of the Gitea type.
  3. Enter the Target URL and keep HTTP Method as POST.
  4. Set POST Content Type to application/json. Form-encoded and GET deliveries sign the JSON payload, not the body you receive, so they fail verification.
  5. Enter a Secret. Generate a long random value and store it server-side, for example as GITEA_WEBHOOK_SECRET.
  6. Optionally set an Authorization Header if your endpoint also requires application-level authentication.
  7. Optionally set a Branch Filter, such as main or {main,release/*}.
  8. 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-Signature on POST + application/json deliveries, 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's action.

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 ref in 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 after SHA or the pull request number and action. 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.