Validate Deliveries

This guide explains how to validate Clearout webhook deliveries using HMAC signature verification. Proper validation ensures that webhook requests are authentic and haven't been tampered with, protecting your application from malicious requests.

Why Validate Webhooks

Webhook validation is crucial for security and includes protection against:

  • Replay Attacks - Timestamps prevent old requests from being replayed
  • Request Tampering - HMAC signatures ensure data integrity
  • Unauthorized Requests - Only requests with valid signatures are processed
  • Data Spoofing - Verification confirms requests originate from Clearout
Important

Always validate webhook signatures in production. Never process webhook data without proper verification, as this could lead to security vulnerabilities and data corruption.

Signature Format

Clearout sends webhook signatures in the x-co-webhook-signature header with the following format:

t=,v1=

Signature Components

  • t - Unix timestamp (UTC) when the webhook was created
  • v1 - HMAC-SHA256 signature of the payload

How Signatures Are Generated

Clearout generates signatures using the following process:

  1. Create a string by concatenating the timestamp and raw JSON payload: timestamp.payload
  2. Generate HMAC-SHA256 hash using your webhook secret token
  3. Encode the hash as a hexadecimal string
  4. Include both timestamp and signature in the header

General Instructions for Any Language

When implementing webhook verification in any programming language, follow these core steps:

Step 1: Parse Signature Header

Extract the timestamp and signature from the x-co-webhook-signature header:

Header format: t=,v1=
Example: t=1691234567,v1=abc123def456...

Step 2: Construct Data String

Create the data string for HMAC calculation using this exact format:

data = timestamp + "." + raw_webhook_body
Critical: Use Raw Body

Always use the raw request body as received, before any parsing or modifications:

  • Before JSON parsing - Use the raw string before converting to JSON objects
  • Before any processing - Don't modify, trim, or reformat the body
  • Exact match required - The body must match exactly what was sent by Clearout
  • Preserve formatting - Maintain original whitespace, indentation, and field ordering

Step 3: Calculate HMAC

Generate HMAC SHA256 signature using your webhook secret:

expected_signature = HMAC_SHA256(data_string, webhook_secret)

Step 4: Verify Signature

Compare signatures using constant-time comparison to prevent timing attacks:

if (constant_time_compare(expected_signature, received_signature)) {
    // Signature is valid
}

Step 5: Validate Timestamp (Optional)

Check that the timestamp is recent to prevent replay attacks (optional but recommended):

current_time = get_current_unix_timestamp()
age = current_time - webhook_timestamp

if (age > 120) { // >2 minutes old (recommended)
    // Reject webhook
}
Recommended Time Window

We recommend validating timestamps within a 2-minute window, but you can choose any grace period between 2-5 minutes based on your security requirements and system performance needs. This validation is optional but helps prevent replay attacks.

Step 6: Return Response

Always return HTTP 200 status for successful webhook processing:

HTTP/1.1 200 OK
Content-Type: application/json

{"status": "success"}
Important: HTTP Status Codes

Your webhook endpoint must return HTTP 200 for successful processing. Any other status code will cause Clearout to retry the webhook delivery according to the retry schedule.

JavaScript Example

import express, { Request, Response } from "express";
import crypto from "crypto";

const app = express();
const port = 3000;

// Parse JSON body
app.use(express.json());

// Webhook signature verification function
function verifySignature(secret, body, signatureHeader) {
  try {
    const [tPart, v1Part] = signatureHeader.split(",");
    
    if (!tPart || !v1Part) {
      console.log("Invalid signature header format");
      return false;
    }
    
    const timestamp = tPart.split("=")[1];
    const receivedSig = v1Part.split("=")[1];
    
    if (!timestamp || !receivedSig) {
      console.log("Missing timestamp or signature in header");
      return false;
    }

    const data = `${timestamp}.${JSON.stringify(body)}`;
    const expectedSig = crypto
      .createHmac("sha256", secret)
      .update(data, "utf8")
      .digest("hex");

    if (expectedSig !== receivedSig) {
      console.log("Signature verification failed");
      return false;
    }
    
    console.log("Signature verified");
    return true;
  } catch (error) {
    console.log("Error verifying signature:", error);
    return false;
  }
}

