Webhooks
Webhooks tell your systems when something happens in a workspace: publications change, players complete puzzles, comments are posted, moderation acts. Each event is a signed HTTPS POST.
Delivery
Each event is sent as a POST with a JSON body to every enabled endpoint of the workspace that subscribes to its type, while the workspace's plan includes API access. Every request carries these headers:
Puzzle-Event-Id: the event id, the same on every retry and redeliveryPuzzle-Event-Type: the event type, such as publication.publishedPuzzle-Delivery-Id: this delivery to this endpoint (the delivery log shows it)Puzzle-Signature: the signature to verify, described below
Answer with any 2xx within 10 seconds. Redirects are never followed and the response body is ignored, so do slow work later, in your own queue.
Envelope
Every body has the same envelope: id, type, version, createdAt, workspace (exactly one of ownerUserId and ownerOrgId is set) and the event's data.
{
"id": "0193a5b2-c051-7283-de45-6f708192a3b4",
"type": "publication.published",
"version": "1",
"createdAt": "2026-10-24T12:00:00.000Z",
"workspace": {
"ownerUserId": null,
"ownerOrgId": "0193a5b2-0000-7000-8000-000000000001"
},
"data": {}
}version changes only with a breaking change; within version 1, fields are only added. Ignore fields you don't know.
The test button in Studio sends a webhook.ping event (a single attempt, no retries) to any endpoint, subscribed or not:
{
"id": "0193a5b2-d162-7394-ef56-708192a3b4c5",
"type": "webhook.ping",
"version": "1",
"createdAt": "2026-10-24T12:00:00.000Z",
"workspace": {
"ownerUserId": null,
"ownerOrgId": "0193a5b2-0000-7000-8000-000000000001"
},
"data": {
"endpointId": "0193a5b2-e273-74a5-f067-8192a3b4c5d6"
}
}Verifying signatures
Each request is signed with the endpoint's secret (shown once in Studio). The Puzzle-Signature header holds a Unix timestamp t and one or more v1 signatures. Refuse requests whose signature doesn't match or whose timestamp is more than 300 seconds from your clock.
Puzzle-Signature: t=1792843200,v1=5d41402abc4b2a76b9719d911017c592ae7e3e7f0b1c2d3e4f5a6b7c8d9e0f1a- Read
tand everyv1from the header. - Compute the hex HMAC-SHA256 of
t, a dot and the raw request body, with the whole secret (includingwhsec_) as the key. - Compare it with each v1 value in constant time.
- Refuse timestamps more than 300 seconds away (replays).
- Deduplicate on the event
idbefore acting on it.
Node.js
import crypto from "node:crypto";
import express from "express";
const TOLERANCE_SECONDS = 300;
function verifyPuzzleSignature(secret, header, rawBody, nowSeconds = Date.now() / 1000) {
let timestamp = null;
const candidates = [];
for (const part of String(header ?? "").split(",")) {
const [key, value = ""] = part.trim().split("=", 2);
if (key === "t" && /^\d{1,12}$/.test(value)) timestamp = Number(value);
else if (key === "v1" && /^[0-9a-f]{64}$/.test(value)) candidates.push(value);
}
if (timestamp === null || candidates.length === 0) return false;
if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
return candidates.some((candidate) =>
crypto.timingSafeEqual(expected, Buffer.from(candidate, "hex"))
);
}
const app = express();
const seen = new Set(); // use your database in production
// The raw body: the signature covers the exact bytes received.
app.post("/puzzles/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyPuzzleSignature(
process.env.PUZZLE_WEBHOOK_SECRET,
req.get("Puzzle-Signature"),
req.body
);
if (!ok) return res.status(400).end();
const event = JSON.parse(req.body.toString("utf8"));
if (seen.has(event.id)) return res.status(200).end(); // a retry or redelivery
seen.add(event.id);
// Answer quickly; do the work in a queue.
res.status(200).end();
});Python
import hashlib
import hmac
import json
import os
import re
import time
from flask import Flask, abort, request
TOLERANCE_SECONDS = 300
app = Flask(__name__)
def verify_puzzle_signature(secret: str, header: str, raw_body: bytes) -> bool:
timestamp = None
candidates = []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t" and re.fullmatch(r"\d{1,12}", value):
timestamp = int(value)
elif key == "v1" and re.fullmatch(r"[0-9a-f]{64}", value):
candidates.append(value)
if timestamp is None or not candidates:
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, c) for c in candidates)
@app.post("/puzzles/webhooks")
def puzzle_webhook():
raw = request.get_data() # the exact bytes received
if not verify_puzzle_signature(
os.environ["PUZZLE_WEBHOOK_SECRET"],
request.headers.get("Puzzle-Signature", ""),
raw,
):
abort(400)
event = json.loads(raw)
# Deduplicate on event["id"]: retries and redeliveries repeat it.
return "", 200Verify the exact bytes you received: parsing and re-serializing the JSON changes them. Rotating the secret in Studio replaces it at once; later attempts are signed with the new one.
Retries and redelivery
- A failed attempt (no 2xx, a timeout or a connection error) is retried up to 8 times over about 21.6 hours, with growing waits.
- Retries and manual redeliveries keep the event
id: deduplicate on it. - Events can arrive out of order. Compare
createdAt, or read the current state through the API. - An endpoint is disabled after 3 deliveries in a row failed every attempt, and the workspace is told by email. Enabling it again in Studio resets the count.
- The delivery log in Studio keeps 30 days of deliveries with the exact body and every attempt, and can redeliver any finished one.
Volume caps and overload
completion.createdis capped per workspace: at most 60 events a minute and 2000 a day (UTC windows). Other event types aren't capped.- Events beyond a cap are dropped, not queued for later: read completions through the API to fill the gap.
- Offline completion uploads are limited per player (429
PLAY_RATE_LIMITED, 10 a minute), which also bounds the events they cause. - A 429 from your endpoint is retried like any failure, but a delivery whose last attempt got 429 doesn't count toward disabling the endpoint (your receiver is overloaded, not broken). 5xx answers, timeouts and connection errors still count toward the 3 in a row.
- Webhooks never block the change itself: if an event can't be queued, the change still happens.
What payloads contain
Payloads carry ids and the publication's public settings. They never contain solutions, answers, checks, play states, comment bodies, emails, names, or who played or commented. School students' completions are never sent. Read more through the API with a key when you need it.
Event catalog
Subscribe each endpoint to the types it needs. Every publication.* and moderation.* event carries the publication fields (publicationId, puzzleId, versionId, visibility, accessMode, mode) plus its own.
publication.published
A publication was published (it is playable unless moderation took it down).
| Field | Type | Notes |
|---|---|---|
| publicationId | uuid | |
| puzzleId | uuid | |
| versionId | uuid | null | The version the publication points at (completions: the version played). |
| visibility | "public" | "unlisted" | "private" | |
| accessMode | "standalone" | "embed" | "both" | |
| mode | "casual" | "competitive" | |
| publishedAt | date-time |
{
"id": "0193a5b2-c051-7283-de45-6f708192a3b4",
"type": "publication.published",
"version": "1",
"createdAt": "2026-10-24T12:00:00.000Z",
"workspace": {
"ownerUserId": null,
"ownerOrgId": "0193a5b2-0000-7000-8000-000000000001"
},
"data": {
"publicationId": "0193a5b2-7c1d-7e4f-9a01-2b3c4d5e6f70",
"puzzleId": "0193a5b2-6a0b-7c3d-8e9f-0a1b2c3d4e5f",
"versionId": "0193a5b2-8d2e-7f50-ab12-3c4d5e6f7081",
"visibility": "public",
"accessMode": "both",
"mode": "casual",
"publishedAt": "2026-10-24T12:00:00.000Z"
}
}publication.unpublished
A published publication stopped being published: unpublished by the workspace, or closed because its puzzle was deleted.
| Field | Type | Notes |
|---|---|---|
| publicationId | uuid | |
| puzzleId | uuid | |
| versionId | uuid | null | The version the publication points at (completions: the version played). |
| visibility | "public" | "unlisted" | "private" | |
| accessMode | "standalone" | "embed" | "both" | |
| mode | "casual" | "competitive" | |
| reason | "unpublished" | "puzzle_deleted" | unpublished: by the workspace; puzzle_deleted: its puzzle was deleted and the publication is gone. |
| unpublishedAt | date-time |
{
"id": "0193a5b2-c051-7283-de45-6f708192a3b4",
"type": "publication.unpublished",
"version": "1",
"createdAt": "2026-10-24T12:00:00.000Z",
"workspace": {
"ownerUserId": null,
"ownerOrgId": "0193a5b2-0000-7000-8000-000000000001"
},
"data": {
"publicationId": "0193a5b2-7c1d-7e4f-9a01-2b3c4d5e6f70",
"puzzleId": "0193a5b2-6a0b-7c3d-8e9f-0a1b2c3d4e5f",
"versionId": "0193a5b2-8d2e-7f50-ab12-3c4d5e6f7081",
"visibility": "public",
"accessMode": "both",
"mode": "casual",
"reason": "unpublished",
"unpublishedAt": "2026-10-24T12:00:00.000Z"
}
}publication.archived
A publication was archived.
| Field | Type | Notes |
|---|---|---|
| publicationId | uuid | |
| puzzleId | uuid | |
| versionId | uuid | null | The version the publication points at (completions: the version played). |
| visibility | "public" | "unlisted" | "private" | |
| accessMode | "standalone" | "embed" | "both" | |
| mode | "casual" | "competitive" | |
| wasPublished | boolean | It was published until archived (archiving unpublishes it; no separate unpublished event is sent). |
| archivedAt | date-time |
{
"id": "0193a5b2-c051-7283-de45-6f708192a3b4",
"type": "publication.archived",
"version": "1",
"createdAt": "2026-10-24T12:00:00.000Z",
"workspace": {
"ownerUserId": null,
"ownerOrgId": "0193a5b2-0000-7000-8000-000000000001"
},
"data": {
"publicationId": "0193a5b2-7c1d-7e4f-9a01-2b3c4d5e6f70",
"puzzleId": "0193a5b2-6a0b-7c3d-8e9f-0a1b2c3d4e5f",
"versionId": "0193a5b2-8d2e-7f50-ab12-3c4d5e6f7081",
"visibility": "public",
"accessMode": "both",
"mode": "casual",
"wasPublished": true,
"archivedAt": "2026-10-24T12:00:00.000Z"
}
}completion.created
A player completed a publication, checked against the solution on the server. Anonymous: no player identity. Not sent for school students.
| Field | Type | Notes |
|---|---|---|
| completionId | uuid | |
| publicationId | uuid | |
| puzzleId | uuid | |
| versionId | uuid | null | The version the publication points at (completions: the version played). |
| mode | "casual" | "competitive" | |
| verified | boolean | A competitive completion verified on the server clock. |
| verificationNote | "casual" | "offline" | "too_fast" | "deadline_passed" | "mode_mismatch" | "assisted" | "repeat_attempt" | null | Why it isn't verified; null when it is. |
| elapsedMs | integer ≥ 0 | |
| score | number | null | |
| hintsUsed | integer ≥ 0 | |
| revealsUsed | integer ≥ 0 | |
| assisted | boolean | Hints or reveals were used. |
| completedAt | date-time | When the player finished. For offline uploads this is the device's (checked) time, while the envelope's createdAt is when the server recorded it. |
{
"id": "0193a5b2-c051-7283-de45-6f708192a3b4",
"type": "completion.created",
"version": "1",
"createdAt": "2026-10-24T12:00:00.000Z",
"workspace": {
"ownerUserId": null,
"ownerOrgId": "0193a5b2-0000-7000-8000-000000000001"
},
"data": {
"completionId": "0193a5b2-9e3f-7061-bc23-4d5e6f708192",
"publicationId": "0193a5b2-7c1d-7e4f-9a01-2b3c4d5e6f70",
"puzzleId": "0193a5b2-6a0b-7c3d-8e9f-0a1b2c3d4e5f",
"versionId": "0193a5b2-8d2e-7f50-ab12-3c4d5e6f7081",
"mode": "competitive",
"verified": true,
"verificationNote": null,
"elapsedMs": 312000,
"score": 940,
"hintsUsed": 0,
"revealsUsed": 0,
"assisted": false,
"completedAt": "2026-10-24T12:00:00.000Z"
}
}comment.created
A comment or reply was posted on a publication. Ids only: read the comment through the API.
| Field | Type | Notes |
|---|---|---|
| commentId | uuid | |
| publicationId | uuid | |
| parentCommentId | uuid | null | The comment replied to; null for top-level comments. Read the comment with the API. |
| createdAt | date-time |
{
"id": "0193a5b2-c051-7283-de45-6f708192a3b4",
"type": "comment.created",
"version": "1",
"createdAt": "2026-10-24T12:00:00.000Z",
"workspace": {
"ownerUserId": null,
"ownerOrgId": "0193a5b2-0000-7000-8000-000000000001"
},
"data": {
"commentId": "0193a5b2-af40-7172-cd34-5e6f708192a3",
"publicationId": "0193a5b2-7c1d-7e4f-9a01-2b3c4d5e6f70",
"parentCommentId": null,
"createdAt": "2026-10-24T12:00:00.000Z"
}
}moderation.publication_taken_down
Platform moderation took the publication down: it isn't served until restored.
| Field | Type | Notes |
|---|---|---|
| publicationId | uuid | |
| puzzleId | uuid | |
| versionId | uuid | null | The version the publication points at (completions: the version played). |
| visibility | "public" | "unlisted" | "private" | |
| accessMode | "standalone" | "embed" | "both" | |
| mode | "casual" | "competitive" | |
| status | "unpublished" | "published" | "archived" | The publication's lifecycle status, which moderation doesn't change; after a restore, published means it is playable again. |
| takenDownAt | date-time |
{
"id": "0193a5b2-c051-7283-de45-6f708192a3b4",
"type": "moderation.publication_taken_down",
"version": "1",
"createdAt": "2026-10-24T12:00:00.000Z",
"workspace": {
"ownerUserId": null,
"ownerOrgId": "0193a5b2-0000-7000-8000-000000000001"
},
"data": {
"publicationId": "0193a5b2-7c1d-7e4f-9a01-2b3c4d5e6f70",
"puzzleId": "0193a5b2-6a0b-7c3d-8e9f-0a1b2c3d4e5f",
"versionId": "0193a5b2-8d2e-7f50-ab12-3c4d5e6f7081",
"visibility": "public",
"accessMode": "both",
"mode": "casual",
"status": "published",
"takenDownAt": "2026-10-24T12:00:00.000Z"
}
}moderation.publication_restored
Platform moderation restored a publication it had taken down.
| Field | Type | Notes |
|---|---|---|
| publicationId | uuid | |
| puzzleId | uuid | |
| versionId | uuid | null | The version the publication points at (completions: the version played). |
| visibility | "public" | "unlisted" | "private" | |
| accessMode | "standalone" | "embed" | "both" | |
| mode | "casual" | "competitive" | |
| status | "unpublished" | "published" | "archived" | The publication's lifecycle status, which moderation doesn't change; after a restore, published means it is playable again. |
| restoredAt | date-time |
{
"id": "0193a5b2-c051-7283-de45-6f708192a3b4",
"type": "moderation.publication_restored",
"version": "1",
"createdAt": "2026-10-24T12:00:00.000Z",
"workspace": {
"ownerUserId": null,
"ownerOrgId": "0193a5b2-0000-7000-8000-000000000001"
},
"data": {
"publicationId": "0193a5b2-7c1d-7e4f-9a01-2b3c4d5e6f70",
"puzzleId": "0193a5b2-6a0b-7c3d-8e9f-0a1b2c3d4e5f",
"versionId": "0193a5b2-8d2e-7f50-ab12-3c4d5e6f7081",
"visibility": "public",
"accessMode": "both",
"mode": "casual",
"status": "published",
"restoredAt": "2026-10-24T12:00:00.000Z"
}
}