PlaygentDocs

DevelopersWebhooks

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

  1. A reader your site has signed in finishes a puzzle on a Daily or Pinned game.
  2. Playgent sends a POST to your address with the result as JSON.
  3. 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

  1. Build an endpoint that answers the challenge

    When you save the address, Playgent sends a test event with a challenge. Your server must answer 2xx with the challenge in the body. See the test event.

  2. Save the address

    Go to SettingsEmbedResults webhook, enter your Webhook address and press Save webhook. It saves only when your server answers the challenge.

  3. 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.

  4. 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, .localhost or .test.

For local development, use a tunnel that gives your computer a public https address.

The request

Text
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…
HeaderValue
Playgent-Eventresult or test.
Playgent-DeliveryThe event's id. The same on every retry.
Playgent-Signaturet=<unix seconds>,v1=<signature>. Right after you rotate the secret, it carries two v1 values.

The result event

JSON
{
  "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": []
  }
}
FieldMeaning
idThe event id, evt_…. The same on every retry.
createdWhen the event was made.
data.resultA unique key for this reader's result on this board. Use it to store each result once.
data.replacesThe id of an earlier unverified event this one replaces, or null.
data.gameIdThe game's id, as in its embed code.
data.gameThe game type, such as worday.
data.puzzled and the date for Daily games. For Pinned games, a code for the pinned puzzle.
data.dayYYYYMMDD for Daily games, null for Pinned.
data.levelThe level, or - for games without levels.
data.readerThe id you passed to identify, and the name shown on the board.
data.verifiedtrue when the reader came with a valid signature from your server.
data.wonfalse for a loss, or when the solution was shown.
data.revealedThe player showed the solution.
data.msTime in milliseconds, hint time included.
data.hintsHints used.
data.statsThe game's own numbers, such as guesses, moves or score.
data.rank, data.totalPlace on the board when the event was made, and how many ranked. null when it doesn't rank, or the leaderboard is off.
data.sortKeySorts results best first, ascending. null when the result can't rank.
data.flagsReasons to be careful. See below.

Flags

FlagMeaning
too_fastFaster than a person can play it.
off_dayPlayed on a different day than the puzzle's, in the reader's time zone.
ip_capToo many plays of this puzzle from one network.
guestPlayed as a guest on this device before signing in.

When to reward

Reward a result only when all of these hold:

JavaScript
event.type === 'result' && data.verified && data.won && data.flags.length === 0
  • verified means 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: true and replaces set to the first event's id. Reward that one.
  • For final standings of a puzzle, sort all the events you received for one gameId, puzzle and level by sortKey, skipping null.

The test event

Saving the address and Send a test both send a test event. On save it carries a challenge:

JSON
{ "id": "evt_…", "type": "test", "created": "2026-10-24T13:05:12.345Z", "data": { "challenge": "9f2c4a1b0d3e5f67" } }

Answer 2xx with the challenge back:

JSON
{ "challenge": "9f2c4a1b0d3e5f67" }

Only the first 1 KB of your answer is read. Send a test sends "data": {}: any 2xx answer passes.

A complete receiver

JavaScript
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()
})

Retries

  • Answer within 10 seconds. A 2xx answer ends the delivery.
  • 408, 425, 429, any 5xx, 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 2xx again.
  • 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.