- JavaScript 99.3%
- Dockerfile 0.7%
| .forgejo/workflows | ||
| hooks | ||
| src | ||
| test | ||
| .dockerignore | ||
| compose.yml | ||
| Dockerfile | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
Webhook Translator
A lightweight webhook receiver that dynamically loads JS hook files, transforms incoming payloads, and forwards them elsewhere.
Hooks are just .js files in a directory. Each file defines a route to listen on, a transform function for the payload, and a forward target.
Quick Start
npm start
Listens on :3000 by default and loads hooks from ./hooks/.
Environment Variables
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Server port |
HOOKS_DIR |
./hooks |
Directory to load hook files from |
Hook File Contract
Each .js file in the hooks directory must export two things:
config
export const config = {
receive: {
path: "/hooks/my-service", // URL path to listen on (required)
method: "POST", // HTTP method to match (default: POST)
},
forward: {
url: "https://example.com/api/webhook", // Target URL (required for forwarding)
method: "POST", // HTTP method for the forward request (default: POST)
as: "json", // Encoding: "json" | "form" | "query" (default: "json")
headers: {}, // Extra headers to send with the forward request
sign: undefined, // Sign outgoing payload (see Signing)
forwardHeaders: [], // Whitelist of incoming headers to forward (see Header Forwarding)
},
};
If forward is omitted, the transformed payload is returned directly in the response without being forwarded.
transform(ctx)
Called with a context object, must return a new payload or null.
export function transform(ctx) {
// ctx.payload - parsed request body (JSON or form-urlencoded)
// ctx.headers - normalized lowercase headers from the incoming request
// ctx.query - parsed query parameters from the incoming URL
// Return a new payload to forward it
return { message: ctx.payload.text };
// Return null to discard the webhook (responds 204)
// return null;
}
Forward Encoding Options
as value |
Behavior |
|---|---|
json |
Content-Type: application/json, body is JSON.stringify(payload) |
form |
Content-Type: application/x-www-form-urlencoded, payload is flattened into key-value pairs |
query |
Payload is appended as query parameters to the URL; method defaults to GET unless overridden |
Signing
Optionally sign the outgoing request body by adding a sign config to forward. The signature is computed over the serialized body and sent as a header.
forward: {
url: "https://example.com/api/webhook",
method: "POST",
as: "json",
sign: {
secret: "my-hmac-secret", // Required
// type: "hmac-sha256", // Default, currently the only option
// header: "X-Webhook-Signature", // Default header name
},
},
| Field | Default | Description |
|---|---|---|
secret |
(required) | HMAC secret key |
type |
"hmac-sha256" |
Digest algorithm (see below) |
header |
"X-Webhook-Signature" |
HTTP header to carry the signature |
The signature value is a hex-encoded HMAC digest of the outgoing request body.
Supported Digest Types
| Type | Algorithm | Output format |
|---|---|---|
hmac-sha256 |
HMAC-SHA256 | hex string |
Additional digest types can be added easily - the signer uses a strategy map internally.
Header Forwarding
By default, incoming request headers are not forwarded to the target. Use forwardHeaders to explicitly whitelist headers to pass through:
forward: {
url: "https://example.com/api/webhook",
method: "POST",
as: "json",
forwardHeaders: ["x-custom-event", "x-request-id"],
},
Matching is case-insensitive against the normalized (lowercased) incoming headers. If a whitelisted header also appears in forward.headers, the explicit value in headers takes precedence.
File Loading Rules
- Only
.jsfiles are loaded - Files starting with
_are skipped (useful for examples and drafts) - Files must export both
configandtransform- otherwise they're skipped with a warning - No subdirectory scanning, only the top-level of
HOOKS_DIRis read
HTTP Response Codes
| Code | Meaning |
|---|---|
200 |
Hook matched, transform returned a payload, forward succeeded |
204 |
Hook matched, transform returned null (webhook discarded) |
400 |
Request body could not be parsed as JSON |
404 |
No hook matches the requested path and method |
500 |
transform() threw an error |
502 |
Forward request failed (upstream returned 5xx) |
Example
// hooks/github-pr.js
export const config = {
receive: { path: "/hooks/github", method: "POST" },
forward: { url: "https://my-app.com/api/notify", method: "POST", as: "json" },
};
export function transform(ctx) {
if (ctx.payload.action !== "opened") return null;
return {
title: ctx.payload.pull_request.title,
author: ctx.payload.pull_request.user.login,
url: ctx.payload.pull_request.html_url,
};
}
Run:
curl -X POST http://localhost:3000/hooks/github \
-H "Content-Type: application/json" \
-d '{"action":"opened","pull_request":{"title":"Fix bug","user":{"login":"alice"},"html_url":"https://github.com/org/repo/pull/1"}}'
Dev Mode
npm run dev
Uses Node's --watch flag to auto-restart the server when source files change. Note: adding/removing hook files still requires a restart since hooks are loaded at startup.