Webhook security

Verify SuaveHooks signatures

When SuaveHooks forwards a webhook to your server, it can sign the payload so you can reject forgeries and replays. This page documents SuaveHooks v1 verification in common languages.

SuaveHooks v1

Configure a signing secret and choose suave (or both) on the endpoint edit page. SuaveHooks adds three headers on each forward:

X-SuaveHooks-Id: <request-id-32-hex>
X-SuaveHooks-Timestamp: <unix-seconds>
X-SuaveHooks-Signature: v1,<base64-hmac-sha256>

Secret: shsec_ + base64 key bytes (generated in the UI) or any raw UTF-8 string.

Signed string: concatenate with dots (body is the raw UTF-8 payload, or empty string if no body):

v1.{id}.{timestamp}.{endpointId}.{body}
  • {id} — capture request id (32 hex chars, no dashes)
  • {timestamp} — Unix seconds when the forward was signed
  • {endpointId} — SuaveHooks endpoint id (32 hex chars, no dashes). Bind signatures to this destination.
  • {body} — exact raw request body bytes interpreted as UTF-8

Compute HMAC-SHA256(secret, signed_string), base64-encode the digest, prefix with v1,. The header also accepts legacy v1= (normalized to v1,).

Verification checklist

  1. Read the raw request body before JSON parsing (middleware order matters).
  2. Extract X-SuaveHooks-Id, X-SuaveHooks-Timestamp, and X-SuaveHooks-Signature.
  3. Reject if timestamp is outside your tolerance (default 5 minutes).
  4. Recompute the signature with your shared secret and the expected endpoint id from SuaveHooks.
  5. Compare signatures in constant time (crypto.timingSafeEqual, CryptographicOperations.FixedTimeEquals, etc.).
  6. Return 401 on failure; process the webhook only after verification succeeds.

F#

Standalone verifier (Suave, Giraffe, ASP.NET Core minimal APIs, etc.):

open System
open System.Security.Cryptography
open System.Text

module SuaveHooksVerify =

    let private secretPrefix = "shsec_"

    let private signingKeyBytes (secret : string) =
        if secret.StartsWith(secretPrefix, StringComparison.OrdinalIgnoreCase) then
            Convert.FromBase64String(secret.Substring(secretPrefix.Length))
        else
            Encoding.UTF8.GetBytes secret

    let private bodyText (body : byte[]) =
        if body.Length = 0 then "" else Encoding.UTF8.GetString body

    let signSuaveV1 (secret : string) (webhookId : string) (timestamp : int64) (endpointId : Guid) (body : byte[]) =
        let content =
            sprintf "v1.%s.%d.%s.%s" webhookId timestamp (endpointId.ToString("N")) (bodyText body)
        use hmac = new HMACSHA256(signingKeyBytes secret)
        let digest = hmac.ComputeHash(Encoding.UTF8.GetBytes content)
        "v1," + Convert.ToBase64String(digest)

    let private constantTimeEquals (a : string) (b : string) =
        if a.Length <> b.Length then false
        else
            let mutable diff = 0
            for i in 0 .. a.Length - 1 do
                diff <- diff ||| (int a.[i] ^^^ int b.[i])
            diff = 0

    let verifySuaveV1
        (secret : string)
        (webhookId : string)
        (timestamp : int64)
        (endpointId : Guid)
        (body : byte[])
        (signature : string)
        (toleranceSeconds : int) =
        let now = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
        if Math.Abs(now - timestamp) > int64 toleranceSeconds then false
        else
            let expected = signSuaveV1 secret webhookId timestamp endpointId body
            let provided =
                if signature.StartsWith("v1,", StringComparison.OrdinalIgnoreCase) then signature
                elif signature.StartsWith("v1=", StringComparison.OrdinalIgnoreCase) then "v1," + signature.Substring(3)
                else signature
            constantTimeEquals expected provided

// Usage:
// let ok = SuaveHooksVerify.verifySuaveV1 secret id ts endpointId body sig 300

