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
- Fixify constructs a signed string:
${timestamp}.${rawBody} - Signs it with HMAC-SHA256 using your signing secret
- 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
- Extract the
tandv1values from the header - Get the raw request body as a string (do not parse/re-serialize)
- Compute:
HMAC-SHA256(secret, "${t}.${rawBody}") - Compare your computed HMAC with
v1using a timing-safe comparison - Optionally: reject if
tis 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 |