# Metabase Webhooks

Metabase can send the results of a saved question to a webhook when an alert fires. The request is
a `POST` with a JSON body. Webhooks are **not signed**: there is no signature header and no
timestamp. Instead you choose an authentication method on the webhook, and this skill covers
**HTTP Basic**, where Metabase sends `Authorization: Basic base64(username:password)` on every
request, up front, and your endpoint compares it with the credentials you chose.

## When to Use This Skill

* How do I receive Metabase webhooks (alert webhooks)?
* How do I check the Basic `Authorization` header from Metabase?
* Does Metabase sign webhook payloads? (No: use Basic authentication)
* How do I handle the `alert` payload, `raw_data` and the base64 PNG `visualization`?
* Why does my Metabase webhook endpoint return 401?

## Setup note

**Choose Basic authentication on the Metabase webhook.** (Bearer, API key and query-param options
are not covered here.) Use the same username and password in `METABASE_WEBHOOK_USERNAME` and
`METABASE_WEBHOOK_PASSWORD`.

## Verification (core)

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

const sha = (b) => crypto.createHash('sha256').update(b).digest();
const safeEqual = (a, b) => crypto.timingSafeEqual(sha(a), sha(b)); // equal-length digests

function verifyMetabaseRequest(headers, username, password) {
  if (!username || !password) return false;                  // never accept when unconfigured
  const m = /^basic +([A-Za-z0-9+/]+={0,2})$/i.exec((headers['authorization'] || '').trim());
  if (!m) return false;                                       // strict base64 alphabet only
  const decoded = Buffer.from(m[1], 'base64');
  if (decoded.toString('base64') !== m[1]) return false;      // reject trailing junk
  const i = decoded.indexOf(0x3a);                            // split on the FIRST colon
  if (i === -1) return false;
  const userOk = safeEqual(decoded.subarray(0, i), Buffer.from(username));
  const passOk = safeEqual(decoded.subarray(i + 1), Buffer.from(password));
  return userOk && passOk;                                    // evaluate both, no short-circuit
}

```

* Header: `Authorization: Basic base64(username:password)`; the scheme is case-insensitive.
* Return `401` for a missing or malformed header or a mismatch, `400` for a body that isn't a JSON
  object, `200` once handled. Credentials arrive on the **first** request (Metabase sends them
  preemptively), so there is no challenge round-trip to support.
* Read the body raw (`express.raw({ type: '*/*', limit: '10mb' })`) and authenticate before
  parsing it. Alerts embed a base64 PNG, so raise Express's default 100kb body limit.
* In Python, `base64.b64decode(value, validate=True)` and `hmac.compare_digest` on **bytes**
  (encode both sides; `str` with non-ASCII raises).
* **A static shared credential authenticates the sender, not the body, and gives no replay
  protection** (no signature, no timestamp). Use HTTPS, and rotate the credential if it leaks.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/metabase-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/metabase-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/metabase-webhooks/examples/fastapi/).

## Payload and Event Types

There is one payload type, `alert`, and it's the `type` field of the body:

| `type` | Sent When |
| --- | --- |
| `alert` | A Metabase alert on a question fires (or an admin sends a test alert; then `alert_id` is `null`) |

```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": [["2024-09-01T00:00:00Z", 346]] }
  },
  "sent_at": "2024-09-30T20:16:15.76582Z"
}

```

## Environment Variables

```bash
METABASE_WEBHOOK_USERNAME=your-webhook-username   # Admin > Settings > Webhooks > Basic
METABASE_WEBHOOK_PASSWORD=your-webhook-password

```

## Local Development

Metabase can't reach `localhost`. Tunnel with the Hookdeck CLI (no account required):

```bash
npx hookdeck-cli listen 3000 metabase --path /webhooks/metabase

```

Use the printed URL as the webhook URL in Metabase, then use **Send a test alert**.

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/metabase-webhooks/references/overview.md) - What Metabase webhooks are, the alert payload
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/metabase-webhooks/references/setup.md) - Create the webhook with Basic authentication
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/metabase-webhooks/references/verification.md) - Basic auth check details, gotchas, debugging 401s
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/metabase-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests