NewDash Dots are hereMini apps in Dash, with payments built inLearn more

Developer Resources · Webhooks

Testing Locally

Webhook URLs must be HTTPS and publicly reachable. localhost is not allowed. To test on your machine, expose your local server with a tunnel.

Option 1: ngrok

ngrok creates a public HTTPS URL that forwards to your local server.

Setup:

  1. Install ngrok: brew install ngrok (macOS) or download from https://ngrok.com
  2. Start your local server (for example on port 3000).
  3. Run: ngrok http 3000
  4. Copy the HTTPS URL (for example https://abc123.ngrok.io).

Create the webhook:

  1. Use the ngrok URL as the webhook URL: https://abc123.ngrok.io/webhooks/monime
  2. Create the webhook in the UI.
  3. Trigger an event or replay a delivery.
  4. Watch your local server logs for the request.

Caveats:

  • Free ngrok URLs change each time you restart ngrok.
  • Paid plans offer stable subdomains.

Option 2: localtunnel

localtunnel is a free alternative.

Terminal
npx localtunnel --port 3000

Use the generated HTTPS URL as your webhook URL.

Raw body for signature verification

Frameworks often parse JSON before your handler runs. Signature verification must use the raw body bytes. If you use parsed req.body, verification can fail.

Express (Node.js)

JavaScript
const express = require('express');
const crypto = require('crypto');

const app = express();
const secret = process.env.WEBHOOK_SECRET;

function parseSignatureHeader(header) {
  return Object.fromEntries(
    header.split(',').map((part) => {
      const [key, ...value] = part.split('=');
      return [key.trim(), value.join('=')];
    }),
  );
}

// Webhook route: keep raw body for verification
app.post('/webhooks/monime', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body;
  const signatureHeader = req.headers['monime-signature'];
  if (!signatureHeader) return res.status(401).send('Missing signature');

  const { t, v1 } = parseSignatureHeader(signatureHeader);
  if (!t || !v1) return res.status(401).send('Malformed signature');

  if (!/^\d+$/.test(t)) return res.status(401).send('Malformed timestamp');

  const timestamp = Number(t);
  const now = Math.floor(Date.now() / 1000);
  if (now - timestamp > 300 || timestamp - now > 60) {
    return res.status(401).send('Timestamp out of tolerance');
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}_`)
    .update(rawBody)
    .digest();

  const received = Buffer.from(v1, 'base64');

  if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
    return res.status(401).send('Invalid signature');
  }

  const payload = JSON.parse(rawBody.toString());
  // Process payload
  res.status(200).send('OK');
});

Important: register the webhook route before any global JSON body parser, or use a route-specific raw parser as shown.

Flask (Python)

Python
import hmac
import hashlib
import json
import base64
import os
import time
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/webhooks/monime', methods=['POST'])
def webhook():
    raw_body = request.get_data()
    signature_header = request.headers.get('monime-signature', '')
    secret = os.environ['WEBHOOK_SECRET'].encode()

    parts = dict(
        part.strip().split('=', 1)
        for part in signature_header.split(',')
        if '=' in part
    )
    timestamp = parts.get('t')
    signature = parts.get('v1')
    if not timestamp or not signature:
        return jsonify({'error': 'Malformed signature'}), 401

    try:
        timestamp_seconds = int(timestamp)
    except ValueError:
        return jsonify({'error': 'Malformed timestamp'}), 401
    now = int(time.time())
    if now - timestamp_seconds > 300 or timestamp_seconds - now > 60:
        return jsonify({'error': 'Timestamp out of tolerance'}), 401

    signed_payload = timestamp.encode() + b'_' + raw_body
    expected = base64.b64encode(
        hmac.new(secret, signed_payload, hashlib.sha256).digest()
    ).decode()
    if not hmac.compare_digest(expected, signature):
        return jsonify({'error': 'Invalid signature'}), 401

    payload = json.loads(raw_body)
    # Process payload
    return '', 200

Other frameworks:

  • FastAPI: await request.body()
  • Django: request.body
  • PHP: file_get_contents('php://input')

Quick test checklist

  • Tunnel is running and URL is HTTPS.
  • Webhook created with tunnel URL.
  • Endpoint uses raw body for verification.
  • Secret matches the webhook.
  • Endpoint returns 2xx.
  • Trigger an event or replay a delivery.
  • Check Event Deliveries for Success.

Debugging failed verification

If verification fails during local testing:

  1. Log the raw body length, not the body contents.
  2. Log whether monime-signature is present, not its full value.
  3. Confirm the signed payload is timestamp_rawBody and v1 is base64.
  4. Check middleware order (raw body must be preserved).
  5. Inspect a real delivery in the UI and test locally.

Testing without real events

If you cannot trigger a real event:

  1. Create the webhook with your tunnel URL.
  2. Use Replay on any existing delivery.
  3. Or send a manual POST with a computed signature using the same secret.

ngrok URL changes

Free ngrok URLs change when you restart ngrok. Options:

  1. Update the webhook URL if supported.
  2. Create a new webhook.
  3. Use a paid plan for a stable subdomain.

Keep reading