# Guide to Metabase Webhooks: Features and Best Practices

Metabase webhooks send the results of an alert to your own service. When a saved question crosses the threshold you set, Metabase can post the question's rows, a rendered chart and a link back to the question to any URL, so you can open a ticket, page someone through a tool Metabase does not integrate with, store the numbers, or kick off a workflow.

This guide covers how Metabase webhooks are created and authenticated, what an alert payload contains, and the practices that make a Metabase receiver dependable.

## What are Metabase webhooks?

In Metabase, a webhook is a destination an admin creates once and other people then pick as the recipient of an alert. Webhooks only work with alerts: you cannot send a dashboard subscription to one. When an alert fires, Metabase sends a JSON request with metadata about the alert and the question, the question's results as rows, and the chart as a base64-encoded PNG.

Metabase does not sign webhook requests. Each webhook has an **Authentication method**, and the one that fits Hookdeck's Metabase source is **Basic**: Metabase sends `Authorization: Basic <base64(username:password)>` on every request. Metabase is open source, and its code builds that header when the webhook is saved and adds it to each request, so the credentials arrive on the first request rather than after a challenge.

## Metabase webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Admin** > **Settings** > **Webhooks** > **Webhooks for alerts** > **Add a webhook** |
| Who can create them | Admins and people with settings access |
| What can use them | Alerts on questions. Not dashboard subscriptions |
| Method and format | `POST` with a JSON body (from Metabase's source; the docs say only "JSON") |
| Authentication | None, Basic (username and password), Bearer (secret token), or API key in a header or query parameter |
| Signature and timestamp | None |
| Event type | `type` is `alert` |
| Test alerts | Same shape, with `alert_id` set to `null` |
| Retries, timeout, source IPs | Not documented |

## The alert payload

There is one payload type. Here is Metabase's example, with the rows and the PNG trimmed:

```json
{
  "type": "alert",
  "alert_id": null,
  "alert_creator_id": 2666,
  "alert_creator_name": "Roberto Bolaño",
  "data": {
    "type": "question",
    "question_id": 108,
    "question_name": "Sales",
    "question_url": "https://example.com/question/108",
    "visualization": "data:image/png;base64,...",
    "raw_data": {
      "cols": ["CREATED_AT", "count"],
      "rows": [
        ["2023-09-01T00:00:00Z", 346],
        ["2023-10-01T00:00:00Z", 354]
      ]
    }
  },
  "sent_at": "2024-09-30T20:16:15.76582Z"
}

```

`raw_data` is the table view of the results: `cols` names the columns and each entry in `rows` is one row in the same order. `visualization` is the chart as a `data:image/png;base64,` URL, which can make the body much larger than the rows alone, so raise your framework's body size limit. `alert_id` is `null` when the request comes from a test alert, which is a useful way to keep tests out of production data.

> **Inspect Metabase webhooks as they arrive.** Point a Metabase webhook at a [Hookdeck Inspect](https://inspect.hookdeck.com) URL and send a test alert to see the full payload, PNG included, with no account or setup required.

## Setting up Metabase webhooks

Choose a username and a long random password first, for example with `openssl rand -hex 32` for the password.

1. Click the grid icon in the top right corner and select **Admin**.
2. Go to **Settings** > **Webhooks**.
3. Under **Webhooks for alerts**, click **Add a webhook**.
4. Enter the **Webhook URL**, a name people will recognise when they set up alerts, and a description.
5. Set **Authentication method** to **Basic** and enter the username and password.
6. Save.
7. Open a saved question, create an alert, and choose the webhook as a recipient.

To route through Hookdeck, create a Hookdeck source with the **Metabase** type and use its URL as the Webhook URL. The source asks for a **Username** and **Password**: enter the same pair. It accepts `POST`.

The Hookdeck source checks HTTP Basic credentials only. A webhook set to **Bearer** or **API key** sends a different header, and a query parameter key is not a header at all, so none of them will verify against the Metabase source type. Hookdeck never answers with a `401` challenge, which is fine here because Metabase sends the Basic credentials with every request.

## Securing Metabase webhooks

Decode the `Authorization: Basic` header, split on the first colon (a password can contain colons), and compare the username and password with your stored values in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

const USERNAME = process.env.METABASE_WEBHOOK_USERNAME;
const PASSWORD = process.env.METABASE_WEBHOOK_PASSWORD;

function safeEqual(a, b) {
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

function verifyBasicAuth(header) {
  if (!USERNAME || !PASSWORD || typeof header !== 'string') return false;
  const match = /^basic\s+([A-Za-z0-9+/]+={0,2})\s*$/i.exec(header);
  if (!match) return false;
  const decoded = Buffer.from(match[1], 'base64');
  const colon = decoded.indexOf(0x3a); // split on the first colon
  if (colon === -1) return false;
  const userOk = safeEqual(decoded.subarray(0, colon), Buffer.from(USERNAME, 'utf8'));
  const passOk = safeEqual(decoded.subarray(colon + 1), Buffer.from(PASSWORD, 'utf8'));
  return userOk && passOk;
}

const app = express();

// Alerts embed a base64 PNG, so allow more than the 100kb default.
app.post('/webhooks/metabase', express.raw({ type: '*/*', limit: '10mb' }), (req, res) => {
  if (!verifyBasicAuth(req.get('authorization'))) {
    return res.sendStatus(401);
  }

  let alert;
  try {
    alert = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // alert.alert_id is null for test alerts. alert.data.raw_data holds the rows.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import binascii
import hmac
import os

USERNAME = os.environ.get("METABASE_WEBHOOK_USERNAME", "")
PASSWORD = os.environ.get("METABASE_WEBHOOK_PASSWORD", "")

def verify_basic_auth(header) -> bool:
    if not USERNAME or not PASSWORD or not header:
        return False
    scheme, _, encoded = header.partition(" ")
    if scheme.lower() != "basic":
        return False
    try:
        decoded = base64.b64decode(encoded.strip(), validate=True)
    except (binascii.Error, ValueError):
        return False
    user, sep, password = decoded.partition(b":")
    if not sep:
        return False
    user_ok = hmac.compare_digest(user, USERNAME.encode("utf-8"))
    pass_ok = hmac.compare_digest(password, PASSWORD.encode("utf-8"))
    return user_ok and pass_ok

```

Basic credentials are static. They authenticate the sender, not the body, so a modified payload still passes, and with no timestamp a captured request can be replayed. Serve the endpoint over HTTPS only, use a long random password, and change it on the webhook and in your environment together if it might have leaked.

> **Make Metabase webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the Basic credentials on every Metabase alert, stores each request durably, and retries delivery to your service on a schedule you control.

## Metabase webhook limitations and pain points

### Only Basic fits the source type

**The Problem:** A webhook set up with Bearer or an API key never verifies in Hookdeck's Metabase source.

**Why It Happens:** Metabase offers four authentication methods. Hookdeck's Metabase source type checks HTTP Basic credentials only.

**Workarounds:**

* Set the webhook's **Authentication method** to **Basic**.
* If you need Bearer or a header API key, check it in your own receiver instead. Avoid the query parameter option, which puts the key in URLs that end up in logs.

**How Hookdeck Can Help:** With Basic selected, the Metabase source compares the username and password you enter. Hookdeck answers Metabase with a `200` either way, but requests without matching credentials are recorded as failed verification and never reach your service.

### No documented delivery guarantees

**The Problem:** You cannot tell from the docs whether Metabase retries a failed request or how long it waits for your endpoint.

**Why It Happens:** The webhooks page covers setup and the payload, not delivery behaviour.

**Workarounds:**

* Respond quickly and process afterwards, so a slow handler does not become a lost alert.
* For alerts that matter, make the alert's own condition visible elsewhere too, such as a dashboard someone checks.

**How Hookdeck Can Help:** Hookdeck accepts each alert and stores it, then delivers to your service with [retries](/docs/retries) you configure. You can replay any alert after a fix, and [issues](/docs/issues) tell you when deliveries to your service fail.

### Large bodies

**The Problem:** Requests fail with `413 Payload Too Large` or time out while your framework buffers the body.

**Why It Happens:** Every alert includes the chart as a base64-encoded PNG, on top of the result rows.

**Workarounds:**

* Raise the body size limit on the webhook route only.
* If you only need the numbers, read `raw_data` and drop `visualization` before storing the event.

**How Hookdeck Can Help:** [Transformations](/docs/transformations) can remove the `visualization` field before the alert reaches your service, so your endpoint only receives the rows.

### Test alerts look like real ones

**The Problem:** Test alerts are mixed in with real ones.

**Why It Happens:** A test alert has the same shape as a real one, with `alert_id` set to `null`.

**Workarounds:**

* Skip or route separately any payload where `alert_id` is `null`.

**How Hookdeck Can Help:** A Hookdeck [filter](/docs/filters) on `alert_id` can keep test alerts away from your production destination.

## Best practices

### Key on the alert and the time it was sent

There is no delivery ID. Use `alert_id` plus `sent_at` as an idempotency key, so a repeated request does not open two tickets. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Read rows by column name

Map `raw_data.cols` to indexes before reading `rows`, so a change to the question's columns does not silently shift values.

### Link back with question_url

Include `data.question_url` in whatever you create from the alert, so the person who picks it up can open the question in Metabase.

### Acknowledge fast and process asynchronously

Verify the credentials, store the alert, return `200`, and do the rest afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Metabase sends alert results to a webhook as a JSON `POST` with the rows, a PNG of the chart and a link to the question, and it does not sign the request. Choose Basic authentication on the webhook, compare the decoded username and password in constant time over HTTPS, allow for large bodies, and key on `alert_id` and `sent_at`.

[Hookdeck Event Gateway](https://hookdeck.com) checks the Basic credentials, keeps every alert, and retries and replays delivery to your service, so an outage on your side does not mean a missed alert.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Metabase webhooks reliably in minutes.