Skip to Content
WebhooksVerify Signature

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 end

Rust (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_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:

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=2bd8e57e9f5b2e8d2f8c4d1c9a1b9c3a3a4f5d6e7c8b9a0d1e2f3a4b5c6d7e8f

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