# Guide to Forgejo Webhooks: Features and Best Practices

[Forgejo](https://forgejo.org) is a self-hosted Git forge, a community fork of Gitea, and the software that runs Codeberg. Like GitHub, it can call out to other systems when something happens in a repository: a push, a new pull request, an issue comment, a published release. Those webhooks drive CI that isn't Forgejo Actions, deploy pipelines, chat bots, mirrors and project tracking, whether you host your own instance or use Codeberg.

This guide covers the Forgejo webhook type and its settings, the event headers it shares with Gitea, Gogs and GitHub, the method and content-type choice that decides whether the signature can be verified, and what Forgejo leaves to the instance administrator.

## What are Forgejo webhooks?

Forgejo webhooks can be set on a repository, on an organisation, or instance-wide by an administrator. When you add one you pick a type. Besides the raw **Forgejo** type, there are Gitea, Gogs, Slack, Discord, Matrix, Telegram and others that format the message for a particular service. This guide covers the Forgejo type, which sends Forgejo's own JSON payloads.

The Forgejo type lets you choose the HTTP method (`GET` or `POST`) and, for `POST`, the content type (`application/json` or form-encoded). When the webhook has a secret, Forgejo signs the JSON payload and sends the digest in `X-Forgejo-Signature`. It also sends the same values under Gitea, Gogs and GitHub header names, so tools built for those forges often work unchanged.

Set a secret on the Forgejo webhook. Without one, deliveries are unsigned and fail verification. Set the webhook's HTTP method to POST and its content type to application/json. Form-encoded and GET deliveries sign the JSON payload rather than the request body and fail verification.

## Forgejo webhook features

| Feature | Details |
| --- | --- |
| Configuration | Repository, organisation or instance settings: **Settings > Webhooks > Add webhook > Forgejo** |
| Method and content type | `GET`, or `POST` with `application/json` or form. Use `POST` + JSON |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-forgejo-signature` |
| Compatible headers | `X-Gitea-Signature`, `X-Gogs-Signature`, `X-Hub-Signature-256` (`sha256=`), `X-Hub-Signature` (SHA-1) |
| Signed content | The JSON payload, which is the raw body only for `POST` + JSON |
| Secret | Chosen by you. Optional; without it the signature headers are empty |
| Extra auth | Optional `Authorization` header, stored encrypted |
| Body | JSON |
| Event routing | Header field `X-Forgejo-Event-Type` |
| Testing | **Test Delivery** button and **Recent Deliveries** list |
| Timeouts and retries | Instance settings, not documented in the user guide |

## Events and payloads

| Event type | Triggered When | Common Use Cases |
| --- | --- | --- |
| `push` | Commits are pushed | CI/CD, mirroring, deploys |
| `pull_request` | PR opened, closed, reopened, edited | Run checks, notify reviewers |
| `issues` | Issue opened, closed, reopened, edited | Triage, labeling |
| `issue_comment` | Comment on an issue | Bot commands |
| `pull_request_comment` | Comment on a pull request | Bot commands |
| `create` | Branch or tag created | Environment setup |
| `delete` | Branch or tag deleted | Cleanup |
| `release` | Release published, updated, deleted | Deploy, changelog |

Forgejo's user guide doesn't list event types. About 41 are defined in its source code, including `fork`, `issue_assign`, `issue_label`, `issue_milestone`, `pull_request_assign`, `pull_request_label`, `pull_request_milestone`, `pull_request_sync`, review events, `wiki`, `repository`, package and Actions events. Handle the ones you need and log the rest. Most bodies also carry an `action`, such as `opened` or `closed`.

### Event Payload Structure

| Header | Meaning |
| --- | --- |
| `X-Forgejo-Event-Type` | Exact event type, e.g. `issue_assign` |
| `X-Forgejo-Event` | Event group, e.g. `issues` (empty for package, schedule, workflow_dispatch) |
| `X-Forgejo-Delivery` | Delivery UUID |
| `X-Forgejo-Signature` | Bare hex HMAC-SHA256 (empty if no secret) |

`X-Gitea-*`, `X-Gogs-*` and `X-GitHub-*` headers repeat the same values. Pull request comments go out as `X-Forgejo-Event-Type: pull_request_comment` with `X-Forgejo-Event: issue_comment`.

### Published payload example

This is the `push` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/forgejo/latest/push.json). It is the payload Forgejo sends for a push, and the same event the **Test Delivery** button sends.

```json
{
  "ref": "refs/heads/develop",
  "before": "28e1879d029cb852e4844d9c718537df08844e03",
  "after": "bffeb74224043ba2feb48d137756c8a9331c449a",
  "compare_url": "http://localhost:3000/forgejo/webhooks/compare/28e1879d029cb852e4844d9c718537df08844e03...bffeb74224043ba2feb48d137756c8a9331c449a",
  "commits": [
    {
      "id": "bffeb74224043ba2feb48d137756c8a9331c449a",
      "message": "Webhooks Yay!",
      "url": "http://localhost:3000/forgejo/webhooks/commit/bffeb74224043ba2feb48d137756c8a9331c449a",
      "author": {
        "name": "Forgejo",
        "email": "someone@forgejo.org",
        "username": "forgejo"
      },
      "committer": {
        "name": "Forgejo",
        "email": "someone@forgejo.org",
        "username": "forgejo"
      },
      "timestamp": "2017-03-13T13:52:11-04:00"
    }
  ],
  "repository": {
    "id": 140,
    "owner": {
      "id": 1,
      "login": "forgejo",
      "full_name": "Forgejo",
      "email": "someone@forgejo.org",
      "avatar_url": "https://localhost:3000/avatars/1",
      "username": "forgejo"
    },
    "name": "webhooks",
    "full_name": "forgejo/webhooks",
    "description": "",
    "private": false,
    "fork": false,
    "html_url": "http://localhost:3000/forgejo/webhooks",
    "ssh_url": "ssh://forgejo@localhost:2222/forgejo/webhooks.git",
    "clone_url": "http://localhost:3000/forgejo/webhooks.git",
    "website": "",
    "stars_count": 0,
    "forks_count": 1,
    "watchers_count": 1,
    "open_issues_count": 7,
    "default_branch": "master",
    "created_at": "2017-02-26T04:29:06-05:00",
    "updated_at": "2017-03-13T13:51:58-04:00"
  },
  "pusher": {
    "id": 1,
    "login": "forgejo",
    "full_name": "Forgejo",
    "email": "someone@forgejo.org",
    "avatar_url": "https://localhost:3000/avatars/1",
    "username": "forgejo"
  },
  "sender": {
    "id": 1,
    "login": "forgejo",
    "full_name": "Forgejo",
    "email": "someone@forgejo.org",
    "avatar_url": "https://localhost:3000/avatars/1",
    "username": "forgejo"
  }
}

```

## Setting up Forgejo webhooks

You need admin rights on the repository or organisation, or instance admin rights for system and default webhooks.

1. Pick a long random string as the secret and store it as `WEBHOOK_SECRET`. Forgejo doesn't generate one, and it uses the value as plain UTF-8 text.
2. Open the repository (or organisation) and go to **Settings > Webhooks** (`/:username/:reponame/settings/hooks`).
3. Click **Add webhook > Forgejo**.
4. Set **Target URL** to your endpoint, for example `https://example.com/webhooks/forgejo`.
5. Set **HTTP method** to `POST` and **POST content type** to `application/json`.
6. Paste the secret into **Secret**.
7. Optionally set an **Authorization header**, which Forgejo sends with each request.
8. Choose the trigger events, or "All events", and keep **Active** checked.
9. Save, then use **Test Delivery** to send a `push` event and check **Recent Deliveries** for the response.

To receive deliveries from Codeberg or a remote instance on your machine, forward them with the [Hookdeck CLI](/docs/cli):

```bash
npx hookdeck-cli listen 3000 forgejo --path /webhooks/forgejo

```

## Securing Forgejo webhooks

Compute an HMAC-SHA256 of the raw body with the secret, encode it as hex and compare it with `X-Forgejo-Signature` in constant time. The header is bare hex; only `X-Hub-Signature-256` has a `sha256=` prefix. Forgejo's signing code is in [payloader.go](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/services/webhook/shared/payloader.go).

```javascript
const crypto = require('node:crypto');

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-forgejo-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The verifier rejects an empty header, which is what Forgejo sends when the webhook has no secret. The handler routes on `X-Forgejo-Event-Type` and keys idempotency on the delivery UUID.

```javascript
const express = require('express');

function createForgejoApp({ seenDelivery, onPush, onPullRequest, log }) {
  const app = express();
  app.post('/webhooks/forgejo', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    const eventType = req.get('x-forgejo-event-type');
    if (!eventType) return res.sendStatus(400);
    let payload;
    try {
      payload = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
      return res.sendStatus(400);
    }
    if (eventType === 'push' && (!payload.repository || typeof payload.repository !== 'object')) {
      return res.sendStatus(400);
    }
    try {
      if (await seenDelivery(req.get('x-forgejo-delivery'))) return res.sendStatus(200);
      if (eventType === 'push') await onPush(payload.repository.full_name, payload.ref, payload.after);
      else if (eventType === 'pull_request') await onPullRequest(payload.action, payload.pull_request);
      else await log(eventType, payload.action);
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

Forgejo signs only the payload. There's no timestamp header, so a replayed delivery verifies; the delivery UUID lets you detect it.

Hookdeck's Forgejo source type verifies `X-Forgejo-Signature` as a hex HMAC-SHA256 of the raw body, which matches Forgejo for `POST` + `application/json` webhooks. For a webhook without a secret, leave verification off. Hookdeck answers Forgejo with `200` in every case, records a failed check as failed verification and does not forward that request. It has no timestamp window.

> **Make Forgejo webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies every Forgejo delivery, keeps a searchable history beyond the instance's Recent Deliveries list, and retries your CI or bot when it's down.

## Forgejo webhook limitations and pain points

### GET and form deliveries can't be verified as sent

**The Problem:** A webhook with a secret set still fails every verification.

**Why It Happens:** Forgejo always signs the JSON payload. With `POST` + `application/json`, that payload is the body. With a form content type, the body is `payload=<url-encoded JSON>`, and with `GET` there's no body at all. A receiver that hashes the bytes it received can't match either.

**Workarounds:**

* Set every webhook to `POST` and `application/json`.
* If you inherited form-encoded webhooks, change them rather than reconstructing the payload.

**How Hookdeck Can Help:** Hookdeck's request log shows the method and content type of each delivery, so a misconfigured webhook is obvious when its deliveries fail verification.

### Empty signatures when there's no secret

**The Problem:** A naive verifier accepts unsigned requests.

**Why It Happens:** Without a secret, Forgejo still sends `X-Forgejo-Signature`, but empty. Code that compares an empty header with an empty expected value, or skips verification when the secret is unset, accepts anything.

**Workarounds:**

* Fail closed when the secret is missing or the header is empty, as the code above does.
* Set a secret on every webhook, including system and default webhooks.

**How Hookdeck Can Help:** With verification on, Hookdeck rejects deliveries whose signature header is empty or wrong.

### Retries and timeouts depend on the instance

**The Problem:** You don't know how long Forgejo will wait for your endpoint, or whether it will try again.

**Why It Happens:** Forgejo's user guide documents no timeout or retry policy. Those are instance settings, and on Codeberg you don't control them.

**Workarounds:**

* Answer quickly and do the work afterwards.
* Check **Recent Deliveries** after incidents to spot requests your endpoint failed.

**How Hookdeck Can Help:** Hookdeck accepts each delivery immediately and retries your service on a schedule you set, which gives you a consistent policy across self-hosted instances and Codeberg.

### Several header sets, two event levels

**The Problem:** Routing code picks the wrong event, especially for pull request comments.

**Why It Happens:** `X-Forgejo-Event` is a coarse group and `X-Forgejo-Event-Type` the exact type. Pull request comments arrive with `X-Forgejo-Event: issue_comment` and `X-Forgejo-Event-Type: pull_request_comment`. The same values repeat under Gitea, Gogs and GitHub names.

**Workarounds:**

* Route on `X-Forgejo-Event-Type`.
* Ignore the duplicated headers unless you're reusing a GitHub-compatible tool.

**How Hookdeck Can Help:** Hookdeck filters can match on `X-Forgejo-Event-Type`, sending pushes to CI and issue events to a tracker from one source.

## Best practices

### Verify the raw body

Use `express.raw({ type: '*/*' })`, `await request.arrayBuffer()` or `await request.body()`, and don't let a JSON parser run before verification. Don't hex- or base64-decode the secret.

### Deduplicate on `X-Forgejo-Delivery`

Each delivery has a UUID. Store it with the work it triggered so a repeated delivery doesn't run a deploy twice. See [implementing webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Choose events deliberately

"All events" includes high-volume types such as `pull_request_sync` and package events. Pick the triggers your consumer uses.

### Prefer repository or organisation webhooks

Instance-wide webhooks see every repository on the server. Use them only for administration tasks, and keep their secret separate.

## Conclusion

Forgejo's webhooks are close to Gitea's and GitHub's, which makes them easy to adopt, but verification only works if the webhook is set to `POST` with `application/json` and has a secret. Route on `X-Forgejo-Event-Type`, deduplicate on the delivery UUID, and don't rely on the instance to retry.

[Hookdeck Event Gateway](https://hookdeck.com) gives every Forgejo instance, including Codeberg, a verified, durable endpoint with consistent retries.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Forgejo webhooks reliably in minutes.