Metaplane supports sending incident alerts to 3rd party systems via webhooks.

Setup

First, navigate to the alerts settings page. Scroll to the bottom of the page and find the option to add a new webhook alert destination.

Fill out the form and then click "Add destination."

Verify

You can have Metaplane send you a sample incident by using the actions menu to "Verify" the webhook alert destination.

Refer to the alert routing docs to learn more about how to route specific incidents to your webhook.

Secure

If you'd like to secure your webhook by verifying that request come from Metaplane, you can enable request signatures.

If you lose your secret key before you're able to instrument request verification you can always generate a new key from the Secure dialog.

See the code examples below (Python and NodeJS) to understand how you should verify the webhook request.

from flask import Flask, request, jsonify
import hmac
import hashlib

app = Flask(__name__)

WEBHOOK_SECRET = "YOUR_SECRET_HERE"

def verify_signature(payload, signature):
    expected_sig = hmac.new(
        WEBHOOK_SECRET.encode('utf-8'),
        payload,
        hashlib.sha256
    ).hexdigest()
    
    return hmac.compare_digest(signature, expected_sig)

@app.route('/webhook', methods=['POST'])
def handle_webhook():
    # Get signature from header
    signature = request.headers.get('x-metaplane-sig')
    if not signature:
        return jsonify({'error': 'No signature provided'}), 401
    
    # Get raw request body
    payload = request.get_data()
    
    # Verify signature
    if not verify_signature(payload, signature):
        return jsonify({'error': 'Invalid signature'}), 401
    
    # If signature is valid, process the webhook
    data = request.json
    print(f"Received verified webhook: {data}")
    
    return jsonify({'status': 'success'}), 200

if __name__ == '__main__':
    app.run(port=3000)
const express = require('express');
const crypto = require('crypto');

const app = express();
app.use(express.raw({ type: 'application/json' })); // Important: raw body for signature verification

const WEBHOOK_SECRET = 'YOUR_SECRET_HERE';

