smartchat

Verify a signature

Anyone who knows your webhook address can send a request to it. The signature proves that a delivery is from SmartChat and was not changed.

#How the signature works

  • Every delivery has the header SmartChat-Signature: t=<unix seconds>,v1=<hex>.
  • The signed text is the timestamp, a dot, and the raw request body: t + "." + body.
  • The signature is HMAC-SHA256 of that text, written as lower case hex. The key is the secret of the endpoint, exactly as shown (it starts with whsec_).
  • During a secret rotation the header has two v1= values. One must match.

#Check these things, in this order

  1. The header exists and has t and at least one v1.
  2. The timestamp is within 5 minutes of your clock. This stops someone from sending an old delivery again (a replay).
  3. The signature matches. Compare in constant time.
  4. You have not handled this event id before.

Use the raw body. If you parse the JSON and write it again, the bytes change and the check fails.

#Code

Copy the code for your language. It is the same code that SmartChat tests with its own deliveries. Each function returns true (or prints valid) if the delivery is valid. Pass the raw body and the secret. During a rotation you can pass both secrets (the Python and TypeScript functions take a list).

#!/bin/sh
# Verify a SmartChat webhook with curl-friendly shell tools (needs openssl).
# Usage: ./verify.sh <secret> "<SmartChat-Signature header>" <body-file>
# The body file must contain the RAW request body exactly as received (save it with: curl ... --data-binary @- or from your server log).
secret="$1"; header="$2"; file="$3"
timestamp=$(printf '%s' "$header" | tr ',' '\n' | sed -n 's/^t=//p' | head -n 1)
case "$timestamp" in ''|*[!0-9]*) echo invalid; exit 1;; esac
now=$(date +%s)
diff=$((now - timestamp)); [ "$diff" -lt 0 ] && diff=$((0 - diff))
[ "$diff" -gt 300 ] && { echo invalid; exit 1; }   # replay protection: more than 5 minutes
expected=$( { printf '%s.' "$timestamp"; cat "$file"; } | openssl dgst -sha256 -hmac "$secret" | sed 's/^.*= //')
for sig in $(printf '%s' "$header" | tr ',' '\n' | sed -n 's/^v1=//p'); do
  if [ "$sig" = "$expected" ]; then echo valid; exit 0; fi
done
echo invalid; exit 1
# Verify a SmartChat webhook in Python 3 (standard library only).
# Pass the RAW request body (bytes, exactly as received, before any JSON parsing).
import hashlib
import hmac
import sys
import time


def verify_smartchat_signature(raw_body: bytes, header: str, secrets: list, tolerance_seconds: int = 300) -> bool:
    if not header:
        return False
    timestamp = ""
    signatures = []
    for part in header.split(","):
        key, _, value = part.partition("=")
        if key == "t":
            timestamp = value
        elif key == "v1" and value:
            signatures.append(value)
    if not timestamp.isdigit() or not signatures:
        return False
    if abs(time.time() - int(timestamp)) > tolerance_seconds:  # replay protection
        return False
    for secret in secrets:
        signed = timestamp.encode() + b"." + raw_body
        expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(sig, expected) for sig in signatures):
            return True
    return False


if __name__ == "__main__" and len(sys.argv) >= 4:
    # python3 verify.py <secret> <header> <body-file>
    with open(sys.argv[3], "rb") as f:
        ok = verify_smartchat_signature(f.read(), sys.argv[2], [sys.argv[1]])
    print("valid" if ok else "invalid")
    sys.exit(0 if ok else 1)
// Verify a SmartChat webhook in TypeScript (Node 18+). No dependencies.
// Usage in your server: pass the RAW request body (a string or Buffer, exactly as received, before any JSON parsing).
import { createHmac, timingSafeEqual } from "node:crypto";
import { readFileSync } from "node:fs";

export function verifySmartChatSignature(rawBody: string | Buffer, header: string | undefined, secrets: string[], toleranceSeconds = 300): boolean {
  if (!header) return false;
  let timestamp = "";
  const signatures: string[] = [];
  for (const part of header.split(",")) {
    const [key, value] = part.split("=", 2);
    if (key === "t") timestamp = value ?? "";
    if (key === "v1" && value) signatures.push(value);
  }
  if (!/^\d+$/.test(timestamp) || signatures.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false; // replay protection
  const body = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
  for (const secret of secrets) {
    const expected = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
    for (const sig of signatures) {
      if (sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return true;
    }
  }
  return false;
}

// Command line use: node --import tsx verify.ts <secret> <header> <body-file>
if (process.argv[1]?.endsWith("verify.ts") && process.argv.length >= 5) {
  const ok = verifySmartChatSignature(readFileSync(process.argv[4]!, "utf8"), process.argv[3], [process.argv[2]!]);
  console.log(ok ? "valid" : "invalid");
  process.exit(ok ? 0 : 1);
}

#Get the raw body in your web framework

The most common mistake is to let the framework parse the JSON first. Then you no longer have the original bytes. Ask the framework for the raw body, check the signature, and only then parse. These two examples use the function verifySmartChatSignature from the TypeScript code above. They need a web framework that is not installed here, so they are shown as text and are not run by the SmartChat tests.

import express from "express";

const app = express();

// express.raw keeps the body as a Buffer. Do NOT use express.json() on this route.
app.post("/hooks/smartchat", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  const ok = verifySmartChatSignature(raw, req.header("SmartChat-Signature") ?? "", [process.env.SMARTCHAT_WEBHOOK_SECRET!]);
  if (!ok) return res.status(400).send("invalid signature");
  const event = JSON.parse(raw);
  // Handle the event. Answer quickly with 2xx.
  res.sendStatus(200);
});
export async function POST(request: Request) {
  const raw = await request.text(); // the raw body, not request.json()
  const ok = verifySmartChatSignature(raw, request.headers.get("SmartChat-Signature") ?? "", [process.env.SMARTCHAT_WEBHOOK_SECRET!]);
  if (!ok) return new Response("invalid signature", { status: 400 });
  const event = JSON.parse(raw);
  // Handle the event.
  return new Response(null, { status: 200 });
}

#Rotating the secret

Rotate the secret if it may have leaked. The rotation call creates a new secret and returns it once. For a time you choose (grace_hours, 0 to 72, default 24) deliveries are signed with both secrets, so you can switch without losing events. After that only the new secret is used. Update your code to check with the new secret, then wait for the grace time to end.

#Test your code

Use the test call of the endpoint. It sends a webhook.test event with a real signature. See Webhook testing.