# 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_TIMEOUT`, 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_request_review_approved`, `pull_request_review_rejected`, `pull_request_review_comment`, `pull_request_sync`, `pull_request_review_request` |
| 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_request_review_approved` | `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

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.

```javascript
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:

```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](/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](/docs/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](/docs/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](/webhooks/guides/implement-webhook-idempotency).

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Gitea webhooks reliably in minutes.