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

  1. Parse the Playgent-Signature header.
  2. Reject requests where t is more than 5 minutes from your server's clock (replay protection).
  3. Compute HMAC-SHA256(secret, t + "." + raw_body) and compare with v1 in 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.