Webhooks
The results webhook sends each signed-in reader's first result for a puzzle to your server, as it happens. Every delivery is signed, so you can trust it enough to award points, prizes or badges.
How it works
- A reader your site has signed in finishes a puzzle on a Daily or Pinned game.
- Playgent sends a
POSTto your address with the result as JSON. - Your server checks the signature, saves the result and answers
200.
Guests are never sent. Only the first finished try at each puzzle and level is sent, so a reader can't replay for a better result.
Set it up
Build an endpoint that answers the challenge
When you save the address, Playgent sends a
testevent with a challenge. Your server must answer2xxwith the challenge in the body. See the test event.Save the address
Go to SettingsEmbedResults webhook, enter your Webhook address and press Save webhook. It saves only when your server answers the challenge.
Copy the webhook secret
Once saved, the Webhook secret appears under the address. Press Show or Copy and keep it on your server. Use it to verify signatures.
Send a test
Press Send a test any time. Studio shows what your server answered.
Live games start sending results within a minute, with no update needed.
Address rules
https://only, up to 2,048 characters.- A public domain name: no IP address, port, username or password.
- Not a local or test domain, such as
.local,.internal,.localhostor.test.
For local development, use a tunnel that gives your computer a public https address.
The request
POST /hooks/playgent HTTP/1.1
Content-Type: application/json
User-Agent: Playgent-Webhooks/1
Playgent-Event: result
Playgent-Delivery: evt_6f1c2a7e-7d0b-4a51-9f39-2a0c1b8e9d10
Playgent-Signature: t=1729775112,v1=5f2b8c…| Header | Value |
|---|---|
Playgent-Event | result or test. |
Playgent-Delivery | The event's id. The same on every retry. |
Playgent-Signature | t=<unix seconds>,v1=<signature>. Right after you rotate the secret, it carries two v1 values. |
The result event
{
"id": "evt_6f1c2a7e-7d0b-4a51-9f39-2a0c1b8e9d10",
"type": "result",
"created": "2026-10-24T13:05:12.345Z",
"data": {
"result": "pg_0123456789/d20261024/-/reader-42",
"replaces": null,
"gameId": "pg_0123456789",
"game": "worday",
"puzzle": "d20261024",
"day": "20261024",
"level": "-",
"reader": { "id": "reader-42", "name": "Dana K." },
"verified": true,
"won": true,
"revealed": false,
"ms": 61234,
"hints": 0,
"stats": { "guesses": 3 },
"rank": 37,
"total": 1204,
"sortKey": "00000000003000000000000000612340000000017",
"flags": []
}
}| Field | Meaning |
|---|---|
id | The event id, evt_…. The same on every retry. |
created | When the event was made. |
data.result | A unique key for this reader's result on this board. Use it to store each result once. |
data.replaces | The id of an earlier unverified event this one replaces, or null. |
data.gameId | The game's id, as in its embed code. |
data.game | The game type, such as worday. |
data.puzzle | d and the date for Daily games. For Pinned games, a code for the pinned puzzle. |
data.day | YYYYMMDD for Daily games, null for Pinned. |
data.level | The level, or - for games without levels. |
data.reader | The id you passed to identify, and the name shown on the board. |
data.verified | true when the reader came with a valid signature from your server. |
data.won | false for a loss, or when the solution was shown. |
data.revealed | The player showed the solution. |
data.ms | Time in milliseconds, hint time included. |
data.hints | Hints used. |
data.stats | The game's own numbers, such as guesses, moves or score. |
data.rank, data.total | Place on the board when the event was made, and how many ranked. null when it doesn't rank, or the leaderboard is off. |
data.sortKey | Sorts results best first, ascending. null when the result can't rank. |
data.flags | Reasons to be careful. See below. |
Flags
| Flag | Meaning |
|---|---|
too_fast | Faster than a person can play it. |
off_day | Played on a different day than the puzzle's, in the reader's time zone. |
ip_cap | Too many plays of this puzzle from one network. |
guest | Played as a guest on this device before signing in. |
When to reward
Reward a result only when all of these hold:
event.type === 'result' && data.verified && data.won && data.flags.length === 0verifiedmeans your server vouched for the reader's id. The time and stats still come from the reader's browser, so cap rewards per reader per day.- If a reader's first result arrived unverified and they later play with a valid signature, a second event arrives with
verified: trueandreplacesset to the first event's id. Reward that one. - For final standings of a puzzle, sort all the events you received for one
gameId,puzzleandlevelbysortKey, skippingnull.
The test event
Saving the address and Send a test both send a test event. On save it carries a challenge:
{ "id": "evt_…", "type": "test", "created": "2026-10-24T13:05:12.345Z", "data": { "challenge": "9f2c4a1b0d3e5f67" } }Answer 2xx with the challenge back:
{ "challenge": "9f2c4a1b0d3e5f67" }Only the first 1 KB of your answer is read. Send a test sends "data": {}: any 2xx answer passes.
A complete receiver
import express from 'express'
import { createHmac, timingSafeEqual } from 'node:crypto'
const SECRET = process.env.PLAYGENT_WEBHOOK_SECRET
const app = express()
app.post('/hooks/playgent', express.raw({ type: 'application/json' }), (req, res) => {
const parts = String(req.get('Playgent-Signature') ?? '').split(',')
const t = parts.find((part) => part.startsWith('t='))?.slice(2) ?? ''
const mac = createHmac('sha256', SECRET).update(`${t}.`).update(req.body).digest()
const signed = parts.filter((part) => part.startsWith('v1=')).some((part) => {
const got = Buffer.from(part.slice(3), 'hex')
return got.length === mac.length && timingSafeEqual(got, mac)
})
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300
const event = JSON.parse(req.body.toString('utf8'))
if (event.type === 'test' && event.data.challenge && !SECRET) {
return res.json({ challenge: event.data.challenge })
}
if (!signed || !fresh) return res.status(401).end()
if (event.type === 'test') return res.json({ challenge: event.data.challenge })
const { data } = event
saveOnce(data.result, data) // store by data.result, so a repeat is ignored
if (data.verified && data.won && data.flags.length === 0) reward(data.reader.id, data)
res.status(200).end()
})import hashlib, hmac, json, os, time
from flask import Flask, request, jsonify
SECRET = os.environ.get('PLAYGENT_WEBHOOK_SECRET', '')
app = Flask(__name__)
@app.post('/hooks/playgent')
def playgent():
raw = request.get_data()
parts = request.headers.get('Playgent-Signature', '').split(',')
t = next((p[2:] for p in parts if p.startswith('t=')), '')
mac = hmac.new(SECRET.encode(), f'{t}.'.encode() + raw, hashlib.sha256).hexdigest()
signed = any(hmac.compare_digest(p[3:], mac) for p in parts if p.startswith('v1='))
fresh = t.isdigit() and abs(time.time() - int(t)) < 300
event = json.loads(raw)
if event['type'] == 'test' and event['data'].get('challenge') and not SECRET:
return jsonify(challenge=event['data']['challenge'])
if not (signed and fresh):
return '', 401
if event['type'] == 'test':
return jsonify(challenge=event['data'].get('challenge'))
data = event['data']
save_once(data['result'], data)
if data['verified'] and data['won'] and not data['flags']:
reward(data['reader']['id'], data)
return '', 200<?php
$secret = getenv('PLAYGENT_WEBHOOK_SECRET') ?: '';
$raw = file_get_contents('php://input');
$parts = explode(',', $_SERVER['HTTP_PLAYGENT_SIGNATURE'] ?? '');
$t = '';
$given = [];
foreach ($parts as $part) {
if (str_starts_with($part, 't=')) $t = substr($part, 2);
if (str_starts_with($part, 'v1=')) $given[] = substr($part, 3);
}
$mac = hash_hmac('sha256', $t . '.' . $raw, $secret);
$signed = count(array_filter($given, fn ($v) => hash_equals($mac, $v))) > 0;
$fresh = ctype_digit($t) && abs(time() - (int) $t) < 300;
$event = json_decode($raw, true);
header('Content-Type: application/json');
if ($event['type'] === 'test' && !empty($event['data']['challenge']) && $secret === '') {
echo json_encode(['challenge' => $event['data']['challenge']]);
exit;
}
if (!$signed || !$fresh) { http_response_code(401); exit; }
if ($event['type'] === 'test') {
echo json_encode(['challenge' => $event['data']['challenge'] ?? null]);
exit;
}
$data = $event['data'];
save_once($data['result'], $data);
if ($data['verified'] && $data['won'] && count($data['flags']) === 0) {
reward($data['reader']['id'], $data);
}
http_response_code(200);Retries
- Answer within 10 seconds. A
2xxanswer ends the delivery. 408,425,429, any5xx, a timeout or a network error is retried after 30 seconds, 2 minutes, 10 minutes, 1 hour, 3 hours, 6 hours, 12 hours and 24 hours. After those 9 tries in about two days, the event is dropped.- Any other answer, redirects included, drops the event at once.
- After 20 failures in a row, events are sent one at a time until your server answers
2xxagain. - A delivery can arrive twice. Store each result by
data.result, or skip repeated event ids.
Failed deliveries aren't listed in Studio. Log what your server receives, and use Send a test to check it.
Rotate the secret
Press Rotate webhook secret in SettingsEmbedResults webhook. For the next 7 days, every delivery is signed with both secrets, so your server keeps working while you switch to the new one.
Remove the webhook
Press Remove webhook. Deliveries stop. The secret is kept, in case you set an address again later.