Skip to main content

Signature Verification

Every webhook request from SendSeven is signed with an HMAC-SHA256 signature. Always verify this signature to confirm the request genuinely came from SendSeven and was not tampered with in transit.

How It Works

  1. When you create a webhook, SendSeven returns a signing secret — a 64-character hexadecimal string (for example 9f8e7d6c5b4a…). Store it securely; it is shown only once.

  2. For every delivery, SendSeven builds a signed string by joining the delivery timestamp and the exact request body it is about to send, with a . separator:

    {timestamp}.{raw_request_body}

    The body is serialized once, and the exact bytes that are transmitted are the exact bytes that are signed — so the body you receive is byte-for-byte the body that was signed.

  3. It computes HMAC-SHA256(secret, signed_string) and sends the lowercase hex digest in the X-Sendseven-Signature header (prefixed with sha256=), alongside the timestamp in X-Sendseven-Timestamp.

  4. Your server rebuilds the same signed string from the raw body and the timestamp header, recomputes the HMAC with your secret, and compares it to the received signature using a timing-safe comparison.

Request Headers

Every delivery includes these headers:

HeaderExampleDescription
X-Sendseven-Signaturesha256=a1b2c3…HMAC-SHA256 hex digest, prefixed with sha256=
X-Sendseven-Timestamp1770000000Unix timestamp (seconds) — part of the signed string; also used for replay protection
X-Sendseven-Delivery-Idd4e5f6…Unique delivery attempt ID
X-Sendseven-Eventmessage.receivedThe event type
Hash the raw request body — do NOT re-serialize

Compute the HMAC over {timestamp}.{raw_body}, where raw_body is the exact body you received. Do not parse the JSON and re-serialize it — parsing then re-stringifying can change whitespace or key order and will break the signature. Capture the raw body before any JSON middleware touches it: request.get_data() in Flask, await request.body() in FastAPI, express.raw() in Express.

Python Verification

Using Flask

import hmac
import hashlib
import time
from flask import Flask, request

app = Flask(__name__)

# 64-character hex signing secret from webhook creation
WEBHOOK_SECRET = "9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0"

# Reject deliveries whose timestamp is older than this (replay protection)
MAX_SKEW_SECONDS = 300 # 5 minutes


def verify_signature(raw_body: bytes, signature: str, timestamp: str) -> bool:
if not signature.startswith("sha256="):
return False

# signed string = {timestamp}.{raw_body} — sign the exact bytes received
message = timestamp.encode("utf-8") + b"." + raw_body

expected = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode("utf-8"),
message,
hashlib.sha256,
).hexdigest()

return hmac.compare_digest(expected, signature)


@app.post("/webhooks/sendseven")
def handle_webhook():
signature = request.headers.get("X-Sendseven-Signature", "")
timestamp = request.headers.get("X-Sendseven-Timestamp", "")

# Replay protection: reject stale timestamps
if not timestamp or abs(time.time() - int(timestamp)) > MAX_SKEW_SECONDS:
return "Stale or missing timestamp", 401

# IMPORTANT: use request.get_data() (raw bytes), never request.json first
if not verify_signature(request.get_data(), signature, timestamp):
return "Invalid signature", 401

event = request.get_json()
if event["type"] == "message.received":
handle_new_message(event["data"])
elif event["type"] == "conversation.closed":
sync_to_crm(event["data"])

return "OK", 200

Using FastAPI

import hmac
import hashlib
import json
import time
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

WEBHOOK_SECRET = "9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0"
MAX_SKEW_SECONDS = 300


def verify_signature(raw_body: bytes, signature: str, timestamp: str) -> bool:
if not signature.startswith("sha256="):
return False
# signed string = {timestamp}.{raw_body}
message = timestamp.encode("utf-8") + b"." + raw_body
expected = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode("utf-8"),
message,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)


@app.post("/webhooks/sendseven")
async def handle_webhook(request: Request):
body = await request.body() # raw bytes — capture before parsing
signature = request.headers.get("X-Sendseven-Signature", "")
timestamp = request.headers.get("X-Sendseven-Timestamp", "")

