Developers

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:

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.

Register endpoints in Studio

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
  1. Read t and every v1 from the header.
  2. Compute the hex HMAC-SHA256 of t, a dot and the raw request body, with the whole secret (including whsec_) as the key.
  3. Compare it with each v1 value in constant time.
  4. Refuse timestamps more than 300 seconds away (replays).
  5. Deduplicate on the event id before 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 "", 200

Verify 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

Volume caps and overload

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

The data fields of publication.published
FieldTypeNotes
publicationIduuid
puzzleIduuid
versionIduuid | nullThe version the publication points at (completions: the version played).
visibility"public" | "unlisted" | "private"
accessMode"standalone" | "embed" | "both"
mode"casual" | "competitive"
publishedAtdate-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.

The data fields of publication.unpublished
FieldTypeNotes
publicationIduuid
puzzleIduuid
versionIduuid | nullThe 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.
unpublishedAtdate-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.

The data fields of publication.archived
FieldTypeNotes
publicationIduuid
puzzleIduuid
versionIduuid | nullThe version the publication points at (completions: the version played).
visibility"public" | "unlisted" | "private"
accessMode"standalone" | "embed" | "both"
mode"casual" | "competitive"
wasPublishedbooleanIt was published until archived (archiving unpublishes it; no separate unpublished event is sent).
archivedAtdate-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.

The data fields of completion.created
FieldTypeNotes
completionIduuid
publicationIduuid
puzzleIduuid
versionIduuid | nullThe version the publication points at (completions: the version played).
mode"casual" | "competitive"
verifiedbooleanA competitive completion verified on the server clock.
verificationNote"casual" | "offline" | "too_fast" | "deadline_passed" | "mode_mismatch" | "assisted" | "repeat_attempt" | nullWhy it isn't verified; null when it is.
elapsedMsinteger ≥ 0
scorenumber | null
hintsUsedinteger ≥ 0
revealsUsedinteger ≥ 0
assistedbooleanHints or reveals were used.
completedAtdate-timeWhen 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.

The data fields of comment.created
FieldTypeNotes
commentIduuid
publicationIduuid
parentCommentIduuid | nullThe comment replied to; null for top-level comments. Read the comment with the API.
createdAtdate-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.

The data fields of moderation.publication_taken_down
FieldTypeNotes
publicationIduuid
puzzleIduuid
versionIduuid | nullThe 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.
takenDownAtdate-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.

The data fields of moderation.publication_restored
FieldTypeNotes
publicationIduuid
puzzleIduuid
versionIduuid | nullThe 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.
restoredAtdate-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"
  }
}