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:

  1. Private-network targets are blocked by default. Metabase's http-channel-host-strategy setting defaults to external-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 with MB_HTTP_CHANNEL_HOST_STRATEGY=allow-private (or allow-all to include localhost).
  2. The connection test sends an empty body. The "Send a test" ping is a POST with Content-Length: 0 and 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

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_alerts is the webhook ID; it becomes the URL path (/webhook/metabase_alerts).
  • module: log prints each authenticated payload to the container log. Zero dependencies for the first run; we swap in RabbitMQ at the end.
  • authorization requires the exact Authorization: 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 as METABASE_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-strategy is at its external-only default. Set MB_HTTP_CHANNEL_HOST_STRATEGY=allow-private on 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 and METABASE_WEBHOOK_TOKEN differ. They must be byte-identical.
  • Large payloads: visualization embeds 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.

Subscribe to Free Webhook Tool

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe