Verify Webhook Signatures
Every outbound webhook is signed with the secret of its destination. Verify the signature before parsing or processing the event.
Request headers
| Header | Description |
|---|---|
Content-Type | application/json |
X-Sakneen-Event-Id | Event identifier; matches the body eventId |
X-Sakneen-Application-Id | Application that owns the destination |
X-Sakneen-Destination-Id | Destination whose secret signed the request |
X-Sakneen-Organization-Id | Organization that owns the event |
X-Sakneen-Timestamp | Unix timestamp in seconds used by the signature |
X-Sakneen-Signature | HMAC signature in v1=<hex-digest> format |
HTTP header names are case-insensitive.
The application, destination, organization, and event IDs are identifiers, not credentials. The HMAC signature is what authenticates the request.
Signature algorithm
Sakneen calculates:
signedPayload = X-Sakneen-Timestamp + "." + rawRequestBody
signature = HMAC-SHA256(destinationSigningSecret, signedPayload)
header = "v1=" + lowercaseHex(signature)
The signature uses the exact raw request bytes. Parsing the JSON and serializing it again can change whitespace or key order and will produce a different signature.
Verification requirements
Your receiver should:
- Read the timestamp and signature headers.
- Reject timestamps outside a short tolerance, such as five minutes.
- Compute HMAC-SHA256 over the timestamp, a period, and the raw body.
- Compare signatures with a constant-time comparison.
- Verify the header event and organization identifiers match the body.
- Optionally verify the expected application and destination identifiers.
- Parse and validate JSON only after signature verification succeeds.
Node.js example
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
const signingSecret = process.env.SAKNEEN_WEBHOOK_SIGNING_SECRET;
const timestampToleranceSeconds = 5 * 60;
if (!signingSecret) {
throw new Error("SAKNEEN_WEBHOOK_SIGNING_SECRET is required");
}
function readRequiredHeader(request, name) {
const value = request.get(name);
if (!value) {
throw new Error(`Missing ${name}`);
}
return value;
}
function verifySakneenSignature(request) {
const timestamp = readRequiredHeader(request, "X-Sakneen-Timestamp");
const signatureHeader = readRequiredHeader(
request,
"X-Sakneen-Signature",
);
const timestampSeconds = Number(timestamp);
if (
!Number.isInteger(timestampSeconds) ||
Math.abs(Date.now() / 1000 - timestampSeconds) >
timestampToleranceSeconds
) {
throw new Error("Invalid or expired Sakneen timestamp");
}
const suppliedSignature = signatureHeader.startsWith("v1=")
? signatureHeader.slice(3)
: "";
const expectedSignature = createHmac("sha256", signingSecret)
.update(`${timestamp}.`, "utf8")
.update(request.body)
.digest("hex");
const suppliedBuffer = Buffer.from(suppliedSignature, "hex");
const expectedBuffer = Buffer.from(expectedSignature, "hex");
if (
suppliedBuffer.length !== expectedBuffer.length ||
!timingSafeEqual(suppliedBuffer, expectedBuffer)
) {
throw new Error("Invalid Sakneen webhook signature");
}
}
app.post(
"/webhooks/sakneen",
express.raw({ type: "application/json", limit: "1mb" }),
async (request, response) => {
try {
verifySakneenSignature(request);
const event = JSON.parse(request.body.toString("utf8"));
const headerEventId = readRequiredHeader(
request,
"X-Sakneen-Event-Id",
);
const headerOrganizationId = readRequiredHeader(
request,
"X-Sakneen-Organization-Id",
);
if (
event.eventId !== headerEventId ||
event.organizationId !== headerOrganizationId
) {
return response.status(400).send("Header and body mismatch");
}
// Persist event.eventId and the event atomically before acknowledging.
await acceptEventIdempotently(event);
return response.sendStatus(204);
} catch {
return response.status(400).send("Invalid webhook");
}
},
);
Do not run express.json() before the webhook route. Signature verification
requires the raw request body.
Python example
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
app = Flask(__name__)
signing_secret = os.environ["SAKNEEN_WEBHOOK_SIGNING_SECRET"]
@app.post("/webhooks/sakneen")
def sakneen_webhook():
raw_body = request.get_data(cache=True)
timestamp = request.headers.get("X-Sakneen-Timestamp", "")
signature_header = request.headers.get("X-Sakneen-Signature", "")
try:
timestamp_seconds = int(timestamp)
except ValueError:
return "Invalid timestamp", 400
if abs(time.time() - timestamp_seconds) > 300:
return "Expired timestamp", 400
signed_payload = timestamp.encode() + b"." + raw_body
expected_signature = hmac.new(
signing_secret.encode(),
signed_payload,
hashlib.sha256,
).hexdigest()
supplied_signature = (
signature_header[3:] if signature_header.startswith("v1=") else ""
)
if not hmac.compare_digest(expected_signature, supplied_signature):
return "Invalid signature", 400
event = json.loads(raw_body)
# Persist event["eventId"] and the event atomically before acknowledging.
accept_event_idempotently(event)
return "", 204
Secret rotation
The signing secret is scoped to one destination. When it is rotated, future requests for that destination use the new secret. Update the receiver promptly, then use the Developer Console to retry any delivery that failed during the change.