Developer Resources · Webhooks
Verification and Security
Every webhook delivery includes a cryptographic signature that proves the request came from Monime and has not been tampered with. Verifying this signature is essential. An endpoint that processes unverified requests is vulnerable to attackers who can send forged events to manipulate your system.
This page explains both verification methods, provides code examples in multiple languages, and covers security best practices.
Why verification matters
Your webhook endpoint URL may be discoverable. Attackers who find it can send HTTP POST requests that look like Monime deliveries. Without signature verification, your endpoint cannot distinguish between authentic Monime requests and forged ones.
Consider what happens if an attacker sends a fake payment.processing_completed event. Your system might ship a product, credit an account, or update records based on a payment that never occurred. The consequences range from financial loss to data corruption.
Signature verification prevents this. Monime signs every delivery using a secret (for HMAC-SHA256) or a private key (for ECDSA P-256) that attackers do not have. Your endpoint verifies the signature before processing, rejecting any request that does not match.
HMAC-SHA256 verification
HMAC-SHA256 is the simpler of the two methods. You share a secret with Monime when you create the webhook. Monime computes a signature over a combination of a timestamp and the request body, then sends the result in the monime-signature header. Your endpoint recomputes the signature and compares.
How it works
When Monime sends a delivery, it performs this computation:
signed_payload = timestamp + "_" + raw_request_body
signature = base64_encode(HMAC-SHA256(secret, signed_payload))
header = "t=" + timestamp + ",v1=" + signatureThe monime-signature header looks like this:
t=1773386779,v1=eYpwGrKcNvG0PKXJBEZOYPLQfeUMZu6k+/klkPlL1y4=Your endpoint parses the header, reconstructs the signed payload using the timestamp and raw body joined by an underscore (_), computes the HMAC, and compares the result. If the signatures match, the request is authentic.
Setup
When you create a webhook with HMAC-SHA256 verification, Monime generates a random secret or accepts one you provide. Copy this secret from the webhook configuration page in the dashboard and store it securely on your server, such as in an environment variable or a secrets manager.
The secret must be 32 to 100 characters. A longer secret provides more security, but any value in this range is acceptable.
Replay protection
The t value in the signature header is the Unix timestamp (in seconds) of when Monime sent the delivery. Use it to reject stale or future-dated requests:
- Too old: reject if
now - t > 300(more than 5 minutes ago) - Too far in the future: reject if
t - now > 60(more than 60 seconds ahead)
This prevents attackers from replaying captured requests after they expire.
Verification steps
Read the raw body
Access the raw request body before any JSON parsing. If your framework parses the body automatically, verification will fail because re-serialized JSON may differ from the original bytes.
Parse the signature header
Read the monime-signature header. Split on , then = to extract the t (timestamp) and v1 (base64 signature) values.
Check the timestamp
Reject the request if the timestamp is outside your tolerance window.
Build the signed payload
Concatenate the timestamp, an underscore, and the raw body: t + "_" + rawBody.
Compute the expected signature
Calculate HMAC-SHA256(secret, signedPayload) using UTF-8 encoding. Output as base64 (not hex).
Compare signatures
Decode both the received v1 and your computed signature from base64 to bytes, then compare using a timing-safe function.
Node.js example
const crypto = require('crypto');
const express = require('express');
const app = express();
const secret = process.env.WEBHOOK_SECRET;
const MAX_AGE_SECONDS = 300;
const MAX_FUTURE_SECONDS = 60;
function parseSignatureHeader(header) {
const parts = {};
for (const pair of header.split(',')) {
const [key, ...rest] = pair.split('=');
parts[key.trim()] = rest.join('=');
}
return parts;
}
app.post('/webhooks/monime', express.raw({ type: 'application/json' }), (req, res) => {
const sigHeader = req.headers['monime-signature'];
if (!sigHeader) return res.status(401).send('Missing signature');
const rawBody = req.body;
const { t, v1 } = parseSignatureHeader(sigHeader);
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 > MAX_AGE_SECONDS || timestamp - now > MAX_FUTURE_SECONDS) {
return res.status(401).send('Timestamp out of tolerance');
}
const signedPayload = Buffer.concat([Buffer.from(`${t}_`), rawBody]);
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('base64');
const sigBuffer = Buffer.from(v1, 'base64');
const expectedBuffer = Buffer.from(expected, 'base64');
if (
sigBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(sigBuffer, expectedBuffer)
) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(rawBody.toString());
console.log('Processing event:', payload.event.name);
// ... your event handling logic ...
res.status(200).send('OK');
});
app.listen(3000);Key implementation details:
- Use
express.raw()middleware to preserve the raw request body. If you useexpress.json(), the body is parsed into an object, and re-serializing it may produce different bytes. - The signed payload is
timestamp_rawBodywith an underscore separator. - Use
digest('base64')— Monime uses base64 encoding, not hex. - Use
crypto.timingSafeEqual()for comparison. A naive===comparison returns early on the first mismatched byte, leaking timing information that attackers can exploit. - Return 401 Unauthorized if verification fails. Do not process the payload.
Python example
import base64
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request, abort
app = Flask(__name__)
secret = os.environ['WEBHOOK_SECRET'].encode()
MAX_AGE_SECONDS = 300
MAX_FUTURE_SECONDS = 60
def parse_signature_header(header):
parts = {}
for pair in header.split(','):
key, _, value = pair.partition('=')
parts[key.strip()] = value
return parts
@app.route('/webhooks/monime', methods=['POST'])
def webhook():
raw_body = request.get_data()
sig_header = request.headers.get('monime-signature', '')
parts = parse_signature_header(sig_header)
t = parts.get('t')
v1 = parts.get('v1')
if not t or not v1:
abort(401, 'Malformed signature')
try:
timestamp = int(t)
except ValueError:
abort(401, 'Malformed timestamp')
now = int(time.time())
if now - timestamp > MAX_AGE_SECONDS or timestamp - now > MAX_FUTURE_SECONDS:
abort(401, 'Timestamp out of tolerance')
signed_payload = t.encode() + b'_' + raw_body
expected = base64.b64encode(
hmac.new(secret, signed_payload, hashlib.sha256).digest()
).decode()
if not hmac.compare_digest(expected, v1):
abort(401, 'Invalid signature')
payload = json.loads(raw_body)
print(f"Processing event: {payload['event']['name']}")
# ... your event handling logic ...
return '', 200
if __name__ == '__main__':
app.run(port=3000)Flask's request.get_data() returns the raw bytes. Python's hmac.compare_digest() performs constant-time comparison. The signature is base64-encoded, not hex.
Go example
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var secret = []byte(os.Getenv("WEBHOOK_SECRET"))
const maxAgeSeconds = 300
const maxFutureSeconds = 60
func parseSignatureHeader(header string) (string, string) {
parts := make(map[string]string)
for _, pair := range strings.Split(header, ",") {
kv := strings.SplitN(pair, "=", 2)
if len(kv) == 2 {
parts[strings.TrimSpace(kv[0])] = kv[1]
}
}
return parts["t"], parts["v1"]
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
sigHeader := r.Header.Get("monime-signature")
t, v1 := parseSignatureHeader(sigHeader)
if t == "" || v1 == "" {
http.Error(w, "Malformed signature", http.StatusUnauthorized)
return
}
timestamp, err := strconv.ParseInt(t, 10, 64)
if err != nil {
http.Error(w, "Invalid timestamp", http.StatusUnauthorized)
return
}
now := time.Now().Unix()
if now-timestamp > maxAgeSeconds || timestamp-now > maxFutureSeconds {
http.Error(w, "Timestamp out of tolerance", http.StatusUnauthorized)
return
}
signedPayload := fmt.Sprintf("%s_%s", t, string(rawBody))
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(signedPayload))
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(v1), []byte(expected)) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
var payload map[string]interface{}
json.Unmarshal(rawBody, &payload)
// ... your event handling logic ...
w.WriteHeader(http.StatusOK)
}
func main() {
http.HandleFunc("/webhooks/monime", webhookHandler)
http.ListenAndServe(":3000", nil)
}Go's hmac.Equal() performs constant-time comparison. Note the use of base64.StdEncoding rather than hex encoding.
Common mistakes
Implementation checklist
- Raw body is available before any parsing middleware
- Header parsed as
t=[timestamp],v1=[base64]frommonime-signature - Replay protection: reject if age > 300 seconds or future > 60 seconds
- Signed payload:
timestamp_rawBody(underscore separator) - HMAC-SHA256 with secret, UTF-8 payload, base64 output
- Timing-safe comparison of decoded bytes
ECDSA P-256 verification
ECDSA P-256 uses asymmetric cryptography. Monime holds a private key and signs each delivery. You hold the corresponding public key and verify signatures. Because only Monime has the private key, only Monime can produce valid signatures.
When to use ECDSA P-256
ECDSA P-256 provides two advantages over HMAC-SHA256:
No shared secret. With HMAC-SHA256, both you and Monime know the secret. If your server is compromised, an attacker obtains the secret and can forge signatures. With ECDSA P-256, you only have the public key. Even if your server is compromised, the attacker cannot forge signatures because they do not have the private key.
Non-repudiation. You can prove to a third party that Monime signed a particular payload. With HMAC-SHA256, you cannot prove this because either party could have computed the signature.
The tradeoff is that ECDSA verification is more complex to implement correctly.
Setup
When you create a webhook with ECDSA P-256 verification, Monime generates a key pair. After creation, you can view and copy the public key from the webhook details page. The public key is in PEM format.
Store the public key on your server. Unlike the HMAC secret, the public key is not sensitive—anyone can have it. However, ensure you have the correct key for the webhook you are verifying.
How it works
Monime constructs the same signed payload used for HMAC-SHA256—timestamp_rawBody, with an underscore separator—and signs it using the private key. The signature uses the same header name and value layout:
monime-signature: t=1773386779,v1=MEUCIQD...Your endpoint:
- Parses the
ttimestamp and base64v1signature frommonime-signature - Rejects timestamps outside the accepted tolerance
- Reconstructs
timestamp_rawBody - Decodes the base64 signature and loads the public key
- Verifies the signature against the reconstructed payload
If verification succeeds, the request is authentic.
Node.js example
const crypto = require('crypto');
const express = require('express');
const app = express();
// PEM-encoded public key from the webhook details page
const publicKeyPem = process.env.WEBHOOK_PUBLIC_KEY;
const MAX_AGE_SECONDS = 300;
const MAX_FUTURE_SECONDS = 60;
function parseSignatureHeader(header) {
const parts = {};
for (const pair of header.split(',')) {
const [key, ...rest] = pair.split('=');
parts[key.trim()] = rest.join('=');
}
return parts;
}
app.post('/webhooks/monime', express.raw({ type: 'application/json' }), (req, res) => {
const signatureHeader = req.headers['monime-signature'];
if (!signatureHeader) return res.status(401).send('Missing signature');
const rawBody = req.body;
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 > MAX_AGE_SECONDS || timestamp - now > MAX_FUTURE_SECONDS) {
return res.status(401).send('Timestamp out of tolerance');
}
const signedPayload = Buffer.concat([Buffer.from(`${t}_`), rawBody]);
const signature = Buffer.from(v1, 'base64');
// Verify using the public key
const verifier = crypto.createVerify('SHA256');
verifier.update(signedPayload);
const isValid = verifier.verify(publicKeyPem, signature);
if (!isValid) {
console.error('ECDSA signature verification failed');
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(rawBody.toString());
console.log('Processing event:', payload.event.name);
// ... your event handling logic ...
res.status(200).send('OK');
});
app.listen(3000);Python example
import json
import os
import base64
import time
from flask import Flask, request, abort
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.serialization import load_pem_public_key
app = Flask(__name__)
# Load public key once at startup
public_key_pem = os.environ['WEBHOOK_PUBLIC_KEY'].encode()
public_key = load_pem_public_key(public_key_pem)
MAX_AGE_SECONDS = 300
MAX_FUTURE_SECONDS = 60
def parse_signature_header(header):
parts = {}
for pair in header.split(','):
key, _, value = pair.partition('=')
parts[key.strip()] = value
return parts
@app.route('/webhooks/monime', methods=['POST'])
def webhook():
raw_body = request.get_data()
signature_header = request.headers.get('monime-signature', '')
parts = parse_signature_header(signature_header)
timestamp_value = parts.get('t')
signature_base64 = parts.get('v1')
if not timestamp_value or not signature_base64:
abort(401, 'Malformed signature')
try:
timestamp = int(timestamp_value)
except ValueError:
abort(401, 'Malformed timestamp')
now = int(time.time())
if now - timestamp > MAX_AGE_SECONDS or timestamp - now > MAX_FUTURE_SECONDS:
abort(401, 'Timestamp out of tolerance')
signed_payload = timestamp_value.encode() + b'_' + raw_body
try:
signature = base64.b64decode(signature_base64)
public_key.verify(signature, signed_payload, ec.ECDSA(hashes.SHA256()))
except Exception:
abort(401, 'Invalid signature')
payload = json.loads(raw_body)
print(f"Processing event: {payload['event']['name']}")
# ... your event handling logic ...
return '', 200
if __name__ == '__main__':
app.run(port=3000)This example uses the cryptography library. Install it with pip install cryptography.
Security best practices
Always verify signatures
This cannot be overstated. Every request to your webhook endpoint must have its signature verified before you process the payload. There are no legitimate reasons to skip verification in production.
Use the raw request body
Compute signatures over the raw bytes of the request body, not a parsed and re-serialized JSON object. JSON libraries may reorder keys, change whitespace, or alter number formatting. Any change to the bytes produces a different signature.
This is why the examples use middleware that preserves raw bytes (express.raw() in Node.js, request.get_data() in Flask).
Enforce replay protection
Always check the t timestamp from the monime-signature header. Reject requests older than 5 minutes or more than 60 seconds in the future. Without this check, an attacker who captures a valid request can replay it indefinitely.
Use constant-time comparison
A naive string comparison (===, ==, strcmp) returns as soon as it finds a mismatched character. An attacker can measure how long the comparison takes and deduce characters one at a time.
Always use constant-time comparison functions: crypto.timingSafeEqual() in Node.js, hmac.compare_digest() in Python, hmac.Equal() in Go, or equivalent in your language.
Store secrets securely
For HMAC-SHA256 webhooks, the secret is sensitive. Store it in an environment variable, a secrets manager (like AWS Secrets Manager, HashiCorp Vault, or Google Secret Manager), or an encrypted configuration file. Do not commit secrets to source control.
Do not log sensitive data
Avoid logging the full webhook payload in production. Payloads may contain customer data, financial information, or other sensitive content. If you need to log for debugging, redact sensitive fields.
Handle verification failures gracefully
Return 401 Unauthorized when verification fails. Do not include details about why verification failed in the response body—this could help attackers.
Log verification failures internally so you can investigate. A sudden increase in failures might indicate an attack or a misconfiguration.
Custom headers for additional authentication
Custom headers let you add another layer of authentication. For example, your endpoint might require an API key in addition to the Monime signature.
When you create a webhook, you can add up to ten custom headers. Monime includes these headers in every delivery.
Authorization: Bearer your-api-token
X-API-Key: your-api-keyYour endpoint checks the custom header before or after signature verification. Custom headers are useful when your endpoint is behind an API gateway that requires authentication.
Remember that custom headers complement signature verification; they do not replace it. An attacker who obtains your API key still cannot forge signatures without the webhook secret.
Rotating secrets
There is no in-place secret rotation for HMAC-SHA256 webhooks in the dashboard. To rotate a secret, follow this process:
- Create a new webhook with the same configuration but a new secret.
- Update your endpoint to accept signatures from both the old and new secrets. Verify against the old secret first; if that fails, verify against the new secret.
- Test the new webhook by triggering events and confirming deliveries succeed.
- Disable or delete the old webhook.
- Update your endpoint to remove support for the old secret.
This approach ensures zero downtime during rotation. Events delivered during the transition are verified against whichever secret matches.
For ECDSA P-256 webhooks, rotation follows the same pattern: create a new webhook with a new key pair, update your endpoint to accept both public keys, then retire the old webhook.
Handling verification failures in your application
When your endpoint receives a request that fails verification, it should:
- Return 401 Unauthorized.
- Log the failure with enough context to investigate (timestamp, source IP, headers).
- Not process the payload in any way.
- Not expose internal error details in the response.
if (!verifySignature(req.headers['monime-signature'], rawBody, secret)) {
console.error('Webhook verification failed', {
timestamp: new Date().toISOString(),
ip: req.ip,
userAgent: req.headers['user-agent']
});
return res.status(401).send('Unauthorized');
}Related topics
- Testing locally explains how to verify signatures during development.
- Troubleshooting covers diagnosing signature verification failures.