Agent skill

Grafana Webhooks Skill

Receive and verify Grafana Alerting webhook contact point notifications. Use when setting up a Grafana webhook receiver, debugging Grafana HMAC signature verification (X-Grafana-Alerting-Signature, HMAC-SHA256 hex over the raw body, optionally timestamp + ":" + body), or handling firing and resolved alert notifications from Grafana-managed alerting or Grafana Cloud.

Install this skill

npx skills add hookdeck/webhook-skills --skill grafana-webhooks


Covers the Grafana Alerting webhook contact point (Grafana-managed alerting, the only alerting system in Grafana 11+; also Grafana Cloud).

Not covered: Grafana legacy dashboard alerting webhooks (removed in Grafana 11 — different payload with ruleName/evalMatches, no HMAC); Grafana OnCall / IRM outgoing webhooks (separate product, own templates and auth); Prometheus Alertmanager webhook_config (payload is a close relative — Grafana's is Alertmanager's plus extra fields — but Alertmanager has no HMAC signing).

When to Use This Skill

  • How do I receive Grafana alert webhooks?
  • How do I verify a Grafana webhook signature?
  • What is X-Grafana-Alerting-Signature and how do I check it?
  • Why is my Grafana webhook HMAC verification failing?
  • How do I tell a firing alert from a resolved one in the Grafana payload?
  • How do I secure a Grafana webhook contact point (HMAC, basic auth, bearer token)?

Verification (core)

HMAC-SHA256, lowercase hex, over the raw body — or over <unix-seconds> + ":" + rawBody when a timestamp header is configured. The digest is bare: no sha256= prefix, no t=...,v1= structure. Both header names are user-configurable; the signature header defaults to X-Grafana-Alerting-Signature and the timestamp header has no default name (unset = body-only signing).

Node:

const crypto = require('crypto');

function verify(rawBody, signature, timestamp, secret) {
  if (!signature || !secret) return false;
  const hmac = crypto.createHmac('sha256', secret);       // secret used as-is (UTF-8)
  if (timestamp) hmac.update(`${timestamp}:`);            // COLON separator, seconds
  hmac.update(rawBody);                                   // RAW bytes, never re-serialized JSON
  const expected = hmac.digest('hex');
  const received = Buffer.from(signature.toLowerCase(), 'utf8');
  const want = Buffer.from(expected, 'utf8');
  return received.length === want.length && crypto.timingSafeEqual(received, want);
}

Python:

import hmac, hashlib

def verify(raw_body: bytes, signature: str, timestamp: str | None, secret: str) -> bool:
    if not signature or not secret:
        return False
    mac = hmac.new(secret.encode("utf-8"), digestmod=hashlib.sha256)
    if timestamp:
        mac.update(f"{timestamp}:".encode("utf-8"))   # COLON separator, unix seconds
    mac.update(raw_body)                              # RAW bytes
    # Compare bytes: compare_digest raises TypeError on non-ASCII str input
    received = signature.strip().lower().encode("utf-8", errors="replace")
    return hmac.compare_digest(received, mac.hexdigest().encode("ascii"))

If you configure a timestamp header, also reject stale timestamps (a replay window is your choice — Grafana documents no tolerance; 300s is a sane default) and reject requests that arrive without the header.

For complete handlers with route wiring, dispatch, and tests, see:

No Event Types — Dispatch on Status

Grafana sends no event-type header and no event-type field. Each request is one notification for an alert group. Dispatch on these fields instead:

FieldValuesMeaning
statusfiring, resolvedGroup status — firing if any alert in the group is firing
statealerting, okGrafana's equivalent of status
alerts[].statusfiring, resolvedPer-alert status

A firing notification can contain individual resolved alerts, so iterate alerts[] rather than trusting the top-level status alone. Resolved notifications can be turned off with Disable resolved message.

Payload Shape (default)