C#

ASP.NET Core example — enable buffering and read the raw body:

using System.Security.Cryptography;
using System.Text;

public static class SuaveHooksVerifier
{
    const string SecretPrefix = "shsec_";

    static byte[] SigningKeyBytes(string secret) =>
        secret.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase)
            ? Convert.FromBase64String(secret[SecretPrefix.Length..])
            : Encoding.UTF8.GetBytes(secret);

    public static bool Verify(
        string secret,
        string webhookId,
        long timestamp,
        Guid endpointId,
        byte[] body,
        string signature,
        int toleranceSeconds = 300)
    {
        var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        if (Math.Abs(now - timestamp) > toleranceSeconds) return false;

        var bodyText = body.Length == 0 ? "" : Encoding.UTF8.GetString(body);
        var content = $"v1.{webhookId}.{timestamp}.{endpointId:N}.{bodyText}";

        using var hmac = new HMACSHA256(SigningKeyBytes(secret));
        var digest = hmac.ComputeHash(Encoding.UTF8.GetBytes(content));
        var expected = "v1," + Convert.ToBase64String(digest);

        var provided = signature.StartsWith("v1,", StringComparison.OrdinalIgnoreCase) ? signature
            : signature.StartsWith("v1=", StringComparison.OrdinalIgnoreCase) ? "v1," + signature[3..]
            : signature;

        return CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(expected),
            Encoding.UTF8.GetBytes(provided));
    }
}

// Minimal API handler sketch:
// app.MapPost("/webhooks/suavehooks", async (HttpRequest req) => {
//     req.EnableBuffering();
//     using var ms = new MemoryStream();
//     await req.Body.CopyToAsync(ms);
//     var body = ms.ToArray();
//     req.Body.Position = 0;
//     var ok = SuaveHooksVerifier.Verify(secret,
//         req.Headers["X-SuaveHooks-Id"]!,
//         long.Parse(req.Headers["X-SuaveHooks-Timestamp"]!),
//         expectedEndpointId, body,
//         req.Headers["X-SuaveHooks-Signature"]!);
//     return ok ? Results.Ok() : Results.Unauthorized();
// });

Node.js

Express middleware with raw body capture:

import crypto from 'node:crypto';
import express from 'express';

const SECRET = process.env.SUAVEHOOKS_SECRET;
const EXPECTED_ENDPOINT_ID = process.env.SUAVEHOOKS_ENDPOINT_ID; // 32 hex, no dashes

function signingKeyBytes(secret) {
  if (secret.toLowerCase().startsWith('shsec_')) {
    return Buffer.from(secret.slice('shsec_'.length), 'base64');
  }
  return Buffer.from(secret, 'utf8');
}

function signSuaveV1(secret, webhookId, timestamp, endpointId, body) {
  const bodyText = body.length === 0 ? '' : body.toString('utf8');
  const content = `v1.${webhookId}.${timestamp}.${endpointId}.${bodyText}`;
  const digest = crypto.createHmac('sha256', signingKeyBytes(secret))
    .update(content, 'utf8')
    .digest('base64');
  return `v1,${digest}`;
}

function verifySuaveV1(secret, webhookId, timestamp, endpointId, body, signature, toleranceSeconds = 300) {
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > toleranceSeconds) return false;

  const expected = signSuaveV1(secret, webhookId, timestamp, endpointId, body);
  let provided = signature;
  if (provided.toLowerCase().startsWith('v1=')) provided = 'v1,' + provided.slice(3);

  const a = Buffer.from(expected);
  const b = Buffer.from(provided);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();
app.post('/webhooks/suavehooks',
  express.raw({ type: '*/*' }),
  (req, res) => {
    const id = req.header('X-SuaveHooks-Id');
    const ts = req.header('X-SuaveHooks-Timestamp');
    const sig = req.header('X-SuaveHooks-Signature');
    const ok = verifySuaveV1(SECRET, id, ts, EXPECTED_ENDPOINT_ID, req.body, sig);
    if (!ok) return res.status(401).send('invalid signature');
    // parse req.body as JSON, enqueue job, etc.
    res.sendStatus(200);
  });

