Skip to main content

Why verify signatures?

Webhook endpoints are public URLs. Without verification, anyone could send fake webhooks to your server. M2M signs every webhook with your unique secret, allowing you to verify that:
  1. The webhook came from M2M (authenticity)
  2. The payload wasn’t modified (integrity)
  3. The webhook isn’t a replay of an old request (freshness)
Always verify webhook signatures before processing. Never trust the payload without verification.

How signatures work

M2M uses HMAC-SHA256 to sign webhooks. The signature is computed from:
  • A timestamp (to prevent replay attacks)
  • The raw JSON payload

Signature format

The signature is sent in the X-M2M-Signature header:
sha256=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6

Algorithm

signed_payload = timestamp + "." + raw_json_body
signature = HMAC-SHA256(signed_payload, webhook_secret)
header_value = "sha256=" + hex(signature)

Verification steps

1

Extract the headers

Get the signature and timestamp from the request headers:
const signature = req.headers['x-m2m-signature'];
const timestamp = req.headers['x-m2m-timestamp'];
2

Get the raw body

You must use the raw request body before any JSON parsing. Most frameworks provide a way to access this:
// Express.js - use raw body parser
app.use('/webhooks', express.raw({ type: 'application/json' }));

// The raw body is now in req.body as a Buffer
const rawBody = req.body.toString();
3

Compute the expected signature

Construct the signed payload and compute the HMAC:
const signedPayload = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
  .createHmac('sha256', webhookSecret)
  .update(signedPayload)
  .digest('hex');
4

Compare signatures

Use a timing-safe comparison to prevent timing attacks:
const receivedSignature = signature.replace('sha256=', '');
const isValid = crypto.timingSafeEqual(
  Buffer.from(receivedSignature),
  Buffer.from(expectedSignature)
);
5

Validate timestamp (optional but recommended)

Reject webhooks with old timestamps to prevent replay attacks:
const webhookTime = parseInt(timestamp, 10) * 1000;
const now = Date.now();
const tolerance = 5 * 60 * 1000; // 5 minutes

if (Math.abs(now - webhookTime) > tolerance) {
  throw new Error('Webhook timestamp too old');
}

Implementation examples

Node.js (Express)

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

const app = express();

// IMPORTANT: Use raw body parser for webhook route
app.use('/webhooks/m2m', express.raw({ type: 'application/json' }));

const WEBHOOK_SECRET = process.env.M2M_WEBHOOK_SECRET;

function verifyWebhookSignature(req) {
  const signature = req.headers['x-m2m-signature'];
  const timestamp = req.headers['x-m2m-timestamp'];
  const rawBody = req.body.toString();
  
  if (!signature || !timestamp) {
    return false;
  }
  
  // Verify timestamp is recent (within 5 minutes)
  const webhookTime = parseInt(timestamp, 10) * 1000;
  const tolerance = 5 * 60 * 1000;
  if (Math.abs(Date.now() - webhookTime) > tolerance) {
    console.error('Webhook timestamp too old');
    return false;
  }
  
  // Compute expected signature
  const signedPayload = `${timestamp}.${rawBody}`;
  const expectedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(signedPayload)
    .digest('hex');
  
  // Timing-safe comparison
  const receivedSignature = signature.replace('sha256=', '');
  try {
    return crypto.timingSafeEqual(
      Buffer.from(receivedSignature),
      Buffer.from(expectedSignature)
    );
  } catch {
    return false;
  }
}

app.post('/webhooks/m2m', (req, res) => {
  if (!verifyWebhookSignature(req)) {
    console.error('Invalid webhook signature');
    return res.status(401).send('Invalid signature');
  }
  
  const event = JSON.parse(req.body);
  console.log('Verified webhook:', event.event);
  
  // Process the webhook...
  
  res.status(200).send('OK');
});

app.listen(3000);

Python (Flask)

import hmac
import hashlib
import time
from flask import Flask, request, abort

app = Flask(__name__)

WEBHOOK_SECRET = os.environ.get('M2M_WEBHOOK_SECRET')