// Timestamp validation function
function verifyTimestamp(signatureHeader, maxAgeSeconds = 120) {
  try {
    const [tPart] = signatureHeader.split(",");
    
    if (!tPart) {
      console.log("Invalid signature header format");
      return false;
    }
    
    const timestamp = tPart.split("=")[1];
    
    if (!timestamp) {
      console.log("Missing timestamp in header");
      return false;
    }

    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
    
    if (age <  0) {
      console.log("Timestamp is in the future");
      return false;
    }
    
    if (age > maxAgeSeconds) {
      console.log(`Timestamp too old: ${age} seconds (max: ${maxAgeSeconds})`);
      return false;
    }
    
    console.log("Timestamp verified");
    return true;
  } catch (error) {
    console.log("Error verifying timestamp:", error);
    return false;
  }
}

// Webhook endpoint
app.post("/webhook", (req, res) => {
  const secret = process.env.WEBHOOK_SECRET || "your-webhook-secret";

  // Extract signature header safely
  const signatureHeader = req.headers["x-co-webhook-signature"];
  
  if (typeof signatureHeader !== "string") {
    return res
      .status(400)
      .json({ status: "error", message: "Missing signature header" });
  }

  // Verify signature and timestamp
  const isValidSignature = verifySignature(secret, req.body, signatureHeader);
  const isValidTimestamp = verifyTimestamp(signatureHeader, 120); // 2 minutes

  if (!isValidSignature) {
    return res
      .status(401)
      .json({ status: "error", message: "Invalid signature" });
  }

  if (!isValidTimestamp) {
    return res
      .status(401)
      .json({ status: "error", message: "Timestamp too old" });
  }

  // Process webhook data here
  console.log("Webhook processed successfully:", req.body);

  // Return 200 OK to confirm successful processing
  res.json({
    status: "OK",
    message: "Webhook received and processed",
    signatureValid: isValidSignature,
    timestampValid: isValidTimestamp,
  });
});

app.listen(port, () => {
  console.log(`Webhook server listening on port ${port}`);
});
Key Features
  • Signature Verification - Validates HMAC-SHA256 signature
  • Timestamp Validation - Prevents replay attacks (2-minute window)
  • Error Handling - Proper error responses for different failure scenarios
  • Security - Uses environment variables for secrets
  • Logging - Detailed console output for debugging

Python Example

from flask import Flask, request, jsonify
import hmac
import hashlib
import time
import os

app = Flask(__name__)

def verify_signature(secret, body, signature_header):
    """
    Verify HMAC SHA256 signature using raw body (exact string sent in request).
    Header format: "t=,v1="
    """
    try:
        t_part, v1_part = signature_header.split(",")
        timestamp = t_part.split("=")[1]
        received_sig = v1_part.split("=")[1]

        # Must use raw request body (string), not re-dumped JSON
        data = f"{timestamp}.{body}"
        expected_sig = hmac.new(
            secret.encode("utf-8"),
            data.encode("utf-8"),
            hashlib.sha256,
        ).hexdigest()

        print("body:", body)
        print("secret:", secret)
        print("timestamp:", timestamp)
        print("receivedSig:", received_sig)
        print("expectedSig:", expected_sig)

        # Verify signature match
        if not hmac.compare_digest(expected_sig, received_sig):
            return False
        print("Signature verified")

        return True
    except Exception as e:
        print("Error in verify_signature:", e)
        return False

def verify_timestamp(signature_header):
    """
    Verify timestamp freshness (reject older than 2 minutes).
    """
    try:
        t_part, _ = signature_header.split(",")
        timestamp = int(t_part.split("=")[1])
        print("timestamp:", timestamp)

        age = int(time.time()) - timestamp
        print("age:", age)
        if age > 120:  # >2 minutes old (recommended)
            return False
        print("Timestamp verified")

        return True
    except Exception as e:
        print("Error in verify_timestamp:", e)
        return False

@app.route("/")
def hello_world():
    return "Hello, World!"

@app.route("/webhook", methods=["POST"])
def webhook():
    signature_header = request.headers.get("x-co-webhook-signature")
    print("signatureHeader:", signature_header)
    raw_body = request.get_data(as_text=True)
    secret = os.environ.get("WEBHOOK_SECRET", "your-secret-here")

    if not signature_header:
        return jsonify({"status": "error", "message": "Missing signature header"}), 400

    # Verify signature and timestamp
    is_valid_signature = verify_signature(secret, raw_body, signature_header)
    is_valid_timestamp = verify_timestamp(signature_header)

    print("isValidSignature:", is_valid_signature)
    print("isValidTimestamp:", is_valid_timestamp)

    if not (is_valid_signature and is_valid_timestamp):
        return jsonify({
            "status": "error",
            "message": "Invalid signature or timestamp",
            "signatureValid": is_valid_signature,
            "timestampValid": is_valid_timestamp,
        }), 400

    return jsonify({
        "status": "OK",
        "message": "Webhook received",
        "signatureValid": is_valid_signature,
        "timestampValid": is_valid_timestamp,
    })