if not timestamp or abs(time.time() - int(timestamp)) > MAX_SKEW_SECONDS:
raise HTTPException(status_code=401, detail="Stale or missing timestamp")

if not verify_signature(body, signature, timestamp):
raise HTTPException(status_code=401, detail="Invalid signature")

event = json.loads(body)
if event["type"] == "message.received":
print(f"New message: {event['data']['message']['text']}")

return {"received": True}

Node.js Verification

Because the signature is over the raw body, verification is a single HMAC — no JSON parsing, key sorting, or canonicalization needed. Use express.raw() so you get the untouched bytes.

const crypto = require("crypto");
const express = require("express");

const WEBHOOK_SECRET =
"9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0";
const MAX_SKEW_SECONDS = 300;

function verifySignature(rawBody, signature, timestamp) {
if (!signature || !signature.startsWith("sha256=")) return false;

// signed string = {timestamp}.{raw_body}
const message = Buffer.concat([
Buffer.from(`${timestamp}.`, "utf-8"),
rawBody, // Buffer of the exact bytes received
]);

const expected =
"sha256=" +
crypto.createHmac("sha256", WEBHOOK_SECRET).update(message).digest("hex");

try {
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
} catch {
return false;
}
}

const app = express();

// Capture the raw body bytes — required for signature verification
app.use(express.raw({ type: "application/json" }));

app.post("/webhooks/sendseven", (req, res) => {
const signature = req.headers["x-sendseven-signature"];
const timestamp = req.headers["x-sendseven-timestamp"];

if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > MAX_SKEW_SECONDS) {
return res.status(401).send("Stale or missing timestamp");
}

if (!verifySignature(req.body, signature, timestamp)) {
return res.status(401).send("Invalid signature");
}

const event = JSON.parse(req.body);
switch (event.type) {
case "message.received":
console.log(`Message: ${event.data.message.text}`);
break;
case "conversation.closed":
console.log(`Conversation closed: ${event.data.conversation_id}`);
break;
}

res.status(200).send("OK");
});

app.listen(3000, () => console.log("Webhook server running on port 3000"));

Replay Protection

Every delivery carries X-Sendseven-Timestamp (a Unix timestamp in seconds), and that timestamp is part of the signed string, so it cannot be altered without breaking the signature. Reject any delivery whose timestamp is too far from your server's clock (5 minutes is a good default) to stop an attacker from replaying a previously-captured request. For stronger idempotency, also store the X-Sendseven-Delivery-Id (or the payload id) and ignore duplicates.

Best Practices

  1. Always verify signatures — never skip this in production.
  2. Hash the raw body — sign {timestamp}.{raw_body} using the exact bytes received; never parse-and-re-serialize before hashing.
  3. Use timing-safe comparisonhmac.compare_digest() in Python, crypto.timingSafeEqual() in Node.js.
  4. Enforce timestamp freshness — reject stale deliveries for replay protection.
  5. Return 200 quickly — acknowledge fast and process asynchronously. Slow handlers count as failed deliveries and are retried.
  6. Store the secret securely — environment variables or a secrets manager, never in source control.
  7. Handle duplicates — deduplicate on the event id / X-Sendseven-Delivery-Id; the same event may occasionally be delivered more than once.

Troubleshooting

SymptomCauseSolution
Signature always failsHashing the parsed/re-serialized JSON instead of the raw bodyHMAC over {timestamp}.{raw_body} using the exact received bytes
Signature always failsBody was consumed/parsed by middleware before you read itCapture raw bytes first — request.get_data() (Flask), await request.body() (FastAPI), express.raw() (Express)
Signature fails, forgot the timestampHashing only the body, omitting the {timestamp}. prefixPrepend X-Sendseven-Timestamp + . to the raw body before hashing
Intermittent signature failuresA proxy or middleware rewriting/re-encoding the request bodyEnsure nothing mutates the payload before verification
401 on legitimate requestsTimestamp skew too tight or server clock driftWiden MAX_SKEW_SECONDS, sync server clock (NTP)

Next Steps