A lightweight webhook receiver that dynamically loads JS transformer files, transforms incoming payloads, and forwards them elsewhere.
  • JavaScript 99.3%
  • Dockerfile 0.7%
Find a file
Leon Schmidt 8389aa4a4c
All checks were successful
Test / test (push) Successful in 8s
Release / test (push) Successful in 8s
Release / docker-build-and-push (push) Successful in 21s
Release / create-release (push) Successful in 7s
feat: implemented webhook signing and header forwarding
2026-05-20 20:46:50 +02:00
.forgejo/workflows ci: add forgejo workflows 2026-05-06 22:24:49 +02:00
hooks feat: implemented webhook signing and header forwarding 2026-05-20 20:46:50 +02:00
src feat: implemented webhook signing and header forwarding 2026-05-20 20:46:50 +02:00
test feat: implemented webhook signing and header forwarding 2026-05-20 20:46:50 +02:00
.dockerignore chore: added Docker stuff 2026-05-06 21:45:10 +02:00
compose.yml chore: added Docker stuff 2026-05-06 21:45:10 +02:00
Dockerfile chore: added Docker stuff 2026-05-06 21:45:10 +02:00
package-lock.json ci: add forgejo workflows 2026-05-06 22:24:49 +02:00
package.json test: added unit tests 2026-05-06 22:23:29 +02:00
README.md feat: implemented webhook signing and header forwarding 2026-05-20 20:46:50 +02:00

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 .js files are loaded
  • Files starting with _ are skipped (useful for examples and drafts)
  • Files must export both config and transform - otherwise they're skipped with a warning
  • No subdirectory scanning, only the top-level of HOOKS_DIR is 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.