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 (
_redirectfield) - 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,rawhcaptchaProtected- 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 JSONform- Parse as URL-encoded form data (supports_redirectfield)text- Store as plain textraw- Store raw bodymultipart- 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:
-
Token Generation: Tokens are HMAC-signed with a secret and contain:
- Route name (prevents token reuse across routes)
- Timestamp (30-second TTL)
-
Token Delivery: Tokens can be provided via:
_tokenfield in form data (extracted and not stored)X-CSRF-Tokenrequest header
-
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
Server-Side Token Generation (Recommended):
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:
- User submits form with
_redirectfield (and_tokenif required) - Posthook validates token (if required) and stores the request
- Posthook sends ntfy notification (if configured)
- Posthook responds with
303 See OtherandLocation: {redirect_url}header - 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*, onlyhttpsorigins are allowed. Responses only setAccess-Control-Allow-Origin(preflight uses the standard allow-* headers).
Environment Variables:
POSTHOOK_TOKEN_SECRET- HMAC secret for token signing (recommended for production)