function verifySignature(payload, signature) {
  const expectedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(payload)
    .digest('hex');
  
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

app.post('/webhook', (req, res) => {
  const signature = req.header('x-metaplane-sig');
  if (!signature) {
    return res.status(401).json({ error: 'No signature provided' });
  }

  // req.body is a Buffer since we used express.raw()
  if (!verifySignature(req.body, signature)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Parse the JSON after verification
  const data = JSON.parse(req.body);
  console.log('Received verified webhook:', data);
  
  res.json({ status: 'success' });
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

Once you've verified the signature you can be sure that the request came from Metaplane. If you'd like to test your request verification code, simply use the "Verify" action on your alert destination. The verify action will send a signed sample webhook request.

Payload

Schema

You can expect the payload to match this schema:

{
    id: string;
    labels: Array<{
        name: string;
        id: string;
    }>;
    status: "opened" | "resolved";
    updatedAt: string;
    createdAt: string;
    idempotencyId: string;
    acknowledged: boolean;
    acknowledgedAt: string | null;
    incidentUrl: string;
    affectedMonitors_v2: Array<{
        id: string;
        monitor: Array<{
            name: string | null;
            type: string;
            id: string;
            tags: Array<{
                name: string;
            }>;
            entities: Array<{
                id: string;
                connectionId: string;
                entityType: string;
                absolutePath: string;
            }>;
            owners: Array<{
                name: string;
                email: string;
                identities: Array<
                | {
                    type: "slack";
                    userId: string;
                }
                | {
                    type: "ms_teams";
                    userId: string;
                }
                | {
                    type: "dbt";
                    ownerId: string;
                }
                >;
            }>;
        }>;
        group: Array<{
            name: string;
            value: string;
        }> | null;
        linkedAt: string;
        resolvedAt: string | null;
        connections: Array<{
            name: string | null;
            type: string;
            id: string;
        }>;
    }>;
    /* affectedMonitors is deprecated. Please use affectedMonitors_v2 */
    affectedMonitors: Array<{
        id: string;
        monitor: {
            name: string | null;
            type: string;
            id: string;
            tags: Array<{
                name: string;
            }>;
            entityType: string;
            absolutePath: string;
            owners: Array<{
                name: string;
                email: string;
                identities: Array<
                | {
                    type: "slack";
                    userId: string;
                }
                | {
                    type: "ms_teams";
                    userId: string;
                }
                | {
                    type: "dbt";
                    ownerId: string;
                }
                >;
            }>;
        };
        group: Array<{
            name: string;
            value: string;
        }> | null;
        linkedAt: string;
        resolvedAt: string | null;
        connections: Array<{
            name: string | null;
            type: string;
            id: string;
        }>;
    }>;
};

Delivery semantics

The webhook systems conforms to at-least-once delivery semantics. This effectively means that you may receive the exact same event more than once — your 3rd party service should be idempotent so that this detail doesn't cause issues.

One straight-forward way of identifying duplicate payloads is by using the idempotencyId. Multiple payloads with the same idempotencyId will be identical. However, multiple payloads with different idempotencyIds aren't guaranteed to be different. That is, it's possible that 2 payloads have different idempotencyIds while the the rest of the payload content is the same.

affectedMonitors

affectedMonitors is now deprecated. Please use affectedMonitors_V2

The items within affectedMonitors_V2 represent monitors that have been implicated in the incident. Currently, you can expect that only monitors are affected.

Affected monitors may have a group value. If the group value is not null then it'll be an array of key-value pairs that identify a specific group. The group property will only be populated if the alerting monitor is a Group By monitor.

acknowledged and acknowledgedAt

As of September 16th, 2024 the acknowledged and acknowledgedAt fields are deprecated. While the payload fields will continue to exist for backward compatibility, there is no longer a way to affect the acknowledged state of an incident.

Instead, the labels field has superseded acknowledge functionality.

Example

{
  "id": "25674",
  "createdAt": "2024-03-18T14:10:56.757Z",
  "updatedAt": "2024-09-30T18:22:07.603Z",
  "idempotencyId": "MjU2NzQtMjAyNC0wOS0zMFQxODoyMjowNy42MDNa",
  "status": "opened",
  "acknowledged": false,
  "acknowledgedAt": null,
  "incidentUrl": "https://app.metaplane.dev/incident/25674",
  "affectedMonitors_v2": [
    {
      "id": "26084",
      "linkedAt": "2024-03-18T14:11:08.300Z",
      "resolvedAt": null,
      "monitor": {
        "id": "018e51e4-8ed9-7bc3-9aee-3d6011375577",
        "type": "CUSTOM",
        "name": "Group Monitor",
        "entities": [
          {
            "id": "1803f576-2b43-4f4b-b7a5-8d66a898d063",
            "connectionId": "a903aa1f-67d2-46c4-8a69-4553d3709151",
            "entityType": "database",
            "absolutePath": "DEMO_DB"
          }
        ],
        "owners": [
          {
            "name": "John Smith",
            "email": "j@metaplane.dev",
            "identities": [{ "type": "slack", "userId": "U07126QAF4X" }]
          }
        ],
        "tags": [{ "name": "dbt seed" }]
      },
      "group": [
        { "name": "group_id", "value": "7c70f325-80e2-4cda-8e06-104dc0a78270" }
      ],
      "connections": [
        {
          "id": "a903aa1f-67d2-46c4-8a69-4553d3709151",
          "type": "snowflake",
          "name": "Snowflake DB"
        }
      ]
    }
  ],
  "affectedMonitors": [
    {
      "id": "26084",
      "linkedAt": "2024-03-18T14:11:08.300Z",
      "resolvedAt": null,
      "monitor": {
        "id": "018e51e4-8ed9-7bc3-9aee-3d6011375577",
        "type": "CUSTOM",
        "name": "Group Monitor",
        "entityType": "database",
        "absolutePath": "DEMO_DB",
        "owners": [
          {
            "name": "John Smith",
            "email": "j@metaplane.dev",
            "identities": [{ "type": "slack", "userId": "U07126QAF4X" }]
          }
        ],
        "tags": [{ "name": "dbt seed" }]
      },
      "group": [
        { "name": "group_id", "value": "7c70f325-80e2-4cda-8e06-104dc0a78270" }
      ],
      "connections": [
        {
          "id": "a903aa1f-67d2-46c4-8a69-4553d3709151",
          "type": "snowflake",
          "name": "Snowflake DB"
        }
      ]
    }
  ],
  "labels": []
}