Skip to 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:

text
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 ​

javascript
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 ​

python
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.