{
  "receiver": "My Super Webhook",
  "status": "firing",
  "orgId": 1,
  "alerts": [
    {
      "status": "firing",
      "labels": { "alertname": "High memory usage", "team": "blue", "zone": "us-1" },
      "annotations": { "description": "The system has high memory usage" },
      "startsAt": "2021-10-12T09:51:03.157076+02:00",
      "endsAt": "0001-01-01T00:00:00Z",
      "generatorURL": "https://play.grafana.org/alerting/1afz29v7z/edit",
      "fingerprint": "c6eadffa33fcdf37",
      "silenceURL": "https://play.grafana.org/alerting/silence/new?...",
      "dashboardURL": "",
      "panelURL": "",
      "values": { "B": 44.23943737541908, "C": 1 }
    }
  ],
  "groupLabels": {},
  "commonLabels": { "team": "blue" },
  "commonAnnotations": {},
  "externalURL": "https://play.grafana.org/",
  "version": "1",
  "groupKey": "{}:{}",
  "truncatedAlerts": 0,
  "title": "[FIRING:2]  (blue)",
  "state": "alerting",
  "message": "**Firing**\n..."
}

endsAt is "0001-01-01T00:00:00Z" while an alert is still firing. title and message are template-driven. truncatedAlerts counts alerts dropped by Max Alerts. With the Custom Payload option the body is whatever the template renders — possibly pretty-printed or not JSON at all — which is exactly why you must sign and verify the raw bytes.

Important Headers

HeaderDescription
X-Grafana-Alerting-SignatureDefault name for the HMAC-SHA256 hex digest. User-configurable — read the name from config.
(your chosen name)Unix-seconds timestamp, only if you set Timestamp Header. No default name; docs' example uses X-Grafana-Alerting-Signature-Timestamp.
Content-Typeapplication/json by default; overridable via Extra Headers.

There is no delivery-id header (X-Grafana-Delivery, X-Grafana-Event etc. do not exist — don't invent them). Self-hosted Grafana sends from your own egress IPs; Grafana Cloud publishes source-IP lists (Hosted Grafana for Grafana-managed alerts: https://grafana.com/api/hosted-grafana/source-ips.txt). Those IPs are shared by all Grafana Cloud customers, so treat an allowlist as defence in depth, not verification.

HTTP Behaviour

  • Method POST by default; PUT is selectable (httpMethod).
  • Any 2xx counts as success.
  • No handshake / challenge request. The contact point's Test button sends a normal, signed notification with a synthetic alert (labels alertname: TestAlert, instance: Grafana; annotation summary: Notification test).

Other Auth Options

Configured on the same contact point, and combinable with HMAC — but only HMAC proves payload integrity; the rest only authenticate the sender. Compare credentials in constant time too.

OptionSends
HTTP Basic AuthenticationAuthorization: Basic base64(user:pass)
Authorization Header (authorization_scheme, default Bearer, + authorization_credentials)Authorization: <scheme> <credentials>
TLS client certificate (mTLS)Client cert on the TLS handshake

Grafana rejects a config that sets both Basic auth and the Authorization header ("both HTTP Basic Authentication and Authorization Header are set, only 1 is permitted"). Recent Grafana versions also expose an HTTP-client subform (OAuth2 client credentials, proxy). Extra Headers adds static headers, but Authorization, User-Agent, Host and similar are restricted.

Idempotency

There is no delivery id. If you need a dedupe key, derive a heuristic one from groupKey + status + the sorted alerts[].fingerprint and alerts[].startsAt values. Treat it as a heuristic, not a guarantee.

Environment Variables

GRAFANA_WEBHOOK_SECRET=your_hmac_secret          # Contact point → HMAC Signature → Secret
GRAFANA_SIGNATURE_HEADER=X-Grafana-Alerting-Signature   # Optional; this is the default
GRAFANA_TIMESTAMP_HEADER=                        # Optional; empty = body-only signing
GRAFANA_MAX_AGE_SECONDS=300                      # Our replay window, not Grafana's

HMAC is optional in Grafana (off until you fill the HMAC Signature subform), but a receiver should require it: if GRAFANA_WEBHOOK_SECRET is unset, fail closed.

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 grafana --path /webhooks/grafana

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Sep 28, 2026

View on GitHub →