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:
- Install ngrok:
brew install ngrok(macOS) or download from https://ngrok.com - Start your local server (for example on port 3000).
- Run:
ngrok http 3000 - Copy the HTTPS URL (for example
https://abc123.ngrok.io).
Create the webhook:
- Use the ngrok URL as the webhook URL:
https://abc123.ngrok.io/webhooks/monime - Create the webhook in the UI.
- Trigger an event or replay a delivery.
- 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.
npx localtunnel --port 3000Use 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)
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)
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 '', 200Other 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:
- Log the raw body length, not the body contents.
- Log whether
monime-signatureis present, not its full value. - Confirm the signed payload is
timestamp_rawBodyandv1is base64. - Check middleware order (raw body must be preserved).
- Inspect a real delivery in the UI and test locally.
Testing without real events
If you cannot trigger a real event:
- Create the webhook with your tunnel URL.
- Use Replay on any existing delivery.
- 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:
- Update the webhook URL if supported.
- Create a new webhook.
- Use a paid plan for a stable subdomain.