Skip to content
Splashify Pro
Email docsEmail

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

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

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

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)

ruby
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
end

Rust (axum)

rust
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_secret via 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:

text
Body:    {"eventType":"Send","mail":{"timestamp":"2026-05-03T12:00:00Z","messageId":"abc","source":"[email protected]","destination":["[email protected]"]},"send":{}}
Secret:  test-secret
Signature: sha256=2bd8e57e9f5b2e8d2f8c4d1c9a1b9c3a3a4f5d6e7c8b9a0d1e2f3a4b5c6d7e8f

If your code computes the same hex string, you're verified.