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
-
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. -
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.
-
It computes
HMAC-SHA256(secret, signed_string)and sends the lowercase hex digest in theX-Sendseven-Signatureheader (prefixed withsha256=), alongside the timestamp inX-Sendseven-Timestamp. -
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:
| Header | Example | Description |
|---|---|---|
X-Sendseven-Signature | sha256=a1b2c3… | HMAC-SHA256 hex digest, prefixed with sha256= |
X-Sendseven-Timestamp | 1770000000 | Unix timestamp (seconds) — part of the signed string; also used for replay protection |
X-Sendseven-Delivery-Id | d4e5f6… | Unique delivery attempt ID |
X-Sendseven-Event | message.received | The event type |
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
- Always verify signatures — never skip this in production.
- Hash the raw body — sign
{timestamp}.{raw_body}using the exact bytes received; never parse-and-re-serialize before hashing. - Use timing-safe comparison —
hmac.compare_digest()in Python,crypto.timingSafeEqual()in Node.js. - Enforce timestamp freshness — reject stale deliveries for replay protection.
- Return 200 quickly — acknowledge fast and process asynchronously. Slow handlers count as failed deliveries and are retried.
- Store the secret securely — environment variables or a secrets manager, never in source control.
- Handle duplicates — deduplicate on the event
id/X-Sendseven-Delivery-Id; the same event may occasionally be delivered more than once.
Troubleshooting
| Symptom | Cause | Solution |
|---|---|---|
| Signature always fails | Hashing the parsed/re-serialized JSON instead of the raw body | HMAC over {timestamp}.{raw_body} using the exact received bytes |
| Signature always fails | Body was consumed/parsed by middleware before you read it | Capture raw bytes first — request.get_data() (Flask), await request.body() (FastAPI), express.raw() (Express) |
| Signature fails, forgot the timestamp | Hashing only the body, omitting the {timestamp}. prefix | Prepend X-Sendseven-Timestamp + . to the raw body before hashing |
| Intermittent signature failures | A proxy or middleware rewriting/re-encoding the request body | Ensure nothing mutates the payload before verification |
401 on legitimate requests | Timestamp skew too tight or server clock drift | Widen MAX_SKEW_SECONDS, sync server clock (NTP) |
Next Steps
- Webhook Setup — create and manage webhooks
- Webhook Events Reference — all event types and payloads