> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sendbaze.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify the signature

> Every webhook request is signed with your endpoint secret. Verify it against the raw body before you trust the event.

Each request carries a `Sendbaze-Signature` header:

```
Sendbaze-Signature: t=1758528842,v1=6f1c0a9e2d...
```

* `t` is a Unix timestamp in seconds.
* `v1` is the hex HMAC-SHA256 of `"<t>.<raw body>"`, keyed with the endpoint secret (`whsec_…`).

To verify: split the header, reject timestamps more than five minutes old, compute the HMAC over the timestamp, a dot and the **raw** request body exactly as received, and compare with `v1` in constant time. Parse the JSON only after the check passes.

```ts Node.js (SDK) theme={"dark"}
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 });
});
```

```ts Node.js (no SDK) theme={"dark"}
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));
}
```

```ts Next.js theme={"dark"}
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 });
}
```

```python Python theme={"dark"}
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}
```

```ruby Ruby theme={"dark"}
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
```

```php PHP theme={"dark"}
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);
```

```go Go theme={"dark"}
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"]))
}
```

> [!WARNING]
>
> 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 with `Sendbaze-Event-Id`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.