Verify signatures
Playgent signs two things with HMAC-SHA256: every webhook delivery, so your server knows it came from Playgent, and, on your side, each signed-in reader's id, so Playgent knows your site vouched for the reader. Try both below.
Check it here
Paste a real delivery, or press Try an example. Everything runs in your browser.
Fill in the three fields, or try an example.
Webhook deliveries
Each delivery has a Playgent-Signature header:
Playgent-Signature: t=1729775112,v1=5f2b8c0e…Read the raw body
Take the body bytes exactly as they arrived, before parsing the JSON. Parsing and printing it again changes the bytes.
Split the header
Split it on
,. Taketfromt=, and every value afterv1=.Compute the signature
HMAC-SHA256 of
<t>.<raw body>, keyed with your Webhook secret (the whole text, includingpgw_), as lowercase hex.Compare
Accept the delivery if any
v1matches, comparing in constant time. Reject it iftis more than 5 minutes from now.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(rawBody, header, secret) {
const parts = header.split(',')
const t = parts.find((part) => part.startsWith('t='))?.slice(2) ?? ''
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
const mac = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest()
return parts.filter((part) => part.startsWith('v1=')).some((part) => {
const got = Buffer.from(part.slice(3), 'hex')
return got.length === mac.length && timingSafeEqual(got, mac)
})
}import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = header.split(',')
t = next((p[2:] for p in parts if p.startswith('t=')), '')
if not t.isdigit() or abs(time.time() - int(t)) > 300:
return False
mac = hmac.new(secret.encode(), f'{t}.'.encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(p[3:], mac) for p in parts if p.startswith('v1='))function verify(string $rawBody, string $header, string $secret): bool {
$t = '';
$given = [];
foreach (explode(',', $header) as $part) {
if (str_starts_with($part, 't=')) $t = substr($part, 2);
if (str_starts_with($part, 'v1=')) $given[] = substr($part, 3);
}
if (!ctype_digit($t) || abs(time() - (int) $t) > 300) return false;
$mac = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($given as $v) if (hash_equals($mac, $v)) return true;
return false;
}import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"strconv"
"strings"
"time"
)
func Verify(rawBody []byte, header, secret string) bool {
var t string
var given []string
for _, part := range strings.Split(header, ",") {
if strings.HasPrefix(part, "t=") {
t = part[2:]
} else if strings.HasPrefix(part, "v1=") {
given = append(given, part[3:])
}
}
sec, err := strconv.ParseInt(t, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-sec)) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(rawBody)
want := hex.EncodeToString(mac.Sum(nil))
for _, v := range given {
if hmac.Equal([]byte(v), []byte(want)) {
return true
}
}
return false
}require 'openssl'
def verify(raw_body, header, secret)
parts = header.split(',')
t = parts.find { |p| p.start_with?('t=') }&.delete_prefix('t=').to_s
return false unless t.match?(/\A\d+\z/) && (Time.now.to_i - t.to_i).abs <= 300
mac = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{t}.#{raw_body}")
parts.select { |p| p.start_with?('v1=') }.any? { |p| OpenSSL.fixed_length_secure_compare(p.delete_prefix('v1='), mac) rescue false }
endSigned-in readers
Your server signs each reader's id so their results arrive with verified: true. The signature is HMAC-SHA256 of the reader's id, keyed with your Signing secret from SettingsEmbedSigning secret (the whole text, including pgs_), as 64 lowercase hex characters.
createHmac('sha256', process.env.PLAYGENT_SIGNING_SECRET).update(String(user.id)).digest('hex')hmac.new(os.environ['PLAYGENT_SIGNING_SECRET'].encode(), str(user_id).encode(), hashlib.sha256).hexdigest()hash_hmac('sha256', (string) $userId, getenv('PLAYGENT_SIGNING_SECRET'))Pass the result as sig to Playgent.identify(). See signed-in readers.
Two secrets, two jobs
| Signing secret | Webhook secret | |
|---|---|---|
| Starts with | pgs_ | pgw_ |
| Where | SettingsEmbedSigning secret | SettingsEmbedResults webhook |
| Signs | Your readers' ids, on your server | Each webhook delivery, at Playgent |
| After a rotation | The old one works for 7 more days | Deliveries carry both signatures for 7 days |
Keep both on your server only. Never put them in a page, an app or a public repository.