Gitea's X-Gitea-Event only gives the coarse event; the precise "assigned" sub-event lives in X-Gitea-Event-Type (issue_assign, pull_request_assign). Read that header and check the assignees list against CLAUDE_GITEA_USERNAME so unrelated assignment changes get a 200 "ignored" instead of triggering a routine fire. Document the known Gitea limitation where it can't tell us who was just added to assignees, only the full current list. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Gitea-Claude-Webhook-Proxy
Node.js/Express proxy that receives Gitea webhooks and forwards them to the
Claude Code Routines API, adding the headers Gitea can't be configured to
send itself (Authorization: Bearer <token>, anthropic-beta,
anthropic-version).
How it works
- Gitea sends a
POSTto/gitea-webhookwith:- Header
Authorization: <secret-A>(configured by hand in Gitea). - Header
X-Gitea-Signature: HMAC-SHA256 of the raw body using the webhook'sSecret.
- Header
- The proxy validates both (constant-time comparison) and returns 401 if either one doesn't match.
- It deduplicates by
X-Gitea-Delivery(configurable TTL) to avoid reprocessing Gitea retries. - It builds a readable
textfrom the payload (push,pull_request; any other event falls back to a generic message) and responds200to Gitea immediately. - In the background, it makes a
POSTtohttps://api.anthropic.com/v1/claude_code/routines/{ROUTINE_ID}/firewith thattextand logs the outcome (success, 401/429/5xx, or a network failure) without crashing the process.
Issue/PR assignment to Claude
Gitea's coarse X-Gitea-Event header (issues, pull_request) doesn't say
what changed — the precise sub-event is in X-Gitea-Event-Type. The proxy
reads that header to detect issue_assign and pull_request_assign
deliveries and only forwards them when the assignees list actually includes
CLAUDE_GITEA_USERNAME; any other assignment change gets a 200 {"status": "ignored"} without hitting the routine.
Known limitation: Gitea (tested on 1.25.4) doesn't tell you who was
just added to assignees, only the full current list. If Claude was already
assigned and someone else gets added too, this still counts as "assigned to
Claude" and fires again — telling the two apart would require comparing
against the previous assignees, which Gitea doesn't send.
Environment variables
See .env.example. Copy it and fill in the real values
(never committed — .env is in .gitignore):
cp .env.example .env
| Variable | Description |
|---|---|
PORT |
Port the proxy listens on (defaults to 3000). |
GITEA_AUTH_HEADER |
Expected value of the Authorization header Gitea sends. |
GITEA_WEBHOOK_SECRET |
Gitea webhook secret, used to verify X-Gitea-Signature. |
ROUTINE_ID |
Claude Code routine ID (trig_...). |
ROUTINE_TOKEN |
Routine token (sk-ant-oat01-...), sent as Bearer. |
DEDUP_TTL_MS |
TTL in ms for the in-memory delivery dedup (defaults to 300000 = 5 min). |
FIRE_TIMEOUT_MS |
Timeout in ms for the call to Anthropic's API (defaults to 10000). |
CLAUDE_GITEA_USERNAME |
Gitea username to match on issue/PR assignment events (defaults to claude). |
Running locally
npm install
cp .env.example .env # then fill in the real values
node server.js
Health check endpoint: GET /health → {"status":"ok"}.
Configuring the webhook in Gitea
On the repo/organization → Settings → Webhooks → Add webhook:
- URL:
https://your-domain/gitea-webhook - Content type:
application/json - Secret: same value as
GITEA_WEBHOOK_SECRET - Authorization header: same value as
GITEA_AUTH_HEADER - Events:
Push,Issues(at least "Assigned"), and/orPull Request(at least "Assigned") — any other event is forwarded with a generic message
Docker
docker build -t gitea-claude-webhook-proxy .
docker run -d --name gitea-claude-webhook-proxy \
--env-file .env \
-p 3000:3000 \
gitea-claude-webhook-proxy
Meant to sit behind an existing nginx doing proxy_pass to
http://127.0.0.1:3000/gitea-webhook.