Skip to main content

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 with t more 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.

Related reading