A universal webhook adapter that normalizes incoming webhook payloads into Markdown and forwards them to multiple downstream targets with platform-specific formatting.
Many monitoring and alerting tools (like GlitchTip, Sentry, etc.) only support a limited set of webhook targets such as Slack and Discord. However, teams often use different communication platforms (DingTalk, Lark, WeCom, etc.) that aren't on the supported list. This creates a gap when you need to receive alerts in your team's preferred tool.
Webhook Router solves this problem by acting as a universal adapter that:
- Accepts webhooks from multiple platforms (ingress compatibility) - supports various webhook formats including Slack, DingTalk, Lark, WeCom, and custom HTTP webhooks
- Forwards to multiple platforms (egress compatibility) - converts and delivers messages to any supported target platform
- Normalizes content to Markdown - provides a unified intermediate format for easy transformation
- Manages multiple targets - send one webhook to many destinations simultaneously
- Accepts incoming webhooks and normalizes content to Markdown
- Forwards events to multiple targets with platform-specific formatting
- Records delivery results for each target
- Provides a console UI under
/consoleand a Basic Auth protected API under/console/api - Supports custom banner/footer for message customization
| Platform | Ingress message types | Markdown normalization |
|---|---|---|
| DingTalk | text, link, markdown, actionCard (single + buttons), feedCard | Best-effort Markdown from message fields |
| Slack | text, blocks (section/header/divider/image/context/actions/rich_text), attachments, sections | Best-effort Markdown; mrkdwn preserved where possible |
| Lark | text | Uses message content text |
| WeCom | text, markdown, markdown_v2 | Uses content text |
| Custom HTTP | markdown, text (fallback to raw JSON) | Uses provided markdown/text or raw JSON |
The container expects configuration through environment variables.
Example:
docker run --rm -p 3000:3000 \
-e WEBHOOK_ROUTER_USERNAME=your_username \
-e WEBHOOK_ROUTER_PASSWORD=your_password \
-v /path/on/host:/app/data \
ghcr.io/ikkz/webhook-router:latestDocker Compose example: examples/docker-compose.example.yml
All CLI flags are also available via environment variables (useful for Docker).
--bind/WEBHOOK_ROUTER_BIND(default:0.0.0.0:3000)--db-path/WEBHOOK_ROUTER_DB_PATH(default:data/webhook_router.db)--username/WEBHOOK_ROUTER_USERNAME(required)--password/WEBHOOK_ROUTER_PASSWORD(required)--swagger-ui/WEBHOOK_ROUTER_SWAGGER_UI--generate-openapi/WEBHOOK_ROUTER_GENERATE_OPENAPI--public-ingress-base-url/WEBHOOK_ROUTER_PUBLIC_INGRESS_BASE_URL(optional, e.g.https://example.com/webhooks)
As long as you set a strong password, exposing the bind port directly to the public internet is safe enough. If you only want to expose specific webhook endpoints publicly, you can use a reverse proxy for path routing. Example Caddy config:
https://example.com {
handle_path /webhooks/* {
rewrite * /ingress{path}
reverse_proxy localhost:3000
}
handle {
abort
}
}With this setup, the console shows an ingress URL like:
http://localhost:3000/ingress/5bc06725-97e9-4cc7-92f9-9258972687cb/lark
When configuring external platforms, use:
https://example.com/webhooks/5bc06725-97e9-4cc7-92f9-9258972687cb/lark
To have the console display the reverse proxy URL, set:
WEBHOOK_ROUTER_PUBLIC_INGRESS_BASE_URL=https://example.com/webhooks
For the custom platform ingress (POST /ingress/:endpoint_id/custom), the router accepts a JSON payload with the following fields:
markdown(string, optional): The main message content in Markdown format.text(string, optional): Fallback content ifmarkdownis not provided.title(string, optional): An optional title for the message.id(string, optional): A unique identifier for the event. If not provided, a UUID will be generated.timestamp(number/string, optional): Event timestamp (Unix seconds). Defaults to current time.
If neither markdown nor text is provided, the entire raw JSON payload will be used as the message content.
apps/webhook_router: Rust backend (Axum + SQLite)apps/console: React console UIdocs/: design notes and adapter formats
- Ingress:
POST /ingress/:endpoint_id/:platform - Console UI:
GET /console - Console API (Basic Auth):
GET /console/api/...
Install dependencies:
pnpm installRun the backend (builds the console as a dependency):
pnpm exec nx run webhook_router:run:debugRun tests:
pnpm exec nx test webhook_routerRun e2e tests:
pnpm exec nx e2e console-e2e --outputStyle=staticGenerate OpenAPI + API client:
pnpm exec nx run api-client:generatedocs/tech-plan.md: architecture notesdocs/adapters/: adapter formats