Sendbaze-Signature header:
Sendbaze-Signature: t=1758528842,v1=6f1c0a9e2d...
tis a Unix timestamp in seconds.v1is the hex HMAC-SHA256 of"<t>.<raw body>", keyed with the endpoint secret (whsec_…).
v1 in constant time. Parse the JSON only after the check passes.
import { verifyWebhookSignature, SENDBAZE_SIGNATURE_HEADER } from "@sendbaze/sdk";
app.post("/webhooks/sendbaze", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyWebhookSignature({
payload: req.body.toString("utf8"),
header: req.get(SENDBAZE_SIGNATURE_HEADER) ?? "",
secret: process.env.SENDBAZE_WEBHOOK_SECRET!,
});
if (!ok) return res.status(400).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// handle event.type
res.json({ received: true });
});
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifySendbazeSignature(rawBody: string, header: string, secret: string, toleranceSeconds = 300): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const given = String(parts.v1 ?? "");
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
import { NextResponse } from "next/server";
import { verifyWebhookSignature, SENDBAZE_SIGNATURE_HEADER } from "@sendbaze/sdk";
export async function POST(request: Request) {
const rawBody = await request.text();
const ok = verifyWebhookSignature({
payload: rawBody,
header: request.headers.get(SENDBAZE_SIGNATURE_HEADER) ?? "",
secret: process.env.SENDBAZE_WEBHOOK_SECRET!,
});
if (!ok) return new NextResponse("bad signature", { status: 400 });
const event = JSON.parse(rawBody);
return NextResponse.json({ received: true });
}
import hmac, hashlib, time
def verify_sendbaze_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
# Flask
@app.post("/webhooks/sendbaze")
def sendbaze_webhook():
if not verify_sendbaze_signature(request.get_data(), request.headers.get("Sendbaze-Signature", ""), SECRET):
abort(400)
event = request.get_json()
return {"received": True}
require "openssl"
def verify_sendbaze_signature(raw_body, header, secret, tolerance: 300)
parts = header.split(",").to_h { |p| p.strip.split("=", 2) }
timestamp = parts["t"].to_i
return false if timestamp.zero? || (Time.now.to_i - timestamp).abs > tolerance
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
ActiveSupport::SecurityUtils.secure_compare(expected, parts["v1"].to_s)
end
# Rails controller
def create
raw = request.raw_post
head :bad_request unless verify_sendbaze_signature(raw, request.headers["Sendbaze-Signature"].to_s, ENV.fetch("SENDBAZE_WEBHOOK_SECRET"))
event = JSON.parse(raw)
head :ok
end
function verifySendbazeSignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
parse_str(str_replace(',', '&', $header), $parts);
$timestamp = (int) ($parts['t'] ?? 0);
if ($timestamp === 0 || abs(time() - $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1'] ?? '');
}
$raw = file_get_contents('php://input');
if (!verifySendbazeSignature($raw, $_SERVER['HTTP_SENDBAZE_SIGNATURE'] ?? '', getenv('SENDBAZE_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
func verifySendbazeSignature(rawBody []byte, header, secret string) bool {
parts := map[string]string{}
for _, kv := range strings.Split(header, ",") {
if k, v, ok := strings.Cut(strings.TrimSpace(kv), "="); ok {
parts[k] = v
}
}
ts, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts["t"] + "."))
mac.Write(rawBody)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(parts["v1"]))
}
Frameworks that parse JSON before your code runs (Express
json(), Rails parameter parsing) can change whitespace or key order and break the HMAC. Always read the raw body for verification.Rotating a secret
Secrets cannot be rotated in place yet. Create a second endpoint with the same URL, switch your server to the new secret, then delete the old endpoint. Both deliver in the meantime, so de-duplicate withSendbaze-Event-Id.