# Testing Locally (/docs/developer-resources/guides/webhook/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](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.

```bash
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.
