How to Receive Webhooks from Metabase Alerts (Self-Hosted, Step by Step)
Receive Metabase alert webhooks on your own server: Bearer authentication, the host-strategy SSRF setting, the empty-body test ping gotcha, Docker setup, and RabbitMQ routing, verified against a real Metabase alert.
By the end of this guide you will have a self-hosted endpoint that receives Metabase alert webhooks, authenticates every delivery with a Bearer token, and routes the payloads to a log, RabbitMQ, or any other destination. Everything below was verified end to end against a real Metabase instance and the real Core Webhook Module Docker image: an actual Metabase alert sent the deliveries quoted here, and this run surfaced two behaviors worth knowing before you start.
How Metabase webhooks work
Metabase sends webhooks through notification channels: an admin creates a webhook under Admin > Settings > Webhooks ("Webhooks for alerts"), and users can then pick it as the destination of an alert on a question. Webhooks are only available for alerts; dashboard subscriptions cannot target them.
A real alert delivery looks like this (captured live; the base64 PNG shortened for print):
{
"type": "alert",
"alert_id": 1,
"alert_creator_id": 1,
"alert_creator_name": "Admin User",
"data": {
"type": "question",
"question_id": 40,
"question_name": "Order count",
"question_url": "http://metabase.example.com/question/40",
"visualization": "data:image/png;base64,iVBORw0KG...",
"raw_data": {"cols": ["count"], "rows": [[18760]]}
},
"sent_at": "2026-08-29T20:06:58.529002959Z"
}
visualization is a base64-encoded PNG of the chart and raw_data carries the actual result rows, so the webhook consumer gets both the picture and the numbers. The request arrives with Content-Type: application/json and an Apache HttpClient user agent.
Authentication: the webhook form's Authentication method offers None, Basic (username/password), Bearer (secret token), and API key (in a header or query param). With Bearer, Metabase sends exactly Authorization: Bearer <your token> (confirmed by capturing a live request), which the gateway compares in constant time.
Two behaviors verified against a live instance:
- Private-network targets are blocked by default. Metabase's
http-channel-host-strategysetting defaults toexternal-only; a webhook URL that resolves to a private or local address is rejected with "URLs referring to hosts that supply internal hosting metadata are prohibited." For a receiver on your own network, start Metabase withMB_HTTP_CHANNEL_HOST_STRATEGY=allow-private(orallow-allto include localhost). - The connection test sends an empty body. The "Send a test" ping is a POST with
Content-Length: 0and only the auth header. A receiver that validates JSON will reject it with 400 even though authentication succeeded, so a failed test against this gateway does not mean the webhook is broken; a real alert delivers a full JSON payload and succeeds.
Prerequisites
- Docker on any machine (your laptop is fine for the test run)
- A public HTTPS URL for production (which also keeps you clear of the host-strategy default). Two ways to get one:
- HTTPS for webhooks with nginx and Let's Encrypt if you have a public server
- Receiving webhooks through Cloudflare Tunnel if you do not want to open inbound ports
Step 1: Configure the receiver
Create a working directory with two files.
webhooks.json:
{
"metabase_alerts": {
"data_type": "json",
"module": "log",
"authorization": "Bearer {$METABASE_WEBHOOK_TOKEN}"
}
}
connections.json:
{}
What each parameter does:
metabase_alertsis the webhook ID; it becomes the URL path (/webhook/metabase_alerts).module: logprints each authenticated payload to the container log. Zero dependencies for the first run; we swap in RabbitMQ at the end.authorizationrequires the exactAuthorization: Bearer <token>header Metabase sends and compares it in constant time.{$METABASE_WEBHOOK_TOKEN}pulls the token from an environment variable, so it never lives in a config file.
Generate a strong token (or use our webhook secret generator):
openssl rand -hex 32
Step 2: Run it
docker run -d --name webhook-gateway -p 8000:8000 \
-v "$PWD/webhooks.json:/app/webhooks.json:ro" \
-v "$PWD/connections.json:/app/connections.json:ro" \
-e METABASE_WEBHOOK_TOKEN="your-generated-token" \
spiderhash/webhook:latest
Confirm it is up:
docker logs webhook-gateway
# ... INFO: Application startup complete.
The receiver now answers on http://localhost:8000/webhook/metabase_alerts, with auto-generated API docs at http://localhost:8000/docs.
Step 3: Test it locally
Simulate a delivery. Save the alert payload from the top of this article as alert_payload.json, then:
curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:8000/webhook/metabase_alerts \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-generated-token" \
--data-binary @alert_payload.json
Expected result:
{"message":"200 OK"}
HTTP 200
A wrong token:
{"detail":"Unauthorized"}
HTTP 401
And a request with no Authorization header at all:
{"detail":"Invalid Bearer token format: must start with 'Bearer '"}
HTTP 401
Those are the exact responses the gateway returns. docker logs webhook-gateway shows the accepted payload.
Step 4: Create the webhook and alert in Metabase
In Admin > Settings > Webhooks, add a webhook:
- Webhook URL:
https://your-domain/webhook/metabase_alerts - Give it a name: something like
webhook-gateway, plus a description - Authentication method:
Bearer, with the same token you passed asMETABASE_WEBHOOK_TOKEN
Then open any saved question, create an Alert on it, and choose your webhook as the destination. When the alert's condition fires (or on its schedule), the delivery lands; docker logs webhook-gateway shows the full payload with the chart and rows. In this guide's verification run, a real alert send was accepted with 200 within a second.
Troubleshooting
- "URLs referring to hosts that supply internal hosting metadata are prohibited" when saving or testing: your URL resolves to a private or local address and
http-channel-host-strategyis at itsexternal-onlydefault. SetMB_HTTP_CHANNEL_HOST_STRATEGY=allow-privateon the Metabase container (verified: the identical webhook worked the moment the setting changed) or use a public HTTPS URL. - "Failed to connect to channel" on Send a test, while real alerts work: the test ping has an empty body, and this gateway validates JSON, answering
400 {"detail":"Malformed JSON payload"}. Authentication already succeeded at that point; trigger a real alert to see the full flow. 401 {"detail":"Unauthorized"}: the Bearer token in the webhook's Authentication section andMETABASE_WEBHOOK_TOKENdiffer. They must be byte-identical.- Large payloads:
visualizationembeds a PNG, so deliveries run kilobytes to megabytes depending on the chart. Budget accordingly in downstream queues.
Going to production
Printing to logs is not a pipeline. Swap the module for a real destination; here is the RabbitMQ variant, verified end to end (a real Metabase alert landed as a message in the metabase_alerts queue):
webhooks.json:
{
"metabase_alerts": {
"data_type": "json",
"module": "rabbitmq",
"connection": "rabbitmq_local",
"module-config": {
"queue_name": "metabase_alerts"
},
"authorization": "Bearer {$METABASE_WEBHOOK_TOKEN}"
}
}
connections.json:
{
"rabbitmq_local": {
"type": "rabbitmq",
"host": "{$RABBITMQ_HOST:localhost}",
"port": "{$RABBITMQ_PORT:5672}",
"user": "{$RABBITMQ_USER:guest}",
"pass": "{$RABBITMQ_PASS:guest}"
}
}
The same pattern works for 17 other destinations: archive alert history to S3, publish to Kafka, or fan out to several at once with webhook chaining. Built-in rate limiting and retry handling cover the operational edges. The full authentication reference, including Bearer tokens, Basic auth, and 10 other methods, is in the docs.