Skip to main content

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

HeaderDescription
Content-Typeapplication/json
X-Sakneen-Event-IdEvent identifier; matches the body eventId
X-Sakneen-Application-IdApplication that owns the destination
X-Sakneen-Destination-IdDestination whose secret signed the request
X-Sakneen-Organization-IdOrganization that owns the event
X-Sakneen-TimestampUnix timestamp in seconds used by the signature
X-Sakneen-SignatureHMAC 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:

  1. Read the timestamp and signature headers.
  2. Reject timestamps outside a short tolerance, such as five minutes.
  3. Compute HMAC-SHA256 over the timestamp, a period, and the raw body.
  4. Compare signatures with a constant-time comparison.
  5. Verify the header event and organization identifiers match the body.
  6. Optionally verify the expected application and destination identifiers.
  7. 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");
}
},
);
Middleware order

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.