# Verification and Security (/docs/developer-resources/guides/webhook/hmac-verification)

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:

```text
signed_payload = timestamp + "_" + raw_request_body
signature = base64_encode(HMAC-SHA256(secret, signed_payload))
header = "t=" + timestamp + ",v1=" + signature
```

The `monime-signature` header looks like this:

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

> **Warning:** The signed payload uses an **underscore** (`_`) as the separator between the timestamp and body, not a period (`.`). Using the wrong separator will cause verification to fail silently.

### 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.

> **Note:** Use the secret from the **webhook configuration**, not your API key. These are different credentials.

### 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

1. **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.

2. **Parse the signature header**

   Read the `monime-signature` header. Split on `,` then `=` to extract the `t` (timestamp) and `v1` (base64 signature) values.

3. **Check the timestamp**

   Reject the request if the timestamp is outside your tolerance window.

4. **Build the signed payload**

   Concatenate the timestamp, an underscore, and the raw body: `t + "_" + rawBody`.

5. **Compute the expected signature**

   Calculate `HMAC-SHA256(secret, signedPayload)` using UTF-8 encoding. Output as **base64** (not hex).

6. **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

```javascript
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 use `express.json()`, the body is parsed into an object, and re-serializing it may produce different bytes.
- The signed payload is `timestamp_rawBody` with 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

```python
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

```go
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

**Signing only the raw body**

The signed payload is `timestamp_rawBody`, not `rawBody` alone. If you omit the timestamp prefix, the computed signature will never match.

**Using a period separator instead of underscore**

Some providers (like Stripe) use `timestamp.body`. Monime uses `timestamp_body` with an **underscore**. Using a period causes silent verification failure.

**Outputting the HMAC as hex instead of base64**

Monime encodes signatures in base64. If you call `.digest('hex')` or `hexdigest()`, the output will not match the `v1` value in the header.

**Using the event body timestamp instead of the header timestamp**

The `t` value comes from the `monime-signature` header, not from `event.timestamp` or any field inside the JSON body. They are different values.

**Parsing or normalizing JSON before verification**

Any modification to the raw body—sorting keys, pretty-printing, re-serializing—changes the bytes and invalidates the signature. Always verify against the raw bytes as received.

### Implementation checklist

- Raw body is available before any parsing middleware
- Header parsed as `t=[timestamp],v1=[base64]` from `monime-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:

```http
monime-signature: t=1773386779,v1=MEUCIQD...
```

Your endpoint:

1. Parses the `t` timestamp and base64 `v1` signature from `monime-signature`
2. Rejects timestamps outside the accepted tolerance
3. Reconstructs `timestamp_rawBody`
4. Decodes the base64 signature and loads the public key
5. Verifies the signature against the reconstructed payload

If verification succeeds, the request is authentic.

### Node.js example

```javascript
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

```python
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-key
```

Your 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:

1. Create a new webhook with the same configuration but a new secret.
2. 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.
3. Test the new webhook by triggering events and confirming deliveries succeed.
4. Disable or delete the old webhook.
5. 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:

1. Return 401 Unauthorized.
2. Log the failure with enough context to investigate (timestamp, source IP, headers).
3. Not process the payload in any way.
4. Not expose internal error details in the response.

```javascript
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](/docs/developer-resources/guides/webhook/testing-locally) explains how to verify signatures during development.
- [Troubleshooting](/docs/developer-resources/guides/webhook/troubleshoot) covers diagnosing signature verification failures.
