Signature Verification

Verify that incoming webhooks are authentic using HMAC-SHA256 signatures.

Why Verify Signatures?

Anyone who knows your webhook endpoint URL could send forged requests to it. Signature verification ensures that every webhook delivery genuinely originated from Deliverty Hub and has not been tampered with in transit. You should always verify the signature before processing a webhook payload.

How Signing Works

Deliverty Hub signs every webhook delivery using HMAC-SHA256. The signing process works as follows:

1

Generate timestamp

A Unix timestamp (seconds since epoch) is generated at the time of delivery.

2

Build the signed content

The signed content is formed by concatenating the timestamp and the JSON-stringified payload with a period separator: ${timestamp}.${JSON.stringify(payload)}

3

Compute HMAC-SHA256

The HMAC-SHA256 digest is computed over the signed content using your webhook subscription's secret key, producing a hex-encoded signature string.

4

Send in headers

The signature and timestamp are sent in the X-Webhook-Signature header in the format t=<timestamp>,v1=<hex_signature>. The timestamp is also sent separately in the X-Webhook-Timestamp header.

Signature Details

Property Value
Algorithm HMAC-SHA256
Secret format whsec_<random_base64url_string> (32 random bytes, base64url-encoded)
Signed content ${timestamp}.${JSON.stringify(payload)}
Signature header X-Webhook-Signature: t=<unix_timestamp>,v1=<hex_signature>
Timestamp header X-Webhook-Timestamp: <unix_timestamp>
Signature encoding Hexadecimal
Timestamp tolerance 5 minutes (300,000 ms) — reject if the timestamp differs from current time by more than this
Comparison method Timing-safe equality (prevents timing attacks)

Verification Steps

To verify an incoming webhook, your server should perform these steps in order:

  1. Extract the signature header. Read the X-Webhook-Signature header value and parse it to extract the t (timestamp) and v1 (signature) components.
  2. Check the timestamp. Compute the absolute difference between the extracted timestamp and your server's current time. If it exceeds 5 minutes (300 seconds), reject the request to prevent replay attacks.
  3. Reconstruct the signed content. Build the string ${timestamp}.${JSON.stringify(body)} where body is the raw JSON request body exactly as received.
  4. Compute the expected signature. Calculate the HMAC-SHA256 of the signed content using your webhook secret, and hex-encode the result.
  5. Compare signatures. Use a timing-safe comparison function to compare your computed signature with the v1 value from the header. If they match, the webhook is authentic.
Use the Raw Body

The signature is computed over the exact JSON string of the payload. If your framework parses the body and re-serializes it, key ordering or whitespace may differ, causing verification to fail. Always use the raw request body for signature verification, then parse it afterward.

Node.js Example

const crypto = require('crypto');

/**
 * Verify a Deliverty Hub webhook signature.
 *
 * @param {string} rawBody - Raw request body string (not parsed JSON)
 * @param {string} signatureHeader - Value of X-Webhook-Signature header
 * @param {string} secret - Your webhook subscription secret (whsec_...)
 * @returns {boolean} True if the signature is valid
 */
function verifyWebhookSignature(rawBody, signatureHeader, secret) {
  // Step 1: Parse the signature header
  const parts = signatureHeader.split(',');
  let timestamp = null;
  let signature = null;

  for (const part of parts) {
    const [key, value] = part.split('=');
    if (key === 't') {
      timestamp = parseInt(value, 10);
    } else if (key === 'v1') {
      signature = value;
    }
  }

  if (!timestamp || !signature) {
    console.error('Invalid signature header format');
    return false;
  }

  // Step 2: Check timestamp tolerance (5 minutes)
  const currentTime = Math.floor(Date.now() / 1000);
  const timeDiff = Math.abs(currentTime - timestamp);

  if (timeDiff > 300) {
    console.error(`Timestamp too old: ${timeDiff} seconds`);
    return false;
  }

  // Step 3: Reconstruct the signed content
  const signedContent = `${timestamp}.${rawBody}`;

  // Step 4: Compute expected signature
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedContent)
    .digest('hex');

  // Step 5: Timing-safe comparison
  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    );
  } catch (err) {
    // Lengths differ — signatures don't match
    return false;
  }
}