Go

Standard library only:

package suavehooks

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/base64"
    "fmt"
    "math"
    "strings"
    "time"
)

func signingKeyBytes(secret string) ([]byte, error) {
    if strings.HasPrefix(strings.ToLower(secret), "shsec_") {
        return base64.StdEncoding.DecodeString(secret[len("shsec_"):])
    }
    return []byte(secret), nil
}

func SignSuaveV1(secret, webhookID string, timestamp int64, endpointID string, body []byte) (string, error) {
    bodyText := string(body)
    content := fmt.Sprintf("v1.%s.%d.%s.%s", webhookID, timestamp, endpointID, bodyText)
    key, err := signingKeyBytes(secret)
    if err != nil {
        return "", err
    }
    mac := hmac.New(sha256.New, key)
    mac.Write([]byte(content))
    return "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil
}

func VerifySuaveV1(secret, webhookID string, timestamp int64, endpointID string, body []byte, signature string, toleranceSec int) (bool, error) {
    now := time.Now().Unix()
    if math.Abs(float64(now-timestamp)) > float64(toleranceSec) {
        return false, nil
    }
    expected, err := SignSuaveV1(secret, webhookID, timestamp, endpointID, body)
    if err != nil {
        return false, err
    }
    provided := signature
    if strings.HasPrefix(strings.ToLower(provided), "v1=") {
        provided = "v1," + provided[3:]
    }
    return subtle.ConstantTimeCompare([]byte(expected), []byte(provided)) == 1, nil
}

Python

Flask / FastAPI / Django — use the raw body bytes:

import base64
import hashlib
import hmac
import time

def signing_key_bytes(secret: str) -> bytes:
    if secret.lower().startswith("shsec_"):
        return base64.b64decode(secret[len("shsec_"):])
    return secret.encode("utf-8")

def sign_suave_v1(secret: str, webhook_id: str, timestamp: int, endpoint_id: str, body: bytes) -> str:
    body_text = "" if not body else body.decode("utf-8")
    content = f"v1.{webhook_id}.{timestamp}.{endpoint_id}.{body_text}"
    digest = hmac.new(signing_key_bytes(secret), content.encode("utf-8"), hashlib.sha256).digest()
    return "v1," + base64.b64encode(digest).decode("ascii")

def verify_suave_v1(
    secret: str,
    webhook_id: str,
    timestamp: int,
    endpoint_id: str,
    body: bytes,
    signature: str,
    tolerance_seconds: int = 300,
) -> bool:
    if abs(time.time() - timestamp) > tolerance_seconds:
        return False
    expected = sign_suave_v1(secret, webhook_id, timestamp, endpoint_id, body)
    provided = signature
    if provided.lower().startswith("v1="):
        provided = "v1," + provided[3:]
    return hmac.compare_digest(expected, provided)

Ruby

Rails controller or Sinatra:

require "base64"
require "openssl"

module SuaveHooks
  SECRET_PREFIX = "shsec_"

  def self.signing_key_bytes(secret)
    if secret.downcase.start_with?(SECRET_PREFIX)
      Base64.decode64(secret[SECRET_PREFIX.length..])
    else
      secret.b
    end
  end

  def self.sign_suave_v1(secret, webhook_id, timestamp, endpoint_id, body)
    body_text = body.empty? ? "" : body.force_encoding("UTF-8")
    content = "v1.#{webhook_id}.#{timestamp}.#{endpoint_id}.#{body_text}"
    digest = OpenSSL::HMAC.digest("SHA256", signing_key_bytes(secret), content)
    "v1,#{Base64.strict_encode64(digest)}"
  end

  def self.verify_suave_v1(secret, webhook_id, timestamp, endpoint_id, body, signature, tolerance_seconds: 300)
    return false if (Time.now.to_i - timestamp.to_i).abs > tolerance_seconds

    expected = sign_suave_v1(secret, webhook_id, timestamp, endpoint_id, body)
    provided = signature.start_with?("v1=") ? "v1,#{signature[3..]}" : signature
    return false unless expected.bytesize == provided.bytesize
    OpenSSL.fixed_length_secure_compare(expected, provided)
  rescue ArgumentError
    false
  end
