Webhooks
fremforge delivers webhooks compatible with both the Forgejo-native signature scheme and the GitHub-compatible scheme on the same delivery. Receivers built for either work without modification. Pick the header you trust and verify against your shared secret.
This page documents the contract: what fremforge sends, how to verify it, and what guarantees you get on retries and TLS.
Where to configure what: webhook creation (which events to subscribe to, target URL, signing secret) lives in Forgejo native UI at /<org>/-/settings/hooks (org-level, fires for every repo in the org) or /<org>/<repo>/settings/hooks (repo-level, scoped to one repo). Webhook delivery history and redelivery is in fremforge admin at /<org>/_admin/webhooks. fremforge owns the fanin and the dashboard; Forgejo owns the registration UI.
Quick reference
POST <your-webhook-url> HTTP/1.1
Host: receiver.example.com
User-Agent: fremforge-webhook/1
Content-Type: application/json
X-Forgejo-Event: push
X-Gitea-Delivery: 7c4e2ecd-79b5-4e1d-8eaa-0d1c2c8e5fbd
X-Forgejo-Signature: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
X-Hub-Signature-256: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
X-Hub-Signature: sha1=2fd4e1c67a2d28fced849ee1bb76e7391b93eb12
{<your event payload as JSON>}| Header | Meaning |
|---|---|
X-Forgejo-Event | Event type (push, pull_request, issues, release, etc.) |
X-Gitea-Delivery | Unique UUID per delivery attempt, use for replay protection |
X-Forgejo-Signature | HMAC-SHA256 of the raw body, hex-encoded, Forgejo-native |
X-Hub-Signature-256 | HMAC-SHA256 of the raw body, hex-encoded with sha256= prefix, GitHub-compatible |
X-Hub-Signature | HMAC-SHA1 of the raw body, legacy GitHub compatibility, do not rely on it |
The signing key is the secret you set on the webhook in the org admin UI. Empty secret = no signature emitted; the X-*-Signature headers are absent. The fremforge platform-default policy refuses webhook configuration without a secret on new orgs (Phase 1.5+).
Why two signature headers
Receivers built for GitHub generally check X-Hub-Signature-256. Receivers built natively for Forgejo / Gitea check X-Forgejo-Signature. Emitting both on the same delivery means migration from GitHub doesn’t require touching receiver code; new integrations can pick whichever shape feels more native.
The two headers carry the exact same HMAC-SHA256 value computed over the same raw body. Verify either; both succeeding means the request is authentic.
Verifying a webhook, recipes
All recipes below verify the GitHub-compatible X-Hub-Signature-256 header. Adapt to X-Forgejo-Signature by dropping the sha256= prefix-strip step.
Node.js
import crypto from 'node:crypto';
function verifyFremforgeWebhook(rawBody, signatureHeader, secret) {
if (!signatureHeader || !signatureHeader.startsWith('sha256=')) return false;
const provided = Buffer.from(signatureHeader.slice('sha256='.length), 'hex');
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
return provided.length === expected.length &&
crypto.timingSafeEqual(provided, expected);
}
// In an Express / Hono / Fastify route:
// const isAuthentic = verifyFremforgeWebhook(
// req.rawBody, // Buffer or string of the EXACT bytes received
// req.headers['x-hub-signature-256'],
// process.env.FREMFORGE_WEBHOOK_SECRET,
// );
The rawBody MUST be the exact bytes received before any JSON parsing. Middleware that re-serializes will produce a different hash and a false negative.
Python
import hmac
import hashlib
def verify_fremforge_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
if not signature_header or not signature_header.startswith("sha256="):
return False
provided = signature_header[len("sha256="):]
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(provided, expected)
# In a Flask / FastAPI handler:
# raw = request.get_data() # Flask: raw bytes; do NOT parse JSON first
# sig = request.headers.get("X-Hub-Signature-256", "")
# if not verify_fremforge_webhook(raw, sig, os.environ["FREMFORGE_WEBHOOK_SECRET"]):
# abort(401)Go
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
func VerifyFremforge(rawBody []byte, signatureHeader, secret string) bool {
if !strings.HasPrefix(signatureHeader, "sha256=") {
return false
}
provided, err := hex.DecodeString(strings.TrimPrefix(signatureHeader, "sha256="))
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := mac.Sum(nil)
return hmac.Equal(provided, expected)
}Ruby
require 'openssl'
def verify_fremforge_webhook(raw_body, signature_header, secret)
return false unless signature_header&.start_with?('sha256=')
expected = OpenSSL::HMAC.hexdigest('sha256', secret, raw_body)
Rack::Utils.secure_compare(signature_header.sub('sha256=', ''), expected)
endRust
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
pub fn verify_fremforge_webhook(
raw_body: &[u8],
signature_header: &str,
secret: &[u8],
) -> bool {
let Some(hex_sig) = signature_header.strip_prefix("sha256=") else {
return false;
};
let Ok(provided) = hex::decode(hex_sig) else {
return false;
};
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC key");
mac.update(raw_body);
let expected = mac.finalize().into_bytes();
expected.as_slice().ct_eq(&provided).into()
}Replay protection
The HMAC signature proves authenticity (the payload came from fremforge with knowledge of your shared secret) but not freshness. An attacker who captures a valid signed webhook can replay it indefinitely until the secret rotates.
Use the X-Gitea-Delivery UUID for replay defence:
- Maintain a small cache (Redis, memory, even SQLite) of recent delivery IDs.
- Reject any incoming webhook whose delivery ID is already in the cache.
- TTL the cache at 24 hours, fremforge retries within that window; older replays are infeasible against the secret rotation cadence.
Skip replay defence only if your handler is fully idempotent (same delivery applied twice produces the same end state). Idempotent handlers are the cleanest solution; replay-cache is the fallback when the handler isn’t.
TLS requirement
fremforge only delivers webhooks over HTTPS. HTTP receiver URLs are refused at webhook configuration time. The receiver must:
- Serve a valid TLS certificate for the configured hostname (any public CA, Let’s Encrypt, ZeroSSL, ACM, commercial, works).
- Support TLS 1.2 or higher.
- Not rely on SNI tricks that only work for browsers, fremforge’s outbound TLS is HTTP-client-shaped, not browser-shaped.
Self-signed certificates are refused. If you’re testing locally, use a tunnel (ngrok, Cloudflare Tunnel, Bunny Origin Shield) with a real cert instead.
Retry behaviour
fremforge retries on 5xx responses and connection failures (timeout, DNS error, TLS handshake failure). It does not retry on:
- 2xx, delivered successfully.
- 3xx, Forgejo follows redirects up to 3 hops; further 3xx is treated as success without retry.
- 4xx, receiver-side error; retrying won’t help. Configure your receiver to return 200 even for “I don’t care about this event” cases; reserve 4xx for malformed payloads only.
Retry schedule: exponential backoff starting at 30 seconds, capped at 60 minutes between attempts, total retry window of 24 hours. After 24 hours, the delivery is marked failed in the org admin UI’s webhook delivery log and not retried further.
To replay a failed delivery manually, use the redeliver button in the org admin UI’s webhook delivery log, or hit the API endpoint POST /api/v1/repos/{owner}/{repo}/hooks/{hook_id}/redelivery/{delivery_id}.
Configuring a webhook
Webhook configuration lives in two places depending on what you’re doing:
- Outbound fan-out from fremforge to your endpoint: tenant admin, Integration, Webhooks (
https://frem.sh/<your-org>/_admin/webhooks). Add the destination, signing secret, event filter; review delivery health and the DLQ here. - Per-repo or per-org Forgejo-native hooks: Forgejo’s own UI at
https://frem.sh/<your-org>/-/settings/hooks(org-level) orhttps://frem.sh/<your-org>/<repo>/settings/hooks(repo-level). These are the legacy Forgejo hooks. fremforge’s instance-wide system hook captures the same events centrally, so most customers no longer need to wire per-repo hooks.
To add a fremforge-managed outbound destination:
- Tenant admin, Integration, Webhooks, Add destination.
- Target URL, must be
https://. - Content type,
application/jsonrecommended. - Secret, generate a high-entropy value (≥ 32 bytes); store it in your receiver’s secret manager. Don’t reuse a secret across webhooks. Different receivers should use different secrets.
- Events, pick which events fire the webhook.
- Save.
The webhook secret is stored encrypted at rest in fremforge’s database. fremforge never echoes it back via the API after creation; rotate by editing the webhook and entering a new value (the old value is replaced atomically).
Troubleshooting
Signature mismatches
Most signature mismatches come from body re-serialization. Common offenders:
- Express’s
body-parserJSON middleware running BEFORE your verifier, your verifier sees a re-serialized payload, not the bytes fremforge sent. Solution: capture the raw body before parsing (express.raw()then JSON-parse manually). - Frameworks that “helpfully” pretty-print or canonicalize JSON before exposing it, same fix.
- Encoding differences, fremforge sends UTF-8; receivers that interpret as Latin-1 will fail.
TLS errors in the delivery log
unable to get local issuer certificate, your TLS chain is incomplete; serve the intermediate certs alongside the leaf.certificate has expired, renew, then click redeliver.tls: handshake failure, TLS version mismatch or cipher mismatch; fremforge’s outbound supports modern cipher suites only.
Replays / duplicate side effects
If your receiver isn’t idempotent and isn’t tracking X-Gitea-Delivery, brief duplicates can occur during retry (e.g. fremforge re-delivers a webhook because your 200 response was lost in transit). Fix by adding the replay cache described above.
Source
The signing implementation is upstream Forgejo’s; fremforge does not patch it. See Forgejo’s webhook documentation for additional event-shape details. fremforge’s role is configuring high-entropy secrets by default and surfacing the verification recipes documented here.
For inbound webhook security (Mollie payments → fremforge), see the security model. That uses a different shape: URL-as-secret pattern.