Signature verification
Every webhook delivery from Soxara carries a Soxara-Signature header. Use it to confirm the request actually came from Soxara, not from someone who guessed your endpoint URL.
The header
Soxara-Signature: t=1730750100,v1=5c87f2c0a3...t=— unix timestamp (seconds) when Soxara dispatched the eventv1=— HMAC-SHA256 hex digest
This is the same scheme Stripe uses for Stripe-Signature. The wire format is intentional — port any existing Stripe-verifier helper, swap two strings.
What to verify
Three checks:
- The signature is valid. Recompute the HMAC from the signed payload (
timestamp + "." + raw_body) using your endpoint’s signing secret. Compare withv1from the header. Use a constant-time comparison. - The timestamp isn’t too old. Reject anything older than 5 minutes — that’s our default tolerance against replay attacks. Pick a window that matches your clock skew tolerance.
- The raw body matches what was signed. Verify against the raw bytes you received, not a re-serialized JSON object. JSON re-serialization can change whitespace or key ordering and break the signature.
Pseudocode
signed_payload = timestamp + "." + raw_body_bytes
expected = HMAC-SHA256(secret = signing_secret, message = signed_payload)
provided = hex_decode(header["v1"])
if not constant_time_eq(expected, provided):
reject 400
if abs(now() - timestamp) > 300:
reject 400
proceedNode example
import crypto from 'node:crypto';
function verifyWebhook(rawBody, signatureHeader, signingSecret) {
if (!signatureHeader) return false;
// Parse "t=...,v1=..."
const parts = Object.fromEntries(
signatureHeader.split(',').map(p => p.split('='))
);
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) return false;
// 5-minute window
const age = Math.abs(Math.floor(Date.now() / 1000) - parseInt(t, 10));
if (age > 300) return false;
// Recompute HMAC over `t + "." + raw body`
const signedPayload = `${t}.${rawBody}`;
const expected = crypto
.createHmac('sha256', signingSecret)
.update(signedPayload)
.digest('hex');
// Constant-time compare
try {
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(v1, 'hex'),
);
} catch {
return false;
}
}
// In your route handler:
app.post('/webhooks/soxara', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyWebhook(
req.body, // raw bytes (Buffer)
req.headers['soxara-signature'],
process.env.SOXARA_WEBHOOK_SECRET,
);
if (!ok) return res.status(400).send('bad signature');
const event = JSON.parse(req.body.toString());
// ... handle event ...
res.status(200).send('ok');
});The express.raw() middleware matters — express.json() would parse + re-serialize the body, breaking the signature.
Python example
import hmac, hashlib, time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["SOXARA_WEBHOOK_SECRET"]
def verify_webhook(raw_body: bytes, sig_header: str, secret: str) -> bool:
if not sig_header:
return False
parts = dict(p.split("=", 1) for p in sig_header.split(","))
t = parts.get("t")
v1 = parts.get("v1")
if not t or not v1:
return False
if abs(int(time.time()) - int(t)) > 300:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/webhooks/soxara")
def handler():
raw = request.get_data() # raw bytes
ok = verify_webhook(raw, request.headers.get("Soxara-Signature"), SECRET)
if not ok:
return ("bad signature", 400)
event = request.get_json()
# ... handle event ...
return ("ok", 200)Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Verifying against re-serialized JSON | Signature never matches | Verify against req.body raw bytes / request.get_data(), not req.json |
| Using a non-constant-time compare | Theoretically allows timing attacks | Use crypto.timingSafeEqual / hmac.compare_digest |
| Tolerating too-large timestamp drift | Vulnerable to replay attacks | Cap at 5 minutes (300 s) unless you have a specific reason |
| Verifying the live signing secret on a test webhook | All test deliveries get 400 | Keep test + live signing secrets separate; use the right one per env |
| Returning 200 before verifying | Signature failures get logged as “delivered” on our side | Verify first, then return 200 only on success |
Rotating the signing secret
You can mint a new endpoint with a new secret, then delete the old endpoint. There’s no in-place rotation for the secret on an existing endpoint. The two-step rotation lets you deploy the new secret to your handler, confirm it works, and then sunset the old one.