Signature Verification
Every webhook request is signed with your webhook secret. Verify the signature to confirm the request is genuine.
Headers
| Header | Value |
|---|---|
X-SalesLobe-Webhook-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")> (recommended) |
X-SalesLobe-Timestamp | The same t, for convenience |
X-SalesLobe-Event-Id | Stable across retries of the same event: use it to dedupe |
X-SalesLobe-Delivery-Id | One per delivery attempt to your endpoint |
X-SalesLobe-Event | The event type |
X-SalesLobe-Signature | sha256=<hex HMAC-SHA256(secret, raw body)> (legacy, still sent) |
Prefer X-SalesLobe-Webhook-Signature: because the timestamp is signed too, you can reject replays. Reject requests where t is more than 5 minutes from your clock, and store X-SalesLobe-Event-Id to ignore duplicates. The legacy X-SalesLobe-Signature header keeps working for existing integrations.
Use the raw request body
The signature is computed over the exact bytes sent as the HTTP body. Never use JSON.stringify(parsed) or json.dumps(parsed) for verification — the key order of a re-serialized object is not guaranteed and will produce different bytes, causing the signature to fail. Always capture the raw body before parsing.
Recommended: timestamped signature
- Node.js
- Python
const crypto = require('crypto');
function verifyWebhookV1(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries((header || '').split(',').map(p => p.split('=')));
const t = Number(parts.t);
if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret)
.update(`${t}.`).update(rawBody) // raw bytes, NOT JSON.stringify()
.digest('hex');
return parts.v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}
// if (!verifyWebhookV1(req.rawBody, req.get('X-SalesLobe-Webhook-Signature'), SECRET))
// return res.status(401).end();
import hmac, hashlib, time
def verify_webhook_v1(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split('=', 1) for p in (header or '').split(',') if '=' in p)
try:
t = int(parts['t'])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance or 'v1' not in parts:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(parts['v1'], expected)
Legacy signature
- Node.js
- Python
const crypto = require('crypto');
// Express: capture raw bytes via verify callback
app.use(express.json({
verify: (req, _res, buf) => { req.rawBody = buf; }
}));
function verifyWebhook(rawBody, signature, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody) // ← raw bytes, NOT JSON.stringify()
.digest('hex');
return signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
// In your route handler:
// if (!verifyWebhook(req.rawBody, req.get('X-SalesLobe-Signature'), SECRET))
// return res.status(401).end();
import hmac, hashlib
def verify_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
expected = 'sha256=' + hmac.new(
secret.encode(),
raw_body, # ← raw bytes, NOT json.dumps()
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
# Flask example:
# raw = request.get_data() # bytes
# sig = request.headers.get('X-SalesLobe-Signature', '')
# if not verify_webhook(raw, sig, SECRET): abort(401)
# payload = json.loads(raw)