Skip to content

Signature verification

Every request the platform sends to your wallet is signed following the Standard Webhooks specification. Verify it before you parse or act on the body, and answer 401 with code: "invalid_signature" when verification fails.

Header Value
webhook-id msg_<random>, unique per HTTP attempt
webhook-timestamp Unix time in seconds when the attempt was signed
webhook-signature One or more space-separated v1,<base64 signature> entries
key = base64_decode(secret without the "whsec_" prefix)
signed = webhook-id + "." + webhook-timestamp + "." + raw request body
signature = "v1," + base64(HMAC-SHA256(key, signed))

GET requests sign an empty body.

  1. Read the raw body bytes exactly as received. Do not parse and re-serialise JSON first: a single changed space breaks the signature.
  2. Read the three headers (header names are case-insensitive). Missing or non-numeric values → reject.
  3. Reject if |now − webhook-timestamp| > 300 seconds. Keep your servers on NTP.
  4. For each accepted secret, compute the expected v1 signature and compare it with every v1, entry in webhook-signature using a constant-time comparison. Ignore entries with other version prefixes.
  5. Accept if any entry matches; otherwise reject with 401.
  6. Optional replay protection: remember each accepted webhook-id for 10 minutes and reject repeats. Each retry from the platform uses a new id, so this never blocks a legitimate retry.

Rotate the secret in Console → Integration (pick the Sandbox or Production tab), card Signing secret, button Rotate secret. For the next 24 hours the platform signs every request with both the new and the previous secret (two v1, entries). Deploy the new secret alongside the old one (all examples accept a list of secrets), confirm traffic verifies, then remove the old one. Secrets are shown once; we store only a fingerprint.

Each verifier below has no third-party dependencies and is tested against the official Standard Webhooks test vectors and a vector produced by the Star Game platform signer (see Test vectors). Errors use four codes: bad_secret, missing_headers, stale_timestamp, no_matching_signature.

Verifier

verify.mjs
// Verifies Star Game platform requests (Standard Webhooks, HMAC-SHA256). Node.js 18+, no dependencies.
import { createHmac, timingSafeEqual } from "node:crypto";
export const TOLERANCE_SECONDS = 300;
export class SignatureError extends Error {
/** @param {"bad_secret" | "missing_headers" | "stale_timestamp" | "no_matching_signature"} code */
constructor(code) {
super(code);
this.code = code;
}
}
/** Decodes a `whsec_<base64>` secret into the raw HMAC key. */
export function decodeSecret(secret) {
const raw = secret.startsWith("whsec_") ? secret.slice(6) : null;
if (!raw || !/^[A-Za-z0-9+/]+={0,2}$/.test(raw)) throw new SignatureError("bad_secret");
return Buffer.from(raw, "base64");
}
/**
* Verifies the webhook-* headers against the exact request body bytes.
* @param {string[]} secrets every secret currently accepted (two while rotating)
* @param {{ id?: string, timestamp?: string, signature?: string }} headers
* @param {Buffer | string} rawBody the body exactly as received; never a re-serialised JSON object
* @param {number} [now] current unix time in seconds
* @returns {string} the webhook-id, for replay protection
*/
export function verifyWebhook(secrets, headers, rawBody, now = Math.floor(Date.now() / 1000)) {
const keys = secrets.map(decodeSecret);
const { id, timestamp, signature } = headers;
if (!id || !timestamp || !signature || !/^\d+$/.test(timestamp)) throw new SignatureError("missing_headers");
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) throw new SignatureError("stale_timestamp");
const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), Buffer.from(rawBody)]);
const received = signature
.split(" ")
.filter((s) => s.startsWith("v1,"))
.map((s) => Buffer.from(s.slice(3), "base64"));
for (const key of keys) {
const expected = createHmac("sha256", key).update(signed).digest();
if (received.some((sig) => sig.length === expected.length && timingSafeEqual(sig, expected))) return id;
}
throw new SignatureError("no_matching_signature");
}

Usage

usage-express.mjs
// Express: verify every wallet request before your handlers see it.
import express from "express";
import { SignatureError, verifyWebhook } from "./verify.mjs";
const secrets = process.env.STARGAME_WALLET_SECRETS.split(","); // "whsec_new,whsec_old" while rotating
const app = express();
app.use("/game-wallet/v1", express.raw({ type: "*/*", limit: "4mb" }), (req, res, next) => {
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0); // GET requests sign an empty body
try {
verifyWebhook(secrets, { id: req.get("webhook-id"), timestamp: req.get("webhook-timestamp"), signature: req.get("webhook-signature") }, rawBody);
} catch (e) {
if (!(e instanceof SignatureError)) return next(e);
return res
.status(401)
.type("application/problem+json")
.send(JSON.stringify({ type: "about:blank", title: "Invalid signature", status: 401, code: "invalid_signature" }));
}
req.wallet = rawBody.length ? JSON.parse(rawBody.toString("utf8")) : null;
next();
});

You can also use the official Standard Webhooks libraries. They accept the same whsec_… secret.

Language Package
JavaScript / TypeScript standardwebhooks (npm)
Python standardwebhooks (PyPI)
Go github.com/standard-webhooks/standard-webhooks/libraries/go
Java / Kotlin com.standardwebhooks:standardwebhooks (Maven Central)
PHP libraries/php in the standard-webhooks repository

When you need several secrets (rotation) with an official library, try each secret in turn.

Use these to unit-test your own implementation.

Official Standard Webhooks vector

Field Value
secret whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
webhook-id msg_p5jXN8AQM9LWM0D4loKWxJek
webhook-timestamp 1614265330
body {"test": 2432232314}
webhook-signature v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=

Star Game platform vector (test secret, not a real one)

Field Value
secret whsec_c3RhcmdhbWUtZG9jcy10ZXN0LXZlY3Rvci1rZXktMzI=
webhook-id msg_stargame_docs_vector_0001
webhook-timestamp 1791763200
body {"transactions":[{"transaction_id":"lantern:59666:p1:bet","type":"debit","player_id":"p1","session_id":"s1","amount":"1500","currency":"GOLD","game_id":"lantern","round_id":"lantern:59666"}]}
webhook-signature v1,v3cMUzEE3WjapPtMQQFOz2A/RM8BzVBh5ccL6P0/L+I=

Also check that you reject: a changed body, a changed id, a timestamp 301 s old or 301 s in the future, a header with only v2,… entries, and that you accept a header with several entries where one v1 matches.

  • Verifying a re-serialised JSON object instead of the raw body.
  • Comparing signatures with == instead of a constant-time function.
  • Reading the secret including the whsec_ prefix as the HMAC key — the key is the base64-decoded remainder.
  • Accepting the first v1, entry only — during rotation the matching one may be second.
  • Server clock drift beyond 300 s.