// Usage in an Express.js handler:
const express = require('express');
const app = express();

// IMPORTANT: Use raw body for signature verification
app.post('/webhooks/deliverty',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signatureHeader = req.headers['x-webhook-signature'];
    const secret = process.env.DELIVERTY_WEBHOOK_SECRET;

    if (!signatureHeader) {
      return res.status(401).json({ error: 'Missing signature' });
    }

    const rawBody = req.body.toString('utf-8');
    const isValid = verifyWebhookSignature(rawBody, signatureHeader, secret);

    if (!isValid) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // Signature is valid — process the event
    const event = JSON.parse(rawBody);
    console.log(`Received event: ${event.event}`, event.data);

    // Respond quickly with 200
    res.status(200).json({ received: true });

    // Process asynchronously if needed...
  }
);

Python Example

import hashlib
import hmac
import json
import time

def verify_webhook_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
    """
    Verify a Deliverty Hub webhook signature.

    Args:
        raw_body: Raw request body bytes
        signature_header: Value of X-Webhook-Signature header
        secret: Your webhook subscription secret (whsec_...)

    Returns:
        True if the signature is valid
    """
    # Step 1: Parse the signature header
    timestamp = None
    signature = None

    for part in signature_header.split(","):
        key, _, value = part.partition("=")
        if key == "t":
            timestamp = int(value)
        elif key == "v1":
            signature = value

    if timestamp is None or signature is None:
        print("Invalid signature header format")
        return False

    # Step 2: Check timestamp tolerance (5 minutes)
    current_time = int(time.time())
    time_diff = abs(current_time - timestamp)

    if time_diff > 300:
        print(f"Timestamp too old: {time_diff} seconds")
        return False

    # Step 3: Reconstruct the signed content
    body_str = raw_body.decode("utf-8")
    signed_content = f"{timestamp}.{body_str}"

    # Step 4: Compute expected signature
    expected_signature = hmac.new(
        secret.encode("utf-8"),
        signed_content.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    # Step 5: Timing-safe comparison
    return hmac.compare_digest(signature, expected_signature)


# Usage with Flask:
from flask import Flask, request, jsonify

app = Flask(__name__)

WEBHOOK_SECRET = "whsec_your_secret_here"

@app.route("/webhooks/deliverty", methods=["POST"])
def handle_webhook():
    signature_header = request.headers.get("X-Webhook-Signature")

    if not signature_header:
        return jsonify({"error": "Missing signature"}), 401

    raw_body = request.get_data()
    is_valid = verify_webhook_signature(raw_body, signature_header, WEBHOOK_SECRET)

    if not is_valid:
        return jsonify({"error": "Invalid signature"}), 401

    # Signature is valid — process the event
    event = json.loads(raw_body)
    print(f"Received event: {event['event']}", event["data"])

    # Respond quickly with 200
    return jsonify({"received": True}), 200

Replay Attack Protection

The timestamp included in the signature serves as protection against replay attacks. Even if an attacker captures a valid webhook delivery, they cannot replay it after the 5-minute tolerance window has passed. Your verification logic should always check the timestamp before comparing signatures.

The tolerance window is set to 5 minutes (300,000 ms) to account for reasonable network latency and clock drift between servers. If you need stricter protection, you can also store and check the X-Webhook-Id to reject duplicate deliveries.

Common Issues

Problem Cause Solution
Signature always fails The request body was parsed and re-serialized, changing key order or whitespace. Use the raw request body for verification. In Express, use express.raw(). In Flask, use request.get_data().
Timestamp rejected Your server's clock is significantly off from UTC. Synchronize your server clock with NTP. The tolerance is 5 minutes, so minor drift is acceptable.
Wrong secret Using the wrong secret or a stale secret after regeneration. Confirm you are using the correct secret for the specific webhook subscription. After regenerating a secret, update your verification code immediately.
Encoding mismatch Comparing base64-encoded signature with hex-encoded one, or vice versa. Deliverty Hub signatures are hex-encoded. Make sure your HMAC digest output is also hex.
Test Your Endpoint

Use the Test Webhook API endpoint to send a test payload to your URL with a valid signature. This lets you verify your signature checking code is working correctly before subscribing to real events.