def verify_webhook_signature(request):
    signature = request.headers.get('X-M2M-Signature', '')
    timestamp = request.headers.get('X-M2M-Timestamp', '')
    raw_body = request.get_data(as_text=True)
    
    if not signature or not timestamp:
        return False
    
    # Verify timestamp is recent (within 5 minutes)
    webhook_time = int(timestamp)
    tolerance = 5 * 60
    if abs(time.time() - webhook_time) > tolerance:
        print('Webhook timestamp too old')
        return False
    
    # Compute expected signature
    signed_payload = f'{timestamp}.{raw_body}'
    expected_signature = hmac.new(
        WEBHOOK_SECRET.encode('utf-8'),
        signed_payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()
    
    # Timing-safe comparison
    received_signature = signature.replace('sha256=', '')
    return hmac.compare_digest(received_signature, expected_signature)

@app.route('/webhooks/m2m', methods=['POST'])
def handle_webhook():
    if not verify_webhook_signature(request):
        print('Invalid webhook signature')
        abort(401)
    
    event = request.get_json()
    print(f'Verified webhook: {event["event"]}')
    
    # Process the webhook...
    
    return 'OK', 200

Python (FastAPI)

import hmac
import hashlib
import time
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

WEBHOOK_SECRET = os.environ.get('M2M_WEBHOOK_SECRET')

async def verify_webhook_signature(request: Request) -> bool:
    signature = request.headers.get('X-M2M-Signature', '')
    timestamp = request.headers.get('X-M2M-Timestamp', '')
    raw_body = await request.body()
    
    if not signature or not timestamp:
        return False
    
    # Verify timestamp is recent (within 5 minutes)
    webhook_time = int(timestamp)
    tolerance = 5 * 60
    if abs(time.time() - webhook_time) > tolerance:
        return False
    
    # Compute expected signature
    signed_payload = f'{timestamp}.{raw_body.decode()}'
    expected_signature = hmac.new(
        WEBHOOK_SECRET.encode('utf-8'),
        signed_payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()
    
    received_signature = signature.replace('sha256=', '')
    return hmac.compare_digest(received_signature, expected_signature)

@app.post('/webhooks/m2m')
async def handle_webhook(request: Request):
    if not await verify_webhook_signature(request):
        raise HTTPException(status_code=401, detail='Invalid signature')
    
    body = await request.json()
    print(f'Verified webhook: {body["event"]}')
    
    return {'status': 'OK'}

Go

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "io"
    "math"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

var webhookSecret = os.Getenv("M2M_WEBHOOK_SECRET")

func verifyWebhookSignature(r *http.Request, body []byte) bool {
    signature := r.Header.Get("X-M2M-Signature")
    timestamp := r.Header.Get("X-M2M-Timestamp")

    if signature == "" || timestamp == "" {
        return false
    }

    // Verify timestamp is recent (within 5 minutes)
    webhookTime, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return false
    }
    tolerance := int64(5 * 60)
    if math.Abs(float64(time.Now().Unix()-webhookTime)) > float64(tolerance) {
        return false
    }

    // Compute expected signature
    signedPayload := fmt.Sprintf("%s.%s", timestamp, string(body))
    mac := hmac.New(sha256.New, []byte(webhookSecret))
    mac.Write([]byte(signedPayload))
    expectedSignature := hex.EncodeToString(mac.Sum(nil))

    // Timing-safe comparison
    receivedSignature := strings.TrimPrefix(signature, "sha256=")
    return hmac.Equal([]byte(receivedSignature), []byte(expectedSignature))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Error reading body", http.StatusBadRequest)
        return
    }

    if !verifyWebhookSignature(r, body) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    fmt.Println("Verified webhook received")
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
}

func main() {
    http.HandleFunc("/webhooks/m2m", webhookHandler)
    http.ListenAndServe(":3000", nil)
}

Common mistakes

If you parse the JSON body before verifying the signature, the serialization might differ from the original payload.Wrong:
app.use(express.json());
app.post('/webhook', (req, res) => {
  const body = JSON.stringify(req.body); // Re-serialized - may differ!
  verify(body, signature);
});
Correct:
app.use('/webhook', express.raw({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
  const body = req.body.toString(); // Original raw body
  verify(body, signature);
});
Regular string comparison (===) can leak information through timing differences.Wrong:
if (receivedSignature === expectedSignature) // Vulnerable!
Correct:
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
Without timestamp validation, attackers can replay old webhooks.Always check that the timestamp is within an acceptable window (e.g., 5 minutes).
Never log your webhook secret. If you need to debug, log a hash or the last few characters.

Regenerating your secret

If your webhook secret is compromised:
  1. Go to the Partner Portal
  2. Navigate to Settings > Webhooks
  3. Click Regenerate Secret
  4. Update your server with the new secret immediately
The old secret becomes invalid immediately. Deploy your new secret before regenerating to avoid downtime.

Testing signature verification

You can test your implementation by generating a signature locally:
const crypto = require('crypto');

const secret = 'your_webhook_secret';
const timestamp = Math.floor(Date.now() / 1000).toString();
const payload = JSON.stringify({
  event: 'link.opened',
  timestamp: new Date().toISOString(),
  data: { linkId: 'test', referenceId: 'user_123' }
});

const signature = crypto
  .createHmac('sha256', secret)
  .update(`${timestamp}.${payload}`)
  .digest('hex');

console.log('X-M2M-Timestamp:', timestamp);
console.log('X-M2M-Signature:', `sha256=${signature}`);
console.log('Payload:', payload);

// Use these to test your endpoint with curl:
// curl -X POST http://localhost:3000/webhooks/m2m \
//   -H "Content-Type: application/json" \
//   -H "X-M2M-Timestamp: ${timestamp}" \
//   -H "X-M2M-Signature: sha256=${signature}" \
//   -d '${payload}'

Next steps

Webhook Events

See all event types and their payloads.

Data Requests

Learn how to respond to data request webhooks.