Verify that events are originating from Routific
Signature Validation
Routific signs every webhook request with x-routific-signatureheader so you can verify that the payload came from Routific and has not been tampered with. Signature validation is optional on your end — but strongly recommended for production integrations.
Every webhook request includes a signature header. Use the signing secret from your webhook settings to validate it against the raw request body before processing the payload.
If a signature does not match, discard the request.
How it works
The signature is an HMAC-SHA256 hash of the raw request body, signed with your webhook's signing secret.
HMAC-SHA256(secret, "${body}")
The x-routific-signature header value follows the format:
v0=${signature}
To verify:
- Extract the signature from the header
- Compute the expected signature using your secret
- Compare signatures using a constant-time comparison
- Optionally, reject requests with old timestamps to prevent replay attacks
Use raw request body
Always verify against the raw, unparsed request body. Parsing the JSON and re-serializing it before signing will produce a different hash and fail verification.
Rotating Secret
During a secret rotation process, two comma-separated signatures may be present — your verification should accept either:
x-routific-signature: v0=<previous_signature>,<new_signature>
Code Samples for Verifying Events
const crypto = require("crypto");
function verifySignature(rawBody, signatureHeader, secret) {
if (!signatureHeader.startsWith("v0=")) return false;
const signatures = signatureHeader.slice("v0=".length).split(",").map(s => s.trim());
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const expectedBuf = Buffer.from(expected, "utf8");
return signatures.some(sig => {
const sigBuf = Buffer.from(sig, "utf8");
return sigBuf.length === expectedBuf.length && crypto.timingSafeEqual(sigBuf, expectedBuf);
});
}
// Express.js example
app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const isValid = verifySignature(
req.body, // raw Buffer
req.headers["x-routific-signature"],
process.env.ROUTIFIC_WEBHOOK_SECRET
);
if (!isValid) return res.sendStatus(401);
// Replay check
const timestamp = new Date(req.headers["x-routific-timestamp"]);
if (Date.now() - timestamp.getTime() > 5 * 60 * 1000) return res.sendStatus(401);
res.sendStatus(200);
// process req.body ...
});
import hmac
import hashlib
from datetime import datetime, timezone, timedelta
def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
if not signature_header.startswith("v0="):
return False
signatures = signature_header[len("v0="):].split(",")
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, sig.strip()) for sig in signatures)
# Flask example
from flask import Flask, request, abort
app = Flask(__name__)
@app.route("/webhooks", methods=["POST"])
def webhook():
valid = verify_signature(
request.get_data(), # raw bytes
request.headers.get("x-routific-signature", ""),
os.environ["ROUTIFIC_WEBHOOK_SECRET"]
)
if not valid:
abort(401)
# Replay check
timestamp = datetime.fromisoformat(
request.headers["x-routific-timestamp"].replace("Z", "+00:00")
)
if datetime.now(timezone.utc) - timestamp > timedelta(minutes=5):
abort(401)
return "", 200
# process request.json ...
function verifySignature(string $rawBody, string $signatureHeader, string $secret): bool {
if (!str_starts_with($signatureHeader, 'v0=')) return false;
$signatures = explode(',', substr($signatureHeader, strlen('v0=')));
$expected = hash_hmac('sha256', $rawBody, $secret);
foreach ($signatures as $sig) {
if (hash_equals($expected, trim($sig))) return true;
}
return false;
}
// Usage
$rawBody = file_get_contents('php://input');
$signatureHeader = $_SERVER['HTTP_X_ROUTIFIC_SIGNATURE'] ?? '';
$secret = getenv('ROUTIFIC_WEBHOOK_SECRET');
if (!verifySignature($rawBody, $signatureHeader, $secret)) {
http_response_code(401);
exit;
}
// Replay check
$timestamp = new DateTime($_SERVER['HTTP_X_ROUTIFIC_TIMESTAMP']);
$age = (new DateTime('now', new DateTimeZone('UTC')))->getTimestamp() - $timestamp->getTimestamp();
if ($age > 300) {
http_response_code(401);
exit;
}
// process json_decode($rawBody) ...
require "openssl"
def verify_signature(raw_body, signature_header, secret)
return false unless signature_header.start_with?("v0=")
signatures = signature_header.delete_prefix("v0=").split(",").map(&:strip)
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
signatures.any? { |sig| ActiveSupport::SecurityUtils.secure_compare(expected, sig) }
end
# Rails example
def webhook
raw_body = request.raw_post
unless verify_signature(raw_body, request.headers["x-routific-signature"], ENV["ROUTIFIC_WEBHOOK_SECRET"])
return head :unauthorized
end
# Replay check
timestamp = Time.parse(request.headers["x-routific-timestamp"]).utc
return head :unauthorized if Time.now.utc - timestamp > 300
head :ok
# process JSON.parse(raw_body) ...
end
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
"time"
)
func verifySignature(rawBody []byte, signatureHeader, secret string) bool {
if !strings.HasPrefix(signatureHeader, "v0=") {
return false
}
signatures := strings.Split(strings.TrimPrefix(signatureHeader, "v0="), ",")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
for _, sig := range signatures {
if hmac.Equal([]byte(strings.TrimSpace(sig)), []byte(expected)) {
return true
}
}
return false
}
// Usage in an http.Handler
func webhookHandler(w http.ResponseWriter, r *http.Request) {
rawBody, _ := io.ReadAll(r.Body)
if !verifySignature(rawBody, r.Header.Get("x-routific-signature"), os.Getenv("ROUTIFIC_WEBHOOK_SECRET")) {
w.WriteHeader(http.StatusUnauthorized)
return
}
// Replay check
ts, _ := time.Parse(time.RFC3339, r.Header.Get("x-routific-timestamp"))
if time.Since(ts) > 5*time.Minute {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
// process rawBody ...
}
