Verify Signature
Every webhook POST carries an X-Splashify-Signature header — the
HMAC-SHA256 of the raw request body keyed by the webhook_secret
you configured on the destination, hex-encoded with a sha256=
prefix.
Verify in constant time. Standard string equality is vulnerable to timing attacks. Use your language’s
hmac.compare_digest/crypto.timingSafeEqual/ equivalent.
Node.js
import crypto from "crypto";
import express from "express";
const app = express();
// IMPORTANT: read the body as raw bytes, not as parsed JSON, so the
// HMAC computation matches what we signed.
app.post(
"/webhooks/splashify",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.header("x-splashify-signature") || "";
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.SPLASHIFY_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
if (sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString());
handle(event);
res.status(200).end();
},
);Python (Flask)
import hmac, hashlib, os
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["SPLASHIFY_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/splashify")
def webhook():
sig = request.headers.get("X-Splashify-Signature", "")
expected = "sha256=" + hmac.new(SECRET, request.data, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
return ("", 401)
event = request.get_json()
handle(event)
return ("", 200)PHP
<?php
$body = file_get_contents("php://input");
$sig = $_SERVER["HTTP_X_SPLASHIFY_SIGNATURE"] ?? "";
$expected = "sha256=" . hash_hmac("sha256", $body, getenv("SPLASHIFY_WEBHOOK_SECRET"));
if (!hash_equals($expected, $sig)) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
handle($event);
http_response_code(200);Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
)
func handler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
sig := r.Header.Get("X-Splashify-Signature")
mac := hmac.New(sha256.New, []byte(os.Getenv("SPLASHIFY_WEBHOOK_SECRET")))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(sig), []byte(expected)) {
http.Error(w, "", 401)
return
}
// ... handle event
w.WriteHeader(200)
}Ruby (Rails)
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def splashify
body = request.raw_post
sig = request.headers["x-splashify-signature"].to_s
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", ENV["SPLASHIFY_WEBHOOK_SECRET"], body)
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(sig, expected)
event = JSON.parse(body)
handle(event)
head :ok
end
endRust (axum)
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
async fn webhook(headers: HeaderMap, body: Bytes) -> Result<(), StatusCode> {
let sig = headers.get("X-Splashify-Signature")
.and_then(|h| h.to_str().ok()).unwrap_or("");
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(&body);
let expected = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
if !sig.as_bytes().ct_eq(expected.as_bytes()).into() {
return Err(StatusCode::UNAUTHORIZED);
}
// ... handle
Ok(())
}Common pitfalls
- Don’t parse the body before computing HMAC. Most frameworks parse + serialize JSON, which can change byte-for-byte (whitespace, field order). Always sign the raw bytes you received.
- Constant-time compare.
==on strings leaks timing information. Always use the language’s secure-compare function. - Rotation. When you rotate
webhook_secretvia PATCH, BOTH the old and new value should accept incoming events for ~30 seconds while in-flight requests drain. Implement by storing the previous secret + falling back to it on signature mismatch for a brief window. - Reject early. Verify the signature BEFORE any expensive work (DB queries, external calls). Saves resources on forged requests.
Test fixtures
Want to verify your implementation handles a known-good payload? Use this:
Body: {"eventType":"Send","mail":{"timestamp":"2026-05-03T12:00:00Z","messageId":"abc","source":"a@b.com","destination":["c@d.com"]},"send":{}}
Secret: test-secret
Signature: sha256=2bd8e57e9f5b2e8d2f8c4d1c9a1b9c3a3a4f5d6e7c8b9a0d1e2f3a4b5c6d7e8fIf your code computes the same hex string, you’re verified.