WebhooksSignature verification

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 event
  • v1= — 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:

  1. The signature is valid. Recompute the HMAC from the signed payload (timestamp + "." + raw_body) using your endpoint’s signing secret. Compare with v1 from the header. Use a constant-time comparison.
  2. 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.
  3. 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
proceed

Node 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

MistakeSymptomFix
Verifying against re-serialized JSONSignature never matchesVerify against req.body raw bytes / request.get_data(), not req.json
Using a non-constant-time compareTheoretically allows timing attacksUse crypto.timingSafeEqual / hmac.compare_digest
Tolerating too-large timestamp driftVulnerable to replay attacksCap at 5 minutes (300 s) unless you have a specific reason
Verifying the live signing secret on a test webhookAll test deliveries get 400Keep test + live signing secrets separate; use the right one per env
Returning 200 before verifyingSignature failures get logged as “delivered” on our sideVerify 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.