Verifying webhook signatures
stubkit signs every generic webhook payload with HMAC-SHA256 using your endpoint's secret. Your receiver must verify this signature before trusting the payload — otherwise anyone could POST fake events to your URL.
Header format
Signed requests include a stubkit-signature header:
stubkit-signature: t=1713000000,v1=a3c8e9d4f...t is the unix timestamp (seconds) when we signed. v1 is the hex-encoded HMAC-SHA256 of {timestamp}.{raw_body} using your endpoint's secret.
Verification algorithm
- Read the
stubkit-signatureheader. Split on commas. - Parse the
t=andv1=parts. - Reject if the timestamp is older than 5 minutes (replay protection).
- Compute HMAC-SHA256 of
{t}.{raw_body}using your secret. - Compare hex-encoded result with
v1using a constant-time comparison.
Node.js (Express)
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const SECRET = process.env.STUBKIT_WEBHOOK_SECRET!;
app.post(
'/stubkit/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.header('stubkit-signature') ?? '';
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const timestamp = parts.t;
const signature = parts.v1;
if (!timestamp || !signature) return res.status(400).end();
if (Date.now() / 1000 - Number(timestamp) > 300) return res.status(400).end();
const body = req.body as Buffer;
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${timestamp}.${body.toString('utf8')}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end();
}
const event = JSON.parse(body.toString('utf8'));
console.log('event:', event.type, event.data);
res.status(200).end();
},
);Python (Flask)
import hmac
import hashlib
import time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['STUBKIT_WEBHOOK_SECRET']
@app.post('/stubkit/webhook')
def stubkit_webhook():
header = request.headers.get('stubkit-signature', '')
parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)
ts = parts.get('t')
sig = parts.get('v1')
if not ts or not sig:
abort(400)
if time.time() - int(ts) > 300:
abort(400)
body = request.get_data()
signed = f"{ts}.{body.decode('utf-8')}"
expected = hmac.new(SECRET.encode(), signed.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
abort(401)
payload = request.get_json()
print('event:', payload['type'])
return '', 200Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var secret = os.Getenv("STUBKIT_WEBHOOK_SECRET")
func handler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
header := r.Header.Get("stubkit-signature")
parts := map[string]string{}
for _, p := range strings.Split(header, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
ts, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil {
http.Error(w, "bad sig", 400)
return
}
if time.Now().Unix()-ts > 300 {
http.Error(w, "stale", 400)
return
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts["t"] + "." + string(body)))
expected := hex.EncodeToString(mac.Sum(nil))
sig, _ := hex.DecodeString(parts["v1"])
exp, _ := hex.DecodeString(expected)
if !hmac.Equal(sig, exp) {
http.Error(w, "bad sig", 401)
return
}
// body is verified — handle event
w.WriteHeader(200)
}PHP
<?php
$secret = getenv('STUBKIT_WEBHOOK_SECRET');
$header = $_SERVER['HTTP_STUBKIT_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $header) as $p) {
[$k, $v] = array_pad(explode('=', $p, 2), 2, null);
if ($k && $v) $parts[$k] = $v;
}
$ts = $parts['t'] ?? null;
$sig = $parts['v1'] ?? null;
if (!$ts || !$sig || (time() - (int)$ts) > 300) {
http_response_code(400);
exit;
}
$body = file_get_contents('php://input');
$expected = hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($expected, $sig)) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
// handle $event
http_response_code(200);Testing your endpoint
In the dashboard, open a webhook endpoint's delivery log and click "replay" on a successful delivery to re-send the exact payload and signature. This lets you test your verifier without waiting for a live event.
Slack / Discord destinations
If you set destination_type to slack or discord, stubkit does not sign the payload — Slack/Discord don't expect signatures. You still get delivery retries and attempt logs.