aboutsummaryrefslogtreecommitdiff

Posthook - Dynamic Webhook Receiver

A simple API service for receiving and storing webhook requests with dynamic route registration.

Features

  • File-based route configuration with hot-reload
  • Multiple content type support (JSON, form, text, raw, multipart)
  • CSRF token protection with HMAC-signed stateless tokens
  • hCaptcha protection per route
  • ntfy notification alerts when webhooks are received
  • Form redirect support (_redirect field)
  • Request storage with timestamp-prefixed UUIDs
  • Per-topic storage organization
  • TypeScript with Pengueno framework
  • Comprehensive metrics and tracing
  • Single Dockerfile deployment

Configuration

Routes are configured via a routes.toml file that Posthook watches for changes. When you edit the file, Posthook automatically reloads the configuration.

Example Configuration

Create a routes.toml file:

# Simple JSON webhook
[[route]]
name = "github-webhook"
contentType = "json"
hcaptchaProtected = false
requireToken = false

# Form with hCaptcha protection
[[route]]
name = "contact-form"
contentType = "form"
hcaptchaProtected = true
hcaptchaSecret = "0x0000000000000000000000000000000000000000"
requireToken = false

# JSON webhook with ntfy notifications
[[route]]
name = "alerts"
contentType = "json"
hcaptchaProtected = false
requireToken = false

[route.ntfy]
enabled = true
server = "https://ntfy.sh"
topic = "my-alerts"

# Multipart file upload with token protection
[[route]]
name = "file-upload"
contentType = "multipart"
hcaptchaProtected = false
requireToken = true

Configuration Fields

  • name - Route identifier (alphanumeric, dash, underscore only)
  • contentType - One of: json, form, multipart, text, raw
  • hcaptchaProtected - Enable hCaptcha verification (default: false)
  • hcaptchaSecret - hCaptcha secret key (required if hcaptchaProtected is true)
  • requireToken - Enable CSRF token protection (default: false)
  • ntfy - Optional ntfy notification configuration:
  • enabled - Enable notifications (required)
  • server - ntfy server URL (required if enabled)
  • topic - ntfy topic name (required if enabled)

Hot Reload

Posthook watches routes.toml for changes. When you save the file: - Valid changes are applied immediately - Invalid configuration causes the process to exit (fail-fast) - No need to restart the server for route updates

API Endpoints

Get CSRF Token (for routes with requireToken: true)

GET /hook/{routeName}/token

Response:

{
    "ok": {
        "token": "eyJ0aW1lc3RhbXAiOjE3MDI4MzQ1Njc4OTB5...",
        "expiresAt": 1702835467890
    }
}

Note: Tokens expire in 30 seconds. Generate the token server-side when rendering your form, not via client-side fetch.

Send Webhook

Send webhooks to registered routes:

POST /hook/{routeName}
Content-Type: application/json

{
  "your": "data"
}

For hCaptcha-protected routes, include the token:

POST /hook/{routeName}
H-Captcha-Response: <token>
Content-Type: application/json

{
  "your": "data"
}

For form submissions with redirect (returns 303 redirect instead of JSON):

POST /hook/{routeName}
Content-Type: application/x-www-form-urlencoded

name=John&email=john@example.com&_redirect=https://example.com/thank-you

Or in HTML:

<form action="/hook/my-form" method="POST">
    <input type="text" name="name" required />
    <input type="email" name="email" required />
    <input type="hidden" name="_redirect" value="https://example.com/thank-you" />
    <button type="submit">Submit</button>
</form>

For token-protected routes, include the token:

POST /hook/{routeName}
X-CSRF-Token: <token>
Content-Type: application/json

{
  "your": "data"
}

Or in form data:

POST /hook/{routeName}
Content-Type: application/x-www-form-urlencoded

name=John&email=john@example.com&_token=<token>

Health Check

GET /health

Content Types

  • json - Parse request body as JSON
  • form - Parse as URL-encoded form data (supports _redirect field)
  • text - Store as plain text
  • raw - Store raw body
  • multipart - Multipart form data (supports file uploads and _redirect)

CSRF Token Protection

Posthook supports stateless CSRF token protection using HMAC-signed tokens. When requireToken: true is set on a route, clients must include a valid token with their request.

How it works:

  1. Token Generation: Tokens are HMAC-signed with a secret and contain:

    • Route name (prevents token reuse across routes)
    • Timestamp (30-second TTL)
  2. Token Delivery: Tokens can be provided via:

    • _token field in form data (extracted and not stored)
    • X-CSRF-Token request header
  3. Validation: Server validates:

    • HMAC signature
    • Route name matches
    • Token not expired (30 seconds)
    • Timestamp not from future

