API reference
Verifying webhook signatures
Sample code in JavaScript, Python, and Go for verifying the Jobbydev-Signature header and rejecting tampered or replayed deliveries.
Last updated
Every webhook delivery carries a Jobbydev-Signature header. Verify it on every request — without verification, an attacker who guesses your endpoint URL can forge events. Reference implementations in JavaScript, Python, and Go below.
The signature
Header shape: Jobbydev-Signature: t=<unix>,v1=<hex>. Same wire format Stripe uses.
t— Unix timestamp at signing time. Reject deliveries withtmore than 5 minutes off your server's clock to mitigate replay attacks.v1— hex-encoded HMAC-SHA256 of<t>.<raw_request_body>using the subscription secret.
Compare in constant time. A naive string equality check leaks timing data and is exploitable.
JavaScript / Node
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.JOBBYDEV_WEBHOOK_SECRET; // jbb_whsec_...
const TOLERANCE_SECONDS = 300;
app.post("/jobbydev", express.raw({ type: "application/json" }), (req, res) => {
const sigHeader = req.header("Jobbydev-Signature") ?? "";
const parts = Object.fromEntries(
sigHeader.split(",").map((p) => p.split("=")),
);
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) return res.status(400).send("missing signature");
// Replay window
const tNum = Number.parseInt(t, 10);
if (Math.abs(Date.now() / 1000 - tNum) > TOLERANCE_SECONDS) {
return res.status(400).send("signature too old");
}
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${t}.${req.body.toString("utf8")}`)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// ... handle event idempotently using event.id ...
res.status(200).send("ok");
});Python
import hmac
import hashlib
import os
import time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["JOBBYDEV_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
@app.post("/jobbydev")
def jobbydev():
sig_header = request.headers.get("Jobbydev-Signature", "")
parts = dict(p.split("=", 1) for p in sig_header.split(",") if "=" in p)
t = parts.get("t")
v1 = parts.get("v1")
if not t or not v1:
abort(400, "missing signature")
if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
abort(400, "signature too old")
body = request.get_data() # raw bytes
payload = f"{t}.{body.decode()}".encode()
expected = hmac.new(SECRET, payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
abort(400, "invalid signature")
event = request.get_json()
# ... handle event idempotently using event["id"] ...
return "ok"Go
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const toleranceSeconds = 300
func Handler(w http.ResponseWriter, r *http.Request) {
secret := []byte(os.Getenv("JOBBYDEV_WEBHOOK_SECRET"))
sigHeader := r.Header.Get("Jobbydev-Signature")
var t, v1 string
for _, part := range strings.Split(sigHeader, ",") {
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
t = kv[1]
case "v1":
v1 = kv[1]
}
}
if t == "" || v1 == "" {
http.Error(w, "missing signature", http.StatusBadRequest)
return
}
tNum, err := strconv.ParseInt(t, 10, 64)
if err != nil || time.Now().Unix()-tNum > toleranceSeconds {
http.Error(w, "signature too old", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "body read", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, secret)
fmt.Fprintf(mac, "%s.%s", t, body)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(v1)) {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
// ... handle event idempotently ...
w.WriteHeader(http.StatusOK)
}Common mistakes
- Parsed body, not raw.Express / Flask / Gin parse JSON by default; the parsed-then-re-stringified body won't match the signature. Use the raw bytes.
- Plain string equality. Use a constant-time comparison. Most languages have one in stdlib (
hmac.compare_digest,crypto.timingSafeEqual,hmac.Equal). - No replay window. Without a tolerance check, an attacker who once captured a valid delivery can replay it forever. 5 minutes is generous; 30-60 seconds is tighter.
- Verifying after parsing. Verify first, parse second. A malicious payload that crashes your parser shouldn't be visible to the parser at all.