A self-hosted webhook relay service built with Rails 8. Receives webhooks, filters them based on configurable rules, and dispatches to multiple target endpoints.
# Clone and install
git clone https://github.com/mensfeld/hookshot.git
cd hookshot
bundle install
npm install
# Setup database and assets
rails db:create db:migrate
rails tailwindcss:build
# Start the web server (port 3000) and the Tailwind CSS watcher;
# enqueued jobs run inside the web server, recurring tasks do not (see Background Jobs)
./bin/devThen:
- Visit
http://localhost:3000/admin/targets(login:admin/changeme) - Create a target with your destination URL
- Send webhooks to
http://localhost:3000/webhooks/receive
- Webhook Reception: Accepts POST requests at
/webhooks/receiveand stores headers, payload, and metadata - Endpoints: Named inbound URLs (
/webhooks/receive/<slug>) that each forward only to their own targets - Signature Verification: Pluggable per-endpoint providers (GitHub built in) reject forged webhooks
- Multiple Targets: Configure multiple destination URLs for webhook delivery
- Filtering: Route webhooks to specific targets based on header or payload content
- Background Processing: Reliable delivery with Solid Queue, including retries with exponential backoff
- Admin Dashboard: View webhooks, dispatches, and manage targets with a clean DaisyUI interface
- MCP Server: Run admin actions from an MCP client (e.g. Claude Code) at
/mcp - Replay: Re-dispatch any webhook to the active targets of the endpoint that received it
- Health Check:
/healthendpoint for monitoring
- Ruby 4.0 (
.ruby-versionand CI use 4.0.7) - SQLite 3
- Node.js (for Tailwind CSS compilation)
# Install dependencies
bundle install
npm install
# Setup database
rails db:create db:migrate
# Compile assets
rails tailwindcss:build
# Start the server
rails server| Variable | Default | Description |
|---|---|---|
TZ |
UTC |
Timezone for displaying timestamps (e.g., Europe/Warsaw, America/New_York) |
HOOKSHOT_USER |
admin |
HTTP Basic Auth username for admin UI |
HOOKSHOT_PASSWORD |
changeme |
HTTP Basic Auth password for admin UI |
RETENTION_DAYS |
30 |
Days to retain webhook data before cleanup |
MAX_PAYLOAD_SIZE |
1048576 |
Maximum webhook body size in bytes; larger bodies are rejected with 413 |
JOB_CONCURRENCY |
1 |
Number of Solid Queue worker processes for the default queue, which delivers webhooks |
RETENTION_DAYS, MAX_PAYLOAD_SIZE and JOB_CONCURRENCY must be positive integers (digits only; underscores such as 1_000_000 are allowed). Hookshot checks them at boot and refuses to start, with an error naming the variable and its value, when one is set to anything else (empty, 0, negative, non-numeric or with a unit such as 10MB). An unset variable keeps its default.
In development, enqueued jobs (e.g., webhook deliveries) run inside the Rails server process (Active Job's default
async adapter), so ./bin/dev or rails server runs them.
The recurring tasks in config/recurring.yml (retries of failed deliveries every 5 minutes, and the daily cleanup of
old webhooks) run only under Solid Queue, so they do not run in development.
In production, jobs run on Solid Queue. Start it next to the web server:
RAILS_ENV=production rails solid_queue:startOr set SOLID_QUEUE_IN_PUMA=1 to run Solid Queue inside the Puma web server. The Docker image starts both with
bin/start.
Send any POST request to /webhooks/receive:
curl -X POST http://localhost:3000/webhooks/receive \
-H "Content-Type: application/json" \
-d '{"event": "user.created", "data": {"id": 123}}'Access the admin UI at http://localhost:3000/admin/webhooks with HTTP Basic Auth.
- Webhooks: View received webhooks (filterable by endpoint), inspect headers/payload, replay to targets
- Dispatches: Monitor delivery status, retry failed deliveries one by one, in bulk by selecting rows, or all that match the current filters at once
- Endpoints: Configure the inbound URLs and which targets each one forwards to
- Targets: Configure destination URLs with filters
- Jobs: Mission Control jobs dashboard at
/jobs; it shows Solid Queue's queues and jobs in production, and no queues or jobs in development (asyncadapter)
Hookshot is an MCP server at /mcp (Streamable HTTP transport, stateless, no
streaming), so an AI assistant can run admin actions. It uses the same HTTP Basic Auth as the admin UI
(HOOKSHOT_USER / HOOKSHOT_PASSWORD); there is nothing else to configure.
Connect Claude Code:
claude mcp add --transport http hookshot https://hookshot.example.com/mcp \
--header "Authorization: Basic $(printf '%s' 'admin:changeme' | base64)"| Tool | What it does |
|---|---|
list_endpoints |
List endpoints with their attached target ids and signature verification state |
list_verification_providers |
List the registered signature verification providers |
set_endpoint_verification |
Set an endpoint's verification provider and secrets, or turn verification off |
list_targets |
List targets with their endpoints and 24h success rate (custom headers are not shown) |
set_target_active |
Activate or deactivate a target |
list_webhooks |
List webhooks, newest first, by endpoint and search text |
get_webhook |
Show a webhook with headers, payload (first 10 KB) and deliveries |
replay_webhook |
Replay a webhook to the active targets of its endpoint |
list_dispatches |
List deliveries by status and target |
retry_dispatch |
Queue a failed delivery for retry |
list_webhooks and list_dispatches return 20 records by default and at most 100 (limit); list_endpoints and
list_targets return every record. Requests with an Origin header naming another origin, or an unsupported
MCP-Protocol-Version header, are rejected.
The MCP tools never return signing secrets: list_endpoints and set_endpoint_verification show the number of
secrets, the provider, and the rejected-request count and time. set_endpoint_verification takes the endpoint id, a
registered provider key (or null to turn verification off and remove the secrets) and optional secrets, an
array that replaces the current secrets; without secrets the current ones are kept.
Each target has:
- Name: Identifier for the target
- URL: Destination endpoint (an
httporhttpsURL) - Timeout: Request timeout in seconds (1-300)
- Active: Toggle to enable/disable delivery
- Custom Headers: Additional headers to send with each request
- Endpoints: The endpoints it receives webhooks from (a target without endpoints receives nothing)
- Filters: Rules to determine which webhooks to deliver
An endpoint is a named URL webhooks are posted to. Each endpoint forwards what it receives only to the targets attached to it, and each target's filters still apply. This lets one sending app route actions that carry identical payloads by posting them to different URLs:
| Sender posts to | Endpoint | Forwards to |
|---|---|---|
https://hookshot.example.com/webhooks/receive/deploys |
Deploys | Deploy bot, Audit log |
https://hookshot.example.com/webhooks/receive/tracker |
Tracker | Issue tracker, Audit log |
https://hookshot.example.com/webhooks/receive |
Default | targets on the default endpoint |
- Default endpoint: the plain
/webhooks/receiveURL belongs to the built-indefaultendpoint (also reachable as/webhooks/receive/default). Upgrading attaches every existing target to it, so existing senders keep working unchanged. It can be renamed or deactivated, but not deleted. - Slugs: lowercase letters, digits and dashes, derived from the name when left blank. They're matched the same
way, so
/webhooks/receive/Deploysreachesdeploys. - Unknown or inactive endpoints: they answer
404 {"error":"Unknown endpoint"}and nothing is stored. - Endpoints without targets: webhooks are stored but not forwarded.
- Many-to-many: a target can be attached to several endpoints, and an endpoint can have several targets.
- Replay: sends a webhook to the current targets of the endpoint that received it.
Each endpoint can check the signature senders put on their webhooks. Pick a provider under Signature verification in the endpoint form and enter the signing secret. An endpoint without a provider accepts every request, as before. Hookshot ships the GitHub provider; you can add your own (see Custom providers).
- Rejected requests: a missing or wrong signature answers
401 {"error":"Invalid signature"}. Nothing is stored or forwarded. The endpoint page and list show how many requests were rejected and when the last one was. - Raw body: providers check the body bytes exactly as received.
- Secret rotation: enter one secret per line; spaces around each line are removed. A request signed with any of them is accepted, so add the new secret, switch the sender over, then remove the old one.
- Turning it off: choosing "None" removes the endpoint's secrets.
- Removed provider: an endpoint whose provider is no longer registered rejects every request until you pick another one.
GitHub: X-Hub-Signature-256 carries sha256= and the hex HMAC-SHA256 of the body. Create the endpoint with the
GitHub provider and the webhook's secret, and point the GitHub webhook at
https://hookshot.example.com/webhooks/receive/<slug>. To test it by hand:
body='{"zen":"Keep it logically awesome."}'
sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST https://hookshot.example.com/webhooks/receive/github \
-H "Content-Type: application/json" -H "X-Hub-Signature-256: sha256=$sig" -d "$body"A provider is a subclass of VerificationProviders::Base that implements verified?(request) and returns true for
an authentic request. request is the ActionDispatch::Request; request.raw_post is the body as received. Base
gives you:
| Method | Returns |
|---|---|
secrets |
the endpoint's secrets, one per line |
endpoint |
the endpoint that received the request |
hmac(algorithm, secret, *parts) |
the raw HMAC digest of the parts in order, without joining them |
secure_match?(expected, given) |
true when given equals one of expected, compared in constant time |
self.label |
the name shown in the admin (the class name unless you override it) |
Register the class under a key in config/initializers/verification_providers.rb; the key is stored on the endpoints
that use it. For example, a Stripe provider (Stripe-Signature: t=<timestamp>,v1=<hex HMAC-SHA256 of "<t>.<body>">,
with a five minute replay window) in app/services/verification_providers/stripe.rb:
module VerificationProviders
class Stripe < Base
TOLERANCE = 300
def verified?(request)
pairs = request.headers["Stripe-Signature"].to_s.split(",").map { |pair| pair.strip.split("=", 2) }
timestamp = pairs.assoc("t")&.last.to_s
return false unless timestamp.match?(/\A\d+\z/) && (Time.current.to_i - timestamp.to_i).abs <= TOLERANCE
expected = secrets.map { |secret| hmac("sha256", secret, timestamp, ".", request.raw_post).unpack1("H*") }
pairs.any? { |key, value| key == "v1" && secure_match?(expected, value.to_s) }
end
end
endRails.application.config.to_prepare do
VerificationProviders.register("github", VerificationProviders::Github)
VerificationProviders.register("stripe", VerificationProviders::Stripe)
endRegister inside to_prepare, so the registration survives code reloading in development.
VerificationProviders.unregister("github") removes a provider, e.g. the built-in one.
Filters allow routing webhooks to specific targets. A target without filters receives every webhook.
Filter Types:
Header: Match against request headersPayload: Match against JSON payload using dot notation (e.g.,$.event)
Operators:
Exists: Field is presentEquals: Field equals exact valueMatches (wildcard): Whole field matches a pattern with*wildcards, ignoring caseMatches regex: Field matches a Ruby regular expression
Regex details: the pattern may match anywhere in the value, so anchor it with \A and \z to match the whole
value. Matching is case-sensitive unless the pattern starts with (?i). A missing field never matches (not even
.* or a negative lookahead), and non-string payload values are matched by their string form (42, false).
Invalid patterns are rejected when saving, and each match is limited to 100ms so a pathological pattern cannot stall
incoming webhooks.
Example: deliver comments from Renovate or Dependabot regardless of case:
- Type:
Payload - Field:
$.sender.login - Operator:
Matches regex - Value:
(?i)\A(renovate|dependabot)\[bot\]\z
Example: Only deliver webhooks where $.event equals user.created:
- Type:
Payload - Field:
$.event - Operator:
Equals - Value:
user.created
Every filter belongs to a group (default unless you name one). Groups let a single target express several
alternative conditions:
- Within a group, ALL filters must match (AND).
- Across groups, ANY fully matching group triggers delivery (OR).
In the target form each group is a card: rename it, add filters to it, or remove it as a whole. Groups can be collapsed to a one-line summary, and the collapsed state is remembered per target in your browser.
Targets whose filters all stay in the default group behave as a plain AND of every filter. Once more groups exist,
default is just another alternative: there is no condition shared by all groups, so repeat common checks in every
group. A filter on a signature header only checks that the header is there, which anyone can fake: use the endpoint's
signature verification instead. A group that only partially matches never causes delivery
on its own.
Example: forward only actionable GitHub events to one endpoint:
| Group | Type | Field | Operator | Value |
|---|---|---|---|---|
ci-fail |
Header | X-GitHub-Event |
Equals | check_suite |
ci-fail |
Payload | $.check_suite.conclusion |
Equals | failure |
copilot-comment |
Header | X-GitHub-Event |
Equals | issue_comment |
copilot-comment |
Payload | $.issue.pull_request |
Exists | |
copilot-comment |
Payload | $.comment.user.login |
Matches | *copilot* |
This delivers failed check suites and Copilot comments on pull requests, but not a successful check suite
(it only partially matches ci-fail) nor comments by other users. Group names are case-sensitive and surrounding
whitespace is stripped.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /webhooks/receive |
None (or the endpoint's signature) | Receive incoming webhooks on the default endpoint |
| POST | /webhooks/receive/:slug |
None (or the endpoint's signature) | Receive incoming webhooks on a named endpoint |
| GET | /health |
None | Health check endpoint |
| GET | /admin/* |
Basic | Admin dashboard |
| POST | /mcp |
Basic | MCP server for admin tools |
| GET | /jobs |
Basic | Mission Control jobs dashboard |
Each delivery includes these headers:
Content-Type: Original webhook content type (application/octet-streamwhen the sender sent none)X-Hookshot-Webhook-Id: Internal webhook IDX-Hookshot-Delivery-Id: Internal delivery IDX-Hookshot-Attempt: Delivery attempt number, starting at1User-Agent:Hookshot/1.0- Any custom headers configured on the target
Headers sent by the original sender (e.g. X-GitHub-Event) are not forwarded.
Failed deliveries are retried with exponential backoff:
- Up to 10 attempts over roughly 40 hours
- Increasing delay between retries
- Client errors (4xx) are not retried
- Server errors (5xx) and timeouts are retried
Failed deliveries with attempts left can also be retried manually from the Dispatches page: individually, by selecting rows (shift-click selects a range) and using Retry selected, or with Retry all matching, which queues every retryable delivery for the current status and target filters across all pages (up to 1000 per click).
Single container runs both web server and background job processor:
# Build image
docker build -t hookshot .
# Run with docker-compose
SECRET_KEY_BASE=$(rails secret) HOOKSHOT_PASSWORD=your-password docker-compose up -d
# Or run directly
docker run -d \
-p 3000:3000 \
-v hookshot_data:/rails/storage \
-e SECRET_KEY_BASE=$(rails secret) \
-e HOOKSHOT_PASSWORD=your-password \
--name hookshot \
hookshotData is persisted in the hookshot_data volume.
MIT - see LICENSE.md for details.
