Agent skill

Metabase Webhooks Skill

Receive and authenticate Metabase alert webhooks. Use when setting up a Metabase webhook endpoint, checking the HTTP Basic Authorization header, debugging 401s, or handling alert payloads with question results (alert_id, question_url, raw_data, base64 PNG visualization).

Install this skill

npx skills add hookdeck/webhook-skills --skill 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)

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/, examples/nextjs/, examples/fastapi/.

Payload and Event Types

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

typeSent When
alertA Metabase alert on a question fires (or an admin sends a test alert; then alert_id is null)
{
  "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

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):

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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 9, 2026

View on GitHub →