end

Java

Spring Boot, Jakarta Servlet, or any JVM stack — read the raw request body before parsing JSON:

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;

public final class SuaveHooksVerifier {
    private static final String SECRET_PREFIX = "shsec_";

    private SuaveHooksVerifier() {}

    private static byte[] signingKeyBytes(String secret) {
        if (secret.regionMatches(true, 0, SECRET_PREFIX, 0, SECRET_PREFIX.length())) {
            return Base64.getDecoder().decode(secret.substring(SECRET_PREFIX.length()));
        }
        return secret.getBytes(StandardCharsets.UTF_8);
    }

    public static String signSuaveV1(
            String secret,
            String webhookId,
            long timestamp,
            String endpointId,
            byte[] body) throws Exception {
        String bodyText = body.length == 0 ? "" : new String(body, StandardCharsets.UTF_8);
        String content = "v1." + webhookId + "." + timestamp + "." + endpointId + "." + bodyText;
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(signingKeyBytes(secret), "HmacSHA256"));
        byte[] digest = mac.doFinal(content.getBytes(StandardCharsets.UTF_8));
        return "v1," + Base64.getEncoder().encodeToString(digest);
    }

    public static boolean verifySuaveV1(
            String secret,
            String webhookId,
            long timestamp,
            String endpointId,
            byte[] body,
            String signature,
            int toleranceSeconds) throws Exception {
        long now = Instant.now().getEpochSecond();
        if (Math.abs(now - timestamp) > toleranceSeconds) return false;

        String expected = signSuaveV1(secret, webhookId, timestamp, endpointId, body);
        String provided = signature;
        if (provided.regionMatches(true, 0, "v1=", 0, 3)) {
            provided = "v1," + provided.substring(3);
        }
        return MessageDigest.isEqual(
                expected.getBytes(StandardCharsets.UTF_8),
                provided.getBytes(StandardCharsets.UTF_8));
    }
}

// Spring Boot sketch — use ContentCachingRequestWrapper or read InputStream once:
// byte[] body = request.getInputStream().readAllBytes();
// boolean ok = SuaveHooksVerifier.verifySuaveV1(
//     secret,
//     request.getHeader("X-SuaveHooks-Id"),
//     Long.parseLong(request.getHeader("X-SuaveHooks-Timestamp")),
//     expectedEndpointId,
//     body,
//     request.getHeader("X-SuaveHooks-Signature"),
//     300);

Kotlin

Ktor or Spring — same algorithm, idiomatic Kotlin:

import java.security.MessageDigest
import java.time.Instant
import java.util.Base64
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.math.abs
import kotlin.text.Charsets

object SuaveHooksVerifier {
    private const val SECRET_PREFIX = "shsec_"

    private fun signingKeyBytes(secret: String): ByteArray =
        if (secret.startsWith(SECRET_PREFIX, ignoreCase = true)) {
            Base64.getDecoder().decode(secret.substring(SECRET_PREFIX.length))
        } else {
            secret.toByteArray(Charsets.UTF_8)
        }

    fun signSuaveV1(
        secret: String,
        webhookId: String,
        timestamp: Long,
        endpointId: String,
        body: ByteArray,
    ): String {
        val bodyText = if (body.isEmpty()) "" else body.toString(Charsets.UTF_8)
        val content = "v1.$webhookId.$timestamp.$endpointId.$bodyText"
        val mac = Mac.getInstance("HmacSHA256")
        mac.init(SecretKeySpec(signingKeyBytes(secret), "HmacSHA256"))
        val digest = mac.doFinal(content.toByteArray(Charsets.UTF_8))
        return "v1," + Base64.getEncoder().encodeToString(digest)
    }

