Skip to content

Signature Verification

Every webhook delivery includes an X-Fixify-Signature header that you should use to verify the request is authentic and hasn't been tampered with.

How It Works

  1. Fixify constructs a signed string: ${timestamp}.${rawBody}
  2. Signs it with HMAC-SHA256 using your signing secret
  3. Sends the header: t=<timestamp>,v1=<hmac-hex>

To verify, you reconstruct the HMAC on your side and compare.

Header Format

X-Fixify-Signature: t=1718000000000,v1=5d41402abc4b2a76b9719d911017c592abcdef1234567890abcdef1234567890
Part Description
t Epoch milliseconds timestamp of when the signature was generated
v1 Hex-encoded HMAC-SHA256 digest

Verification Steps

  1. Extract the t and v1 values from the header
  2. Get the raw request body as a string (do not parse/re-serialize)
  3. Compute: HMAC-SHA256(secret, "${t}.${rawBody}")
  4. Compare your computed HMAC with v1 using a timing-safe comparison
  5. Optionally: reject if t is too old (e.g., > 5 minutes) to prevent replay attacks

Code Examples

Node.js

const crypto = require("crypto");

function verifyWebhookSignature(req, signingSecret) {
    const signatureHeader = req.headers["x-fixify-signature"];
    if (!signatureHeader) {
        return false;
    }

    // Parse header
    const parts = Object.fromEntries(
        signatureHeader.split(",").map(part => {
            const [key, ...rest] = part.split("=");
            return [key, rest.join("=")];
        })
    );

    const timestamp = parts.t;
    const expectedSignature = parts.v1;

    if (!timestamp || !expectedSignature) {
        return false;
    }

    // Optional: reject old timestamps (5 minute tolerance)
    const age = Date.now() - parseInt(timestamp, 10);
    if (age > 5 * 60 * 1000) {
        return false; // Possible replay attack
    }

    // Compute signature
    const rawBody =
        typeof req.body === "string" ? req.body : JSON.stringify(req.body);

    const computedSignature = crypto
        .createHmac("sha256", signingSecret)
        .update(`${timestamp}.${rawBody}`)
        .digest("hex");

    // Timing-safe comparison
    return crypto.timingSafeEqual(
        Buffer.from(computedSignature, "hex"),
        Buffer.from(expectedSignature, "hex")
    );
}

// Express middleware
app.post("/webhooks/fixify", (req, res) => {
    const isValid = verifyWebhookSignature(
        req,
        process.env.FIXIFY_SIGNING_SECRET
    );

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

    // Process the webhook
    const event = req.body;
    console.log(
        `Received ${event.eventType} for ${event.entityType}/${event.entityId}`
    );

    res.status(200).send("OK");
});

Python

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

app = Flask(__name__)
SIGNING_SECRET = "your-signing-secret"

def verify_webhook_signature(request, signing_secret):
    signature_header = request.headers.get("X-Fixify-Signature")
    if not signature_header:
        return False

    # Parse header
    parts = {}
    for part in signature_header.split(","):
        key, _, value = part.partition("=")
        parts[key] = value

    timestamp = parts.get("t")
    expected_signature = parts.get("v1")

    if not timestamp or not expected_signature:
        return False

    # Optional: reject old timestamps (5 minute tolerance)
    age_ms = int(time.time() * 1000) - int(timestamp)
    if age_ms > 5 * 60 * 1000:
        return False  # Possible replay attack

    # Compute signature
    raw_body = request.get_data(as_text=True)
    message = f"{timestamp}.{raw_body}"
    computed_signature = hmac.new(
        signing_secret.encode("utf-8"),
        message.encode("utf-8"),
        hashlib.sha256
    ).hexdigest()

    # Timing-safe comparison
    return hmac.compare_digest(computed_signature, expected_signature)


@app.route("/webhooks/fixify", methods=["POST"])
def handle_webhook():
    if not verify_webhook_signature(request, SIGNING_SECRET):
        abort(401)

    event = request.get_json()
    print(f"Received {event['eventType']} for {event['entityType']}/{event['entityId']}")

    return "OK", 200

cURL (Manual Testing)

Generate a test signature to verify your implementation:

# Variables
SECRET="your-signing-secret"
TIMESTAMP=$(date +%s%3N)
BODY='{"webhookId":"test","eventType":"job_created","entityType":"job","entityId":"123","parentEntityId":null,"createdById":"agent-1","createdByRole":"Agent","payload":null,"createdOn":1718000000000,"deliveredAt":1718000000000,"attempt":1}'

# Compute signature
SIGNATURE=$(echo -n "${TIMESTAMP}.${BODY}" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

# Send test request
curl -X POST http://localhost:3000/webhooks/fixify \
  -H "Content-Type: application/json" \
  -H "X-Fixify-Signature: t=${TIMESTAMP},v1=${SIGNATURE}" \
  -d "$BODY"

Important Notes

Use the raw body

Always use the raw request body string for signature computation. If you parse the JSON and re-serialize it, whitespace or key ordering differences will cause verification to fail.

Timing-safe comparison

Always use a constant-time comparison function (crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python) to prevent timing attacks.

Replay protection

Check the t timestamp and reject requests older than 5 minutes. This prevents attackers from replaying captured webhook deliveries.

Troubleshooting

Issue Cause Solution
Signature always fails Re-serialized body Use raw body string, not JSON.stringify(parsed)
Intermittent failures Body middleware parsing Ensure raw body is preserved before JSON parsing
Old timestamps rejected Clock drift Use NTP sync; consider 10-minute tolerance
Wrong secret Recently rotated Update secret after calling rotate-secret endpoint