webhook-over-websocket

日本語
A tunnel tool that forwards external webhook requests to a local development server via WebSocket.
Overview
webhook-over-websocket allows you to receive webhooks from external services (e.g. GitHub, Stripe, Slack) on your local development machine without exposing it to the internet directly. It works by establishing a persistent WebSocket connection between a publicly accessible server and the client running locally.
flowchart TD
A[External Service] -- HTTP --> B["Server<br>/webhook/{channel_id}"]
B <-->|WebSocket| C["Client<br>(local machine)"]
C -- HTTP --> D["Local application<br>(e.g. http://localhost:3000)"]
Architecture
| Component |
Role |
| Server |
Publicly accessible HTTP server. Receives webhooks and forwards them over WebSocket to the connected client. Also exposes a Traefik HTTP Provider endpoint for dynamic routing when running at scale. |
| Client |
Runs on the local machine. Connects to the server via WebSocket, receives webhook payloads, and forwards them to the local application. |
Server Endpoints
| Endpoint |
Description |
GET /new |
Issues a new channel_id (UUID) for a client to use |
GET /traefik-config |
Returns dynamic Traefik routing configuration (HTTP Provider) |
GET /internal/channels |
Returns active channel list (used for peer-to-peer sync in multi-replica deployments) |
GET /ws/{channel_id} |
WebSocket upgrade endpoint for client connections |
POST /webhook/{channel_id}[/...] |
Receives external webhook requests and tunnels them to the client |
GET /auth/login |
(auth mode only) Redirects to the GitHub OAuth consent page |
GET /auth/callback |
(auth mode only) GitHub OAuth callback; returns a session JWT |
Installation
Docker
docker pull ghcr.io/nonchan7720/webhook-over-websocket:latest
Go install
go install github.com/nonchan7720/webhook-over-websocket@latest
Binary download
Download the latest binary for your platform from the Releases page.
Usage
1. Start the server
Run the server on a publicly accessible host:
webhook-over-websocket server --port 8080
Or with Docker:
docker run --rm -p 8080:8080 ghcr.io/nonchan7720/webhook-over-websocket:latest server --port 8080
Server flags:
| Flag |
Default |
Description |
--port, -p |
8080 |
Port to listen on |
--peer-domain |
(empty) |
Peer domain name for memberlist cluster discovery |
--cleanup-duration |
5m |
Interval for cleaning up inactive channel sessions |
--memberlist-port |
7946 |
Port for memberlist gossip protocol |
--memberlist-sync-duration |
5s |
Interval for memberlist cluster synchronization |
--github-client-id |
(empty) |
GitHub OAuth App client ID — enables authentication when set |
--github-client-secret |
(empty) |
GitHub OAuth App client secret (required when --github-client-id is set) |
--github-org |
(empty) |
Required GitHub organization — only members are allowed access |
--jwt-signing-key |
(empty) |
Secret key for signing JWT tokens (required when --github-client-id is set) |
2. Start the client
Run the client on your local machine, pointing it at the server and your local application:
webhook-over-websocket client \
--server-url http://your-server.example.com \
--target-url http://localhost:3000
On startup, the client prints the webhook URL to configure in the external service:
Issued Channel ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Please set the webhook destination as follows: http://your-server.example.com/webhook/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
A tunnel to the server has been established.
Client flags:
| Flag |
Default |
Description |
--server-url |
(required) |
URL of the webhook-over-websocket server |
--target-url |
http://localhost:3000 |
URL of the local application to forward webhook requests to |
--token |
(empty) |
Session JWT token for authentication (required when server auth is enabled) |
--insecure |
false |
Skip TLS certificate verification |
Set the webhook URL in the external service (e.g. GitHub, Stripe) to:
http://your-server.example.com/webhook/<channel_id>
Any path suffix after the channel ID is preserved and forwarded to your local application as-is.
Authentication
When the server is configured with a GitHub OAuth App, only authenticated users can obtain channel_ids and connect via WebSocket. Two levels of access control are supported:
- Organization check – only members of a specific GitHub organization may authenticate (enabled via
--github-org).
- Per-channel token – every
channel_id is bound to a signed JWT whose sub (subject) claim equals the channel_id, so a token issued for one channel cannot be used for another.
Setup
- Create a GitHub OAuth App and set the callback URL to
http://your-server.example.com/auth/callback.
- Start the server with the auth flags:
webhook-over-websocket server \
--port 8080 \
--github-client-id <YOUR_CLIENT_ID> \
--github-client-secret <YOUR_CLIENT_SECRET> \
--github-org my-organization \
--jwt-secret <A_LONG_RANDOM_STRING>
New server endpoints (auth mode only)
| Endpoint |
Description |
GET /auth/github |
Redirects the user to the GitHub OAuth consent page |
GET /auth/callback |
GitHub calls this after the user approves; returns the session JWT |
| Variable |
Description |
POD_IP |
Pod IP address used as the server's own IP (Kubernetes). When set to a valid IPv4 address, it is used instead of auto-detection. |
Clustering and High Availability
Traefik Integration with Memberlist
For production deployments with multiple server replicas (e.g. in Kubernetes), Traefik is used as a load balancer with dynamic routing so that webhook requests are always forwarded to the replica that holds the correct WebSocket connection.
Challenge: Traefik's HTTP Provider can only poll a single endpoint URL for configuration updates. In a multi-replica deployment, this creates a problem: how can a single endpoint return routing information for channels connected to different replicas?
Solution: HashiCorp Memberlist enables cluster coordination via a gossip-based membership protocol. When Traefik polls any single replica's /traefik-config endpoint, that replica automatically aggregates channel information from all cluster members and returns the complete routing configuration.
How it works:
- Each server instance joins the memberlist cluster using the
--peer-domain flag for DNS-based peer discovery
- Servers periodically exchange information about their active channels via the gossip protocol
- When Traefik polls
/traefik-config on any replica, that replica:
- Collects its own active channels
- Queries all other alive cluster members via
/internal/channels
- Aggregates all channel information and generates the complete Traefik routing configuration
- Inactive or failed nodes are automatically detected and removed from the cluster
Configuration example:
Server:
webhook-over-websocket server \
--port 8080 \
--peer-domain webhook-service.default.svc.cluster.local \
--memberlist-port 7946 \
--memberlist-sync-duration 5s
Traefik static configuration:
providers:
http:
endpoint: "http://webhook-over-websocket-service/traefik-config"
pollInterval: "5s"
With this setup, Traefik can query any single replica (via the Kubernetes service), and that replica will return routing information for all channels across the entire cluster.
Development
The repository includes a Docker Compose file for local development:
docker compose up -d
This mounts the repository source into the container so you can edit files locally.