    fun verifySuaveV1(
        secret: String,
        webhookId: String,
        timestamp: Long,
        endpointId: String,
        body: ByteArray,
        signature: String,
        toleranceSeconds: Int = 300,
    ): Boolean {
        val now = Instant.now().epochSecond
        if (abs(now - timestamp) > toleranceSeconds) return false

        val expected = signSuaveV1(secret, webhookId, timestamp, endpointId, body)
        val provided = when {
            signature.startsWith("v1=", ignoreCase = true) -> "v1," + signature.substring(3)
            else -> signature
        }
        return MessageDigest.isEqual(
            expected.toByteArray(Charsets.UTF_8),
            provided.toByteArray(Charsets.UTF_8),
        )
    }
}

// Ktor route sketch:
// post("/webhooks/suavehooks") {
//     val body = call.receiveByteArray()
//     val ok = SuaveHooksVerifier.verifySuaveV1(
//         secret,
//         call.request.header("X-SuaveHooks-Id")!!,
//         call.request.header("X-SuaveHooks-Timestamp")!!.toLong(),
//         expectedEndpointId,
//         body,
//         call.request.header("X-SuaveHooks-Signature")!!,
//     )
//     if (!ok) call.respond(HttpStatusCode.Unauthorized) else call.respond(HttpStatusCode.OK)
// }

PHP

Laravel, Symfony, or plain PHP — always verify against php://input (raw body):

<?php

function signing_key_bytes(string $secret): string
{
    if (str_starts_with(strtolower($secret), 'shsec_')) {
        return base64_decode(substr($secret, strlen('shsec_')), true);
    }
    return $secret;
}

function sign_suave_v1(
    string $secret,
    string $webhookId,
    int $timestamp,
    string $endpointId,
    string $body
): string {
    $content = "v1.{$webhookId}.{$timestamp}.{$endpointId}.{$body}";
    $digest = hash_hmac('sha256', $content, signing_key_bytes($secret), true);
    return 'v1,' . base64_encode($digest);
}

function verify_suave_v1(
    string $secret,
    string $webhookId,
    int $timestamp,
    string $endpointId,
    string $body,
    string $signature,
    int $toleranceSeconds = 300
): bool {
    if (abs(time() - $timestamp) > $toleranceSeconds) {
        return false;
    }
    $expected = sign_suave_v1($secret, $webhookId, $timestamp, $endpointId, $body);
    $provided = str_starts_with(strtolower($signature), 'v1=')
        ? 'v1,' . substr($signature, 3)
        : $signature;
    return hash_equals($expected, $provided);
}

// Plain PHP endpoint:
// $body = file_get_contents('php://input');
// $ok = verify_suave_v1(
//     $_ENV['SUAVEHOOKS_SECRET'],
//     $_SERVER['HTTP_X_SUAVEHOOKS_ID'],
//     (int) $_SERVER['HTTP_X_SUAVEHOOKS_TIMESTAMP'],
//     $_ENV['SUAVEHOOKS_ENDPOINT_ID'],
//     $body,
//     $_SERVER['HTTP_X_SUAVEHOOKS_SIGNATURE']
// );
// http_response_code($ok ? 200 : 401);

GitHub scheme

Choose github or both to also send X-Hub-Signature-256: sha256=<hex> (HMAC-SHA256 over the raw body only, secret as UTF-8 bytes). This matches GitHub webhooks and SuaveHooks inbound capture verification.

# Node.js (GitHub-compatible outbound verify)
import crypto from 'node:crypto';

function verifyGitHub(secret, body, header) {
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}

For new receivers, prefer SuaveHooks v1 — it binds timestamp and endpoint id, not just the body.

Set your signing secret and scheme on the endpoint Edit page in the dashboard. Need capture URLs and API access? See the REST API docs.