Webhooks
Signing & verification
Every webhook is signed with HMAC-SHA256. Verify the signature and timestamp before trusting any payload.
The signature header
Every request carries:
text
Playgent-Signature: t=<unix_timestamp>,v1=<hex_signature>
Playgent-Event: game.completed
User-Agent: Playgent-Webhook/1
Content-Type: application/json
t— UNIX timestamp (seconds).v1— HMAC-SHA256 of<t>.<raw body>using your signing secret.
Verification
- Parse the
Playgent-Signatureheader. - Reject requests where
tis more than 5 minutes from your server's clock (replay protection). - Compute
HMAC-SHA256(secret, t + "." + raw_body)and compare withv1in constant time.
Node.js
js
import crypto from "node:crypto";
export function verify(req, body, secret) {
const header = req.headers["playgent-signature"] ?? "";
const parts = Object.fromEntries(
header.split(",").map((p) => p.trim().split("="))
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${body}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
Python
python
import hmac, hashlib, time
def verify(headers, body, secret):
sig = dict(p.split("=") for p in headers.get("Playgent-Signature", "").split(","))
t = int(sig.get("t", 0))
v1 = sig.get("v1", "")
if not t or not v1:
return False
if abs(time.time() - t) > 300:
return False
expected = hmac.new(
secret.encode(), f"{t}.{body}".encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, v1)
Go
go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
"strconv"
"time"
)
func verify(header string, body []byte, secret string) bool {
parts := map[string]string{}
for _, p := range strings.Split(header, ",") {
kv := strings.SplitN(strings.TrimSpace(p), "=", 2)
if len(kv) == 2 { parts[kv[0]] = kv[1] }
}
t, _ := strconv.ParseInt(parts["t"], 10, 64)
v1 := parts["v1"]
if t == 0 || v1 == "" { return false }
if abs(time.Now().Unix() - t) > 300 { return false }
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(t, 10) + "." + string(body)))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(v1))
}
func abs(x int64) int64 { if x < 0 { return -x }; return x }
Use the raw body
You must verify against the raw, unparsed request body — not the JSON-parsed version. Re-serializing strips whitespace and reorders keys, breaking the signature.
In Express:
js
app.post(
"/webhooks/playgent",
express.raw({ type: "application/json" }),
(req, res) => {
const ok = verify(req, req.body.toString("utf8"), process.env.PLAYGENT_WHSEC);
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
/* … */
res.status(200).end();
}
);
Reject before parsing
Always check the signature before parsing or processing. An unsigned payload is untrusted input.