if __name__ == "__main__":
    app.run()
Important Notes for Python
  • Raw Body - Use request.get_data(as_text=True) to get the exact raw body string
  • JSON Stringify - The raw body must match exactly what was sent, including whitespace and formatting
  • HMAC Compare - Use hmac.compare_digest() for secure signature comparison
  • Error Handling - Always wrap signature verification in try-catch blocks
  • Environment Variables - Store your webhook secret in environment variables for security

PHP Example


              <?php
function verify_signature($secret, $body, $signature_header) {
    // Parse the signature header
    $parts = explode(',', $signature_header);
    $timestamp = explode('=', $parts[0])[1];
    $received_sig = explode('=', $parts[1])[1];
    
    // Create the expected signature
    $data = $timestamp . '.' . json_encode($body);
    $expected_sig = hash_hmac('sha256', $data, $secret);
    
    // Verify signature match
    if (!hash_equals($expected_sig, $received_sig)) {
        return false;
    }
    
    return true;
}

function verify_timestamp($signature_header) {
    // Parse the signature header
    $parts = explode(',', $signature_header);
    $timestamp = intval(explode('=', $parts[0])[1]);
    
    // Verify timestamp freshness (reject older than 2 minutes)
    $age = time() - $timestamp;
    if ($age > 120) { // >2 minutes old (recommended)
        return false;
    }
    
    return true;
}

// Usage example
$signature = $_SERVER['HTTP_X_CO_WEBHOOK_SIGNATURE'] ?? '';
$secret = $_ENV['WEBHOOK_SECRET'] ?? 'your-secret-here';

if (!$signature) {
    http_response_code(400);
    echo json_encode(['error' => 'Missing signature header']);
    exit;
}

$body = json_decode(file_get_contents('php://input'), true);

if (!verify_signature($secret, $body, $signature) || !verify_timestamp($signature)) {
    http_response_code(400);
    echo json_encode(['error' => 'Invalid signature or timestamp']);
    exit;
}

// Process the webhook
echo json_encode(['status' => 'success']);
?>
Important Notes for PHP
  • Environment Variables - Store your webhook secret in environment variables for security
  • Hash Equals - Use hash_equals() for secure signature comparison
  • Timestamp Validation - Validates timestamps within 2 minutes (recommended range: 2-5 minutes)
  • Error Handling - Always validate signature header exists before processing

Security Best Practices

Secret Management

  • Store securely - Never hardcode secrets in your application code
  • Use environment variables - Store secrets in environment variables or secure key management systems
  • Rotate regularly - Update your webhook secret periodically for enhanced security
  • Access control - Limit access to webhook secrets to authorized personnel only

Validation Requirements

  • Always validate - Never skip signature validation, even in development
  • Check timestamps - Reject requests older than 5 minutes to prevent replay attacks
  • Use constant-time comparison - Use secure comparison functions to prevent timing attacks
  • Return appropriate status codes - Return HTTP 200 for successful processing

Timestamp Validation (Optional)

Verify that the webhook timestamp is recent to prevent replay attacks:

  • Extract timestamp from the signature header
  • Calculate age by comparing with current time
  • Reject old requests beyond your chosen time window
  • UTC timezone - All timestamps are in UTC
Recommended Time Window

We recommend validating timestamps within a 2-minute window, but you can choose any grace period between 2-5 minutes based on your security requirements and system performance needs.

HTTP Status Codes

Your webhook endpoint must return appropriate HTTP status codes to indicate successful processing:

Successful Response

  • 200 OK - Webhook processed successfully
  • Response Time - Must respond within 30 seconds
Important

You must return a 200 status code for the webhook delivery to be considered successful. Any other status code (4xx or 5xx) will cause the webhook to be retried after a certain time interval. Only a 200 response will mark the webhook as delivered and stop the retry process.

What Triggers Retries

  • 4xx Client Errors - Bad request, unauthorized, forbidden, not found, etc.
  • 5xx Server Errors - Internal server error, service unavailable, etc.
  • Timeout - No response within 30 seconds
  • Connection Errors - Network issues, DNS failures, etc.
Retry Behavior

When your endpoint returns a non-200 status code, Clearout will automatically retry the webhook delivery using exponential backoff. The retry schedule depends on your account type. See our Redelivering Webhooks guide for detailed retry timing information.

Next Steps

Now that you can validate webhook deliveries, learn how to test your webhook integration and understand webhook retry behavior.