stubkit docs

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

  1. Read the stubkit-signature header. Split on commas.
  2. Parse the t= and v1= parts.
  3. Reject if the timestamp is older than 5 minutes (replay protection).
  4. Compute HMAC-SHA256 of {t}.{raw_body} using your secret.
  5. Compare hex-encoded result with v1 using 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 '', 200

Go

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.