Token Secret Configuration:

# Via environment variable (recommended for production)
export POSTHOOK_TOKEN_SECRET=your-long-random-secret-here
npm start

# Via command line
npm start -- --token-secret your-long-random-secret-here

# Auto-generated (not recommended - changes on restart)
npm start  # Warns and generates random secret

With a 30-second TTL, you should generate the token server-side when rendering the form, not via client-side JavaScript.

Example with server-side rendering:

<!-- Your server fetches the token before rendering -->
<form action="/hook/contact-form" method="POST">
    <input type="hidden" name="_token" value="<%= token %>" />
    <input type="text" name="name" required />
    <input type="email" name="email" required />
    <input type="hidden" name="_redirect" value="/thanks" />
    <button type="submit">Submit</button>
</form>

Your server (Node.js example):

app.get('/contact', async (req, res) => {
    const response = await fetch('http://localhost:9000/hook/contact-form/token');
    const {
        ok: { token },
    } = await response.json();
    res.render('contact', { token });
});

Form Redirects

When using contentType: "form" or contentType: "multipart", you can include a _redirect field in your form data. If the request is successfully stored, the server will respond with a 303 See Other redirect to the specified URL instead of a JSON response.

The _redirect field is:

  • Extracted from the form data and not stored in the request body
  • Only honored on successful requests (validation failures, errors, etc. will still return JSON error responses)
  • Useful for traditional HTML form submissions where you want to redirect users after submission

Example form flow:

  1. User submits form with _redirect field (and _token if required)
  2. Posthook validates token (if required) and stores the request
  3. Posthook sends ntfy notification (if configured)
  4. Posthook responds with 303 See Other and Location: {redirect_url} header
  5. Browser automatically redirects user to the specified URL

ntfy Notifications

When you register a route with ntfy enabled, posthook will send a notification to your specified ntfy server and topic whenever a webhook is received. The notification includes:

  • Request timestamp
  • Request UUID
  • HTTP method
  • Route name

Example notification:

Title: Webhook received: my-webhook
Message: Method: POST
Timestamp: 2024-12-14T19:30:45.123Z
UUID: 123e4567-e89b-12d3-a456-426614174000

Storage Format

Requests are stored per-topic in a per-request directory: data/{routeName}/{timestamp}_{uuid}/:

  • request.json - full stored request (headers + parsed body + file metadata)
  • files/ - uploaded files (multipart only)

Example request.json:

{
    "timestamp": 1702834567890,
    "uuid": "123e4567-e89b-12d3-a456-426614174000",
    "routeName": "my-webhook",
    "method": "POST",
    "headers": {
        "content-type": "application/json"
    },
    "body": {
        "your": "data"
    },
    "files": [
        {
            "fieldName": "attachment",
            "originalFilename": "invoice.pdf",
            "filename": "0_invoice.pdf",
            "contentType": "application/pdf",
            "size": 12345,
            "path": "files/0_invoice.pdf"
        }
    ]
}

Usage

Development

npm install
npm run build
export POSTHOOK_TOKEN_SECRET=your-secret-here
npm start -- --port 9000 --host 0.0.0.0 --data-dir ./data

Docker

docker build -t posthook .
docker run -p 9000:9000 \
  -v $(pwd)/data:/app/data \
  -e POSTHOOK_TOKEN_SECRET=your-secret-here \
  posthook

With Custom Config Location

docker run -p 9000:9000 \
  -v $(pwd)/my-routes.toml:/app/routes.toml \
  -v $(pwd)/data:/app/data \
  -e POSTHOOK_TOKEN_SECRET=your-secret-here \
  posthook -- --config /app/routes.toml

Runtime Configuration

Command Line Arguments:

  • --port - Server port (default: 9000)
  • --host - Server host (default: 0.0.0.0)
  • --config - Path to routes.toml configuration file (default: ./routes.toml)
  • --data-dir - Data storage directory (default: ./data)
  • --token-secret - HMAC secret for token signing (optional, generates random if not provided)
  • --cors-origins - Allowed CORS origins (default: *). Supports *, exact origins (https://a.com), and host patterns (liz.coffee, *.liz.coffee). When not *, only https origins are allowed. Responses only set Access-Control-Allow-Origin (preflight uses the standard allow-* headers).

Environment Variables:

  • POSTHOOK_TOKEN_SECRET - HMAC secret for token signing (recommended for production)