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
- Read the raw request body before JSON parsing (middleware order matters).
- Extract
X-SuaveHooks-Id,X-SuaveHooks-Timestamp, andX-SuaveHooks-Signature. - Reject if timestamp is outside your tolerance (default 5 minutes).
- Recompute the signature with your shared secret and the expected endpoint id from SuaveHooks.
- Compare signatures in constant time (
crypto.timingSafeEqual,CryptographicOperations.FixedTimeEquals, etc.). - Return
401on 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.