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.
The scheme
Section titled “The scheme”| 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 bodysignature = "v1," + base64(HMAC-SHA256(key, signed))GET requests sign an empty body.
- Read the raw body bytes exactly as received. Do not parse and re-serialise JSON first: a single changed space breaks the signature.
- Read the three headers (header names are case-insensitive). Missing or non-numeric values → reject.
- Reject if
|now − webhook-timestamp| > 300seconds. Keep your servers on NTP. - For each accepted secret, compute the expected
v1signature and compare it with everyv1,entry inwebhook-signatureusing a constant-time comparison. Ignore entries with other version prefixes. - Accept if any entry matches; otherwise reject with 401.
- Optional replay protection: remember each accepted
webhook-idfor 10 minutes and reject repeats. Each retry from the platform uses a new id, so this never blocks a legitimate retry.
Secret rotation
Section titled “Secret rotation”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.
Examples
Section titled “Examples”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
// 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
// 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 rotatingconst 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();});Verifier
// Package stargame verifies Star Game platform requests (Standard Webhooks, HMAC-SHA256). Standard library only.package stargame
import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" "net/http" "strconv" "strings" "time")
const Tolerance = 300 * time.Second
var ( ErrBadSecret = errors.New("bad_secret") ErrMissingHeaders = errors.New("missing_headers") ErrStaleTimestamp = errors.New("stale_timestamp") ErrNoMatchingSignature = errors.New("no_matching_signature"))
// DecodeSecret decodes a whsec_<base64> secret into the raw HMAC key.func DecodeSecret(secret string) ([]byte, error) { raw, ok := strings.CutPrefix(secret, "whsec_") if !ok { return nil, ErrBadSecret } key, err := base64.StdEncoding.DecodeString(raw) if err != nil || len(key) == 0 { return nil, ErrBadSecret } return key, nil}
// Verify checks the webhook-* headers against the exact request body bytes and returns the// webhook-id for replay protection. secrets lists every secret currently accepted (two while rotating).func Verify(secrets []string, h http.Header, body []byte, now time.Time) (string, error) { keys := make([][]byte, 0, len(secrets)) for _, s := range secrets { k, err := DecodeSecret(s) if err != nil { return "", err } keys = append(keys, k) } id, tsRaw, sigRaw := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature") if id == "" || sigRaw == "" || tsRaw == "" || strings.Trim(tsRaw, "0123456789") != "" { return "", ErrMissingHeaders } ts, err := strconv.ParseInt(tsRaw, 10, 64) // err only on overflow: far outside the window if d := now.Sub(time.Unix(ts, 0)); err != nil || d > Tolerance || d < -Tolerance { return "", ErrStaleTimestamp } for _, key := range keys { mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + tsRaw + ".")) mac.Write(body) expected := mac.Sum(nil) for _, sig := range strings.Fields(sigRaw) { b64, ok := strings.CutPrefix(sig, "v1,") if !ok { continue } got, err := base64.StdEncoding.DecodeString(b64) if err == nil && hmac.Equal(got, expected) { return id, nil } } } return "", ErrNoMatchingSignature}Usage
// Package handler shows net/http middleware that verifies every wallet request.package handler
import ( "bytes" "io" "net/http" "time"
"example.com/stargame-signature/stargame")
// WalletAuth rejects unsigned or badly signed requests with a 401 problem+json and// restores the body for the next handler. secrets holds every accepted whsec_ secret.func WalletAuth(secrets []string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4<<20)) if err == nil { _, err = stargame.Verify(secrets, r.Header, body, time.Now()) } if err != nil { w.Header().Set("Content-Type", "application/problem+json") w.WriteHeader(http.StatusUnauthorized) _, _ = io.WriteString(w, `{"type":"about:blank","title":"Invalid signature","status":401,"code":"invalid_signature"}`) return } r.Body = io.NopCloser(bytes.NewReader(body)) next.ServeHTTP(w, r) })}Verifier
"""Verifies Star Game platform requests (Standard Webhooks, HMAC-SHA256). Python 3.8+, standard library only."""import base64import binasciiimport hashlibimport hmacimport reimport timefrom typing import Mapping, Optional, Sequence
TOLERANCE_SECONDS = 300
class SignatureError(Exception): """code is one of bad_secret, missing_headers, stale_timestamp, no_matching_signature."""
def __init__(self, code: str) -> None: super().__init__(code) self.code = code
def decode_secret(secret: str) -> bytes: """Decodes a whsec_<base64> secret into the raw HMAC key.""" if not secret.startswith("whsec_"): raise SignatureError("bad_secret") try: key = base64.b64decode(secret[len("whsec_"):], validate=True) except (binascii.Error, ValueError): raise SignatureError("bad_secret") from None if not key: raise SignatureError("bad_secret") return key
def verify_webhook(secrets: Sequence[str], headers: Mapping[str, str], raw_body: bytes, now: Optional[int] = None) -> str: """Checks the webhook-* headers against the exact request body bytes; returns the webhook-id for replay protection.
secrets lists every secret currently accepted (two while rotating). headers must be looked up case-insensitively by your framework (e.g. request.headers in Flask/Django/FastAPI).""" keys = [decode_secret(s) for s in secrets] msg_id = headers.get("webhook-id", "") timestamp = headers.get("webhook-timestamp", "") signature = headers.get("webhook-signature", "") if not msg_id or not signature or not re.fullmatch(r"[0-9]+", timestamp): raise SignatureError("missing_headers") now = int(time.time()) if now is None else now if abs(now - int(timestamp)) > TOLERANCE_SECONDS: raise SignatureError("stale_timestamp")
signed = f"{msg_id}.{timestamp}.".encode() + raw_body received = [s[3:].encode() for s in signature.split(" ") if s.startswith("v1,")] for key in keys: expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()) if any(hmac.compare_digest(sig, expected) for sig in received): return msg_id raise SignatureError("no_matching_signature")Usage
"""Flask: verify every wallet request before your views run."""import os
from flask import Flask, request
from stargame_verify import SignatureError, verify_webhook
SECRETS = os.environ["STARGAME_WALLET_SECRETS"].split(",") # "whsec_new,whsec_old" while rotatingapp = Flask(__name__)
@app.before_requestdef check_signature(): try: verify_webhook(SECRETS, request.headers, request.get_data()) except SignatureError: problem = {"type": "about:blank", "title": "Invalid signature", "status": 401, "code": "invalid_signature"} return problem, 401, {"Content-Type": "application/problem+json"} return NoneVerifier
<?php// Verifies Star Game platform requests (Standard Webhooks, HMAC-SHA256). PHP 7.4+, no extensions beyond core.
declare(strict_types=1);
const STARGAME_TOLERANCE_SECONDS = 300;
final class StarGameSignatureError extends RuntimeException{ /** One of bad_secret, missing_headers, stale_timestamp, no_matching_signature. */ public string $errorCode;
public function __construct(string $code) { parent::__construct($code); $this->errorCode = $code; }}
/** Decodes a whsec_<base64> secret into the raw HMAC key. */function stargame_decode_secret(string $secret): string{ if (strncmp($secret, 'whsec_', 6) !== 0) { throw new StarGameSignatureError('bad_secret'); } $key = base64_decode(substr($secret, 6), true); if ($key === false || $key === '') { throw new StarGameSignatureError('bad_secret'); } return $key;}
/** * Checks the webhook-* headers against the exact request body (file_get_contents('php://input')) * and returns the webhook-id for replay protection. * * @param string[] $secrets every secret currently accepted (two while rotating) */function stargame_verify_webhook(array $secrets, string $id, string $timestamp, string $signature, string $rawBody, ?int $now = null): string{ $keys = array_map('stargame_decode_secret', $secrets); if ($id === '' || $signature === '' || !preg_match('/^[0-9]+$/', $timestamp)) { throw new StarGameSignatureError('missing_headers'); } $now = $now ?? time(); if (abs($now - (int) $timestamp) > STARGAME_TOLERANCE_SECONDS) { throw new StarGameSignatureError('stale_timestamp'); }
$signed = $id . '.' . $timestamp . '.' . $rawBody; foreach ($keys as $key) { $expected = base64_encode(hash_hmac('sha256', $signed, $key, true)); foreach (explode(' ', $signature) as $candidate) { if (strncmp($candidate, 'v1,', 3) === 0 && hash_equals($expected, substr($candidate, 3))) { return $id; } } } throw new StarGameSignatureError('no_matching_signature');}Usage
<?php// Plain PHP front controller: verify the request, then decode the raw body you verified.
declare(strict_types=1);
require __DIR__ . '/verify.php';
$secrets = explode(',', (string) getenv('STARGAME_WALLET_SECRETS')); // "whsec_new,whsec_old" while rotating$rawBody = (string) file_get_contents('php://input');
try { stargame_verify_webhook( $secrets, $_SERVER['HTTP_WEBHOOK_ID'] ?? '', $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '', $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '', $rawBody );} catch (StarGameSignatureError $e) { http_response_code(401); header('Content-Type: application/problem+json'); echo json_encode(['type' => 'about:blank', 'title' => 'Invalid signature', 'status' => 401, 'code' => 'invalid_signature']); exit;}
$request = $rawBody === '' ? null : json_decode($rawBody, true);Verifier
// Verifies Star Game platform requests (Standard Webhooks, HMAC-SHA256). Java 11+, JDK only.
import java.nio.charset.StandardCharsets;import java.security.GeneralSecurityException;import java.security.MessageDigest;import java.util.ArrayList;import java.util.Base64;import java.util.List;import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;
public final class StarGameSignature { public static final long TOLERANCE_SECONDS = 300;
/** getMessage() is one of bad_secret, missing_headers, stale_timestamp, no_matching_signature. */ public static final class SignatureException extends Exception { public SignatureException(String code) { super(code); } }
private StarGameSignature() {}
/** Decodes a whsec_<base64> secret into the raw HMAC key. */ public static byte[] decodeSecret(String secret) throws SignatureException { if (!secret.startsWith("whsec_")) throw new SignatureException("bad_secret"); try { byte[] key = Base64.getDecoder().decode(secret.substring(6)); if (key.length == 0) throw new SignatureException("bad_secret"); return key; } catch (IllegalArgumentException e) { throw new SignatureException("bad_secret"); } }
/** * Checks the webhook-* headers against the exact request body bytes and returns the webhook-id * for replay protection. {@code secrets} lists every secret currently accepted (two while rotating). */ public static String verify(List<String> secrets, String id, String timestamp, String signature, byte[] rawBody, long nowSeconds) throws SignatureException { List<byte[]> keys = new ArrayList<>(); for (String s : secrets) keys.add(decodeSecret(s)); if (id == null || id.isEmpty() || signature == null || signature.isEmpty() || timestamp == null || !timestamp.matches("[0-9]+")) { throw new SignatureException("missing_headers"); } try { if (Math.abs(nowSeconds - Long.parseLong(timestamp)) > TOLERANCE_SECONDS) { throw new SignatureException("stale_timestamp"); } } catch (NumberFormatException overflow) { // digits only, so this is a value far outside the window throw new SignatureException("stale_timestamp"); }
byte[] prefix = (id + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8); for (byte[] key : keys) { byte[] expected; try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(key, "HmacSHA256")); mac.update(prefix); expected = mac.doFinal(rawBody); } catch (GeneralSecurityException e) { throw new IllegalStateException(e); } for (String candidate : signature.split(" ")) { if (!candidate.startsWith("v1,")) continue; byte[] got; try { got = Base64.getDecoder().decode(candidate.substring(3)); } catch (IllegalArgumentException e) { continue; } if (MessageDigest.isEqual(got, expected)) return id; } } throw new SignatureException("no_matching_signature"); }
public static String verify(List<String> secrets, String id, String timestamp, String signature, byte[] rawBody) throws SignatureException { return verify(secrets, id, timestamp, signature, rawBody, System.currentTimeMillis() / 1000); }}Usage
// Spring MVC: take the body as raw bytes, verify, then parse.@PostMapping(path = "/game-wallet/v1/transactions", consumes = MediaType.APPLICATION_JSON_VALUE)public ResponseEntity<?> transactions(@RequestHeader HttpHeaders headers, @RequestBody byte[] rawBody) throws IOException { try { StarGameSignature.verify(secrets, // List.of("whsec_new", "whsec_old") while rotating headers.getFirst("webhook-id"), headers.getFirst("webhook-timestamp"), headers.getFirst("webhook-signature"), rawBody); } catch (StarGameSignature.SignatureException e) { return ResponseEntity.status(401).contentType(MediaType.APPLICATION_PROBLEM_JSON) .body(Map.of("type", "about:blank", "title", "Invalid signature", "status", 401, "code", "invalid_signature")); } TransactionBatch batch = objectMapper.readValue(rawBody, TransactionBatch.class); return ResponseEntity.ok(wallet.apply(batch));}Official libraries
Section titled “Official libraries”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.
Test vectors
Section titled “Test vectors”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.
Common mistakes
Section titled “Common mistakes”- 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.