Notifications
Gitea Mirror can push an alert whenever a mirror job finishes, so you find out about a broken sync without opening the dashboard. Four providers are supported: ntfy, Apprise, Gotify, and a plain HTTP webhook. You pick one provider at a time.
All notification settings live in the web UI under Configuration > Notifications. There are no environment variables for notifications, the configuration is stored per user in the database.
Enabling notifications
- Open Configuration > Notifications.
- Turn on the Notifications switch.
- Pick a provider from the segmented control: Ntfy.sh, Apprise, Gotify, or Webhook.
- Fill in the provider fields described below.
- Click Send test to confirm the setup works.
Settings save automatically as you edit them.
Event types
The Notification Events card controls which job outcomes produce a notification.
| Event | Fires when | Default |
|---|---|---|
| Sync errors | A mirror job ends with status failed |
On |
| Sync success | A mirror job ends with status mirrored or synced |
Off |
| New repository discovered | Not yet implemented, the switch is disabled and marked SOON | Off |
Only terminal job statuses trigger notifications. Jobs that are still queued or in progress produce nothing.
Errors are always escalated by the provider that supports priorities, regardless of the default priority you configure. For ntfy that means high, for Gotify it means priority 8.
Ntfy
ntfy is a lightweight HTTP pub-sub service. Use the public server at https://ntfy.sh or run your own.
Settings
| Field | Required | Notes |
|---|---|---|
| Server URL | No | Defaults to https://ntfy.sh. Point it at your own instance if self-hosting. |
| Topic | Yes | The topic your clients subscribe to. |
| Access token | No | Needed only if your ntfy server requires authentication. Sent as Authorization: Bearer <token>. |
| Default priority | No | One of min, low, default, high, urgent. Defaults to default. |
Warning: On the public ntfy server anyone who knows your topic name can read your notifications. Pick a long, unguessable topic such as
gitea-mirror-7f3a91c2b8, or self-host with authentication.
What gets sent
Gitea Mirror sends a plain text POST to <server url>/<topic>. The message body is the notification text, and the metadata travels in headers:
POST /gitea-mirror HTTP/1.1
Host: ntfy.sh
Title: Mirror Failed: acme/website
Priority: high
Tags: warning
Authorization: Bearer tk_...
Repository acme/website failed to mirror
Details: remote: repository not found
Successful jobs use the white_check_mark tag and your configured default priority. Failures use the warning tag and high priority.
Self-hosting quick start
docker run -p 8080:80 binwiederhier/ntfy serve
Then set the Server URL to http://ntfy:8080 if Gitea Mirror runs in the same Docker network, or to the host address otherwise.
Apprise
Apprise is an aggregator that fans a single notification out to more than 100 services, including Slack, Discord, Telegram, email, and Pushover. Run the Apprise API server, configure your destinations there, and point Gitea Mirror at it.
Settings
| Field | Required | Notes |
|---|---|---|
| Server URL | Yes | Base URL of your Apprise API server, for example http://apprise:8000. |
| Token / path | Yes | The Apprise configuration key you created. |
| Tag filter | No | Restricts delivery to Apprise services carrying this tag. Leave blank to notify everything in that configuration. |
Running the Apprise API server
services:
apprise:
image: caronc/apprise:latest
ports:
- "8000:8000"
volumes:
- apprise-config:/config
volumes:
apprise-config:
Add your notification URLs through the Apprise web UI at http://localhost:8000, save them under a configuration key, then use that key as the token in Gitea Mirror.
What gets sent
A JSON POST to <server url>/notify/<token>:
{
"title": "Mirror Success: acme/website",
"body": "Repository acme/website mirrored successfully",
"type": "success",
"tag": "homelab"
}
The type field is failure for sync errors and success otherwise, which is what Apprise uses to colour the message. The tag field is omitted when you leave the tag filter empty.
Gotify
Gotify is a self-hosted push server with its own Android client. Create an application inside Gotify, then paste its token here.
Settings
| Field | Required | Notes |
|---|---|---|
| Server URL | Yes | Base URL of your Gotify server. |
| Application token | Yes | Created under Apps in the Gotify UI. Sent as the X-Gotify-Key header. |
| Default priority | No | Integer from 0 to 10, defaults to 5. Values outside that range are clamped. |
Note: Use an application token, not a client token. Client tokens can read messages but cannot post them, and Gotify will reject the request.
What gets sent
A JSON POST to <server url>/message:
{
"title": "Mirror Failed: acme/website",
"message": "Repository acme/website failed to mirror\nDetails: remote: repository not found",
"priority": 8
}
Failures always use priority 8. Everything else uses your configured default. Gotify’s Android client only vibrates and pops a heads-up notification above priority 4, which is why the error priority is fixed higher than the default.
Webhook
The webhook provider posts JSON to any URL you control. Use it to drive a chat bot, an incident tool, a home automation flow, or anything else that can accept an HTTP request.
Settings
| Field | Required | Notes |
|---|---|---|
| Webhook URL | Yes | The full URL to POST to. |
| Signing secret | No | When set, requests carry an HMAC signature header so your receiver can verify authenticity. |
Payload
Every notification is a POST with Content-Type: application/json and this body:
{
"title": "Mirror Failed: acme/website",
"message": "Repository acme/website failed to mirror\nDetails: remote: repository not found",
"type": "sync_error",
"timestamp": "2026-08-03T14:22:31.004Z"
}
| Field | Type | Description |
|---|---|---|
title |
string | Short summary line, includes the repository or organization name. |
message |
string | Full text, may contain newlines and a Details: section. |
type |
string | sync_error or sync_success. |
timestamp |
string | ISO 8601 timestamp generated when the request is built. |
Verifying the signature
If you set a signing secret, Gitea Mirror adds a header:
X-Webhook-Signature: sha256=<hex digest>
The digest is an HMAC-SHA256 of the exact request body, keyed with your secret, hex encoded and prefixed with sha256=.
Warning: Verify against the raw request body, not a re-serialized object. Most JSON frameworks reorder keys or change whitespace when they parse and re-encode, which produces a different digest and makes every request look invalid.
Node with Express:
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const secret = process.env.WEBHOOK_SECRET;
const app = express();
// express.raw keeps the untouched bytes so the digest matches
app.post("/hooks/gitea-mirror", express.raw({ type: "application/json" }), (req, res) => {
const received = req.get("X-Webhook-Signature") ?? "";
const expected = "sha256=" + createHmac("sha256", secret).update(req.body).digest("hex");
const a = Buffer.from(received);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
console.log(event.type, event.title, event.timestamp);
res.sendStatus(204);
});
app.listen(3000);
Python with Flask:
import hashlib
import hmac
import os
from flask import Flask, abort, request
SECRET = os.environ["WEBHOOK_SECRET"].encode()
app = Flask(__name__)
@app.post("/hooks/gitea-mirror")
def receive():
raw = request.get_data() # bytes, exactly as received
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
received = request.headers.get("X-Webhook-Signature", "")
if not hmac.compare_digest(expected, received):
abort(401)
event = request.get_json()
print(event["type"], event["title"], event["timestamp"])
return "", 204
Both examples use a constant time comparison. A plain == leaks timing information that can help an attacker forge a signature byte by byte.
Testing your configuration
The Send test button appears in the Notifications card footer once notifications are enabled. It sends a message titled “Gitea Mirror - Test Notification” through the provider currently shown in the form, using the values on screen rather than the last saved ones. The test is typed as a success event, so it uses your default priority rather than the escalated error priority.
A toast reports the outcome. Failures show a sanitized reason such as “Ntfy topic is required” or an upstream status like unauthorized or not found. Detailed errors go to the server log under the [NotificationService] prefix.
Security
Notification credentials are encrypted at rest with AES-256-GCM, the same scheme used for GitHub and Gitea tokens. This covers the ntfy access token, the Apprise token, the Gotify application token, and the webhook signing secret.
Only the active provider’s credential is decrypted when a notification fires. A stale or corrupted credential left behind on a provider you no longer use will not break delivery through the provider you do use.
Notification delivery never breaks a mirror job. Failures are caught and logged, and the job result is unaffected.
Troubleshooting
Nothing arrives at all. Check that the Notifications switch is on and that the event type you expect is enabled. Sync success is off by default, so a healthy instance is silent unless you turn it on.
The test works but real jobs are quiet. The test always sends, real notifications are filtered by the event switches. Confirm that Sync errors or Sync success matches the job outcome you are waiting on.
ntfy returns 401 or 403. Your server requires authentication. Add an access token, and make sure it grants publish rights on that specific topic.
Apprise connection refused. Confirm the Apprise container is reachable from the Gitea Mirror container. Inside Docker they need to share a network, and the URL should use the service name rather than localhost.
Gotify returns 401. The token is either a client token instead of an application token, or it belongs to a deleted application.
Webhook signatures never match. Your receiver is almost certainly hashing a re-serialized body. Capture the raw bytes before any JSON parsing happens.
For anything else, check the server logs for lines beginning with [NotificationService].
