Wire Puzzel into your own platform
Puzzel can hand a player's result straight to another system — their score, how far they got, what they answered — without that system being a full LMS. This page is every channel there is: what goes out, when it goes out, and the smallest thing you have to build to catch it.
- Results go out via
- A webhook, or a message to the page around the activity
- Your page can
- Sign the player in and reset the activity
- Switched on
- Per activity, under Developer in the builder
- Included with
- A paid plan — these settings are off on a free account
Which channel do you need?
Three things can leave an activity and one can come in. Which one fits depends on a single question: does the player sit inside your page, or somewhere else entirely?
Every saved result is POSTed to a URL you own, as JSON.
- Use it when
- The player could be anywhere — a shared link, a QR code, someone else's site — and you want the result in your own database.
- You need
- An HTTPS endpoint that accepts a cross-origin POST.
save_puzzle_results_via_webhookThe same JSON, posted to the page that embeds the activity instead of to a server.
- Use it when
- You embed the activity in your own course page and the page itself can do something with the result.
- You need
- An iframe on your page and a message listener. No server, no CORS.
save_results_iframe_postmessageOne message when the player finishes, carrying nothing but that fact.
- Use it when
- All you want to know is whether they are done — to tick the lesson off, unlock the next one, or show your own screen.
- You need
- An iframe on your page and a message listener.
send_completion_signal_when_embeddedResults webhook
Switch on "Send results to a webhook" in the builder and give it a URL. From then on, every time the player's progress is saved, their browser POSTs the whole result to that URL as JSON.
- 1 Open the activity in the builder and go to the Developer menu.
- 2 Switch on "Send results to a webhook" and paste your endpoint into the field below it. It has to be a full URL — a bare domain is refused — and it has to be https, because the browser blocks a plain-http call made from a page served over https.
- 3 Play the activity once yourself. The first POST lands as soon as you answer something.
import express from 'express';
const app = express();
// The POST is made by the player's browser, so the browser asks permission
// first. Answer the preflight and the real request can land.
app.use((req, res, next) => {
res.set('Access-Control-Allow-Origin', 'https://puzzel.org');
res.set('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
app.post('/puzzel/results', express.json(), async (req, res) => {
const result = req.body;
// One run posts several times as it goes. Key on the pair, not on an
// insert: each call carries the whole state, so the newest simply wins.
await db.results.upsert(
{ playerUid: result.playerUid, activityKey: result.activityKey },
result
);
// Only act on the finish once.
if (result.progress >= 100 || result.hasAlternateCompletion) {
await markCourseStepComplete(result);
}
res.sendStatus(200);
});A result goes out every time the entry is saved: after a pause in typing, when a card is placed, when the clock stops, and once more when the activity is finished. A long crossword is a couple of dozen calls, not one — so write your handler as an upsert keyed on playerUid and activityKey rather than as an insert. Each call carries the complete state, so the newest one always supersedes the last and a call that goes missing is made good by the next.
progress is a percentage: 100 means the activity is complete. A few types can end without reaching it — a quiz answered all the way through, a solved solution field — and those carry hasAlternateCompletion instead. Treat either as finished.
The POST comes from the tab the activity is playing in, not from a Puzzel server. Most endpoints built to receive webhooks already accept it. If yours never sees a request, this is why: the browser asks permission first, so answer the OPTIONS preflight with an Access-Control-Allow-Origin header and the real POST follows.
There is no signature on the request, and it comes from a browser you do not control, so anyone who looks at the page can send you one too. That is fine for filling in a progress bar or a dashboard. For anything you would not let a student set themselves — a grade that counts, a certificate, a payment — check it against the results in your own Puzzel dashboard, or let the LMS grade connectors carry the score instead.
Results to the page around it
If you embed the activity, you can have that same JSON posted to your own page instead of to a server. Switch on "Send results to the parent page" and listen for the message. Nothing leaves the browser, so there is no endpoint to build and no CORS to think about.
<iframe
src="https://puzzel.org/en/quiz/embed?p=-Nq8sample_activity_key"
width="100%"
height="700"
frameborder="0"
allowfullscreen
></iframe>
<script>
window.addEventListener('message', (event) => {
if (event.origin !== 'https://puzzel.org') return;
const result = event.data;
if (!result || !result.activityKey) return;
// Same shape the webhook posts, same advice: it arrives repeatedly.
saveProgress(result);
});
</script>Your listener hears every message posted to the page, including from other frames and browser extensions. Compare event.origin against https://puzzel.org before you trust what is in it.
This is the webhook's twin: the same fields, sent at the same moments. Everything under "What a result contains" applies here too.
Completion signal
The smallest channel, for when the result itself is none of your business: switch on "Post a completion signal" and your page gets one message the moment the player finishes.
window.addEventListener('message', (event) => {
if (event.origin !== 'https://puzzel.org') return;
if (event.data?.completed !== true) return;
// { completed: true, activityKey: '-Nq8sample_activity_key' }
unlockNextLesson(event.data.activityKey);
});{
"completed": true,
"activityKey": "-Nq8sample_activity_key"
}The signal is deliberately sent after the completing save has landed, so a page that reacts by reading the result back will find it there.
Both message channels post to the page that frames the activity. Opened on its own tab there is nobody to tell, so nothing is sent.
What a result contains
One shape, whichever channel carries it. The player's answers are keyed by the activity's own item ids, so the same keys turn up in correctUids.
| Field | Type | What it does |
|---|---|---|
activityKey always string | string | The activity the result belongs to. The same key you see in the activity's own URL, after ?p=. |
playerUid always string | string | Who played, as an anonymous id. Stable for this player on this device, so it is what you key results on — it is not an email address and not a Puzzel account. |
player sometimes object | object | The registration fields the activity asks for, as you configured them: name, email, class, student_id and so on. Absent until the player has registered, and absent entirely on an activity that asks for nothing. |
progress always number | number | How far through, as a percentage. 100 means finished. |
timePassed always number | number | Time on the activity, in milliseconds. |
lastPlayedAt always number | number | When this result was saved, as a Unix timestamp in milliseconds. |
createdAt sometimes number | number | When the attempt was started, as a Unix timestamp in milliseconds. |
playerInput sometimes object | object | What the player actually entered, keyed by the id of the item it belongs to. The shape inside depends on the activity type — a word, a list of placed cards, a chosen option. |
correctUids sometimes object | object | Which of those items are right, keyed the same way. Absent while nothing has been answered yet. |
score sometimes number | number | Points scored, on the types that score a run. Absent everywhere else — including on a run that genuinely scored zero, so check the key exists before you read it. |
performance sometimes number | number | A type's own measure of how well it went, where it keeps one — words per minute in typing practice, for instance. |
attempts sometimes number | number | Which run this is: 1 the first time, one more on every start over. Only on types that end a run early and count the retakes. |
knockedOut sometimes boolean | boolean | The run ended on a wrong answer and is over without being complete. |
hasAlternateCompletion sometimes boolean | boolean | The activity was finished in a way that does not reach 100% — a quiz answered all the way through, a solution field solved. Treat it as a completion. |
missedKeys sometimes array | array | Characters the player kept getting wrong, most-missed first. Typing practice only. |
contentVersion sometimes number | number | Which version of the activity's content this was played against. It changes when the owner edits the questions, so an old result can be told apart from a current one. |
{
"activityKey": "-Nq8sample_activity_key",
"playerUid": "kK3r9TzSampleAnonymousUid",
"player": {
"name": "Ava Ortega",
"email": "ava@school.example",
"class": "5B"
},
"progress": 100,
"timePassed": 243120,
"lastPlayedAt": 1758297843120,
"createdAt": 1758297600000,
"playerInput": {
"-Nq8item_one": "Lisbon",
"-Nq8item_two": "1969"
},
"correctUids": {
"-Nq8item_one": true,
"-Nq8item_two": false
},
"score": 800,
"contentVersion": 1757923200000
}A field that does not apply is left out of the JSON rather than sent as null or zero. That is how a type that does not score a run is told apart from a run that scored nothing — so read with a default and never assume a key is there.
Resetting the activity from your page
One instruction travels the other way. With "Accept triggers from the parent page" switched on, the page doing the embedding can clear the player's answers and put the activity back to the start — for a "try again" button of your own, outside the frame.
const frame = document.querySelector('iframe').contentWindow;
// Sent to the frame, from the page that embeds it — no other sender is
// accepted, and the activity has to have the trigger setting switched on.
frame.postMessage({ trigger: { type: 'reset' } }, 'https://puzzel.org');There is no message for submitting a result, opening the finish message, or jumping to a question. A message asking for anything other than a reset is ignored.
The trigger is accepted from the page that frames the activity and from nowhere else — not a sibling frame, not a script on the page. The setting is off by default, so switch it on for the activities you drive.
Bringing your own player identity
If your platform already knows who is playing, the activity does not have to ask them again. Two handshakes exist, both for an activity inside your page, and both switched on by us rather than in the builder — they change who a result belongs to, so they are set up with you rather than from a checkbox.
The activity announces itself with 'app-loaded' and waits. Your page posts the player's details back, and the result is filed under them without the player typing anything or seeing a registration screen.
The same handshake, but your page posts the JWT your identity provider issued instead of the fields themselves. We verify it against the issuers set up for your account before the player is let in, so the identity is proven rather than claimed — this is the one to ask for when the result has to be trustworthy.
window.addEventListener('message', (event) => {
if (event.origin !== 'https://puzzel.org') return;
// The activity says it is ready for a player.
if (event.data === 'app-loaded') {
frame.postMessage(
{
player: { name: 'Ava Ortega', email: 'ava@school.example', class: '5B' },
// true starts a fresh attempt instead of resuming this player's last one
should_reset: false
},
'https://puzzel.org'
);
}
// Signed in; the board is coming.
if (event.data?.success === true) hideYourOwnSpinner();
});Tell us which of the two you want and where the activities will be embedded, and we will set your account up and walk through it with you.
Email us about identityCreating activities from your system
Everything above is about a result coming out. Going the other way — making the activities themselves from content you already have — is the Puzzle API: one POST per activity type, and you get back a key and a URL to embed.
Read the API referenceWhen a ready-made connector is the better answer
If the platform on the other side is a real LMS, you probably do not need any of this. Grades can go back to its gradebook on their own, with nothing for you to host.
Launched from inside the LMS, with the score written back to its gradebook.
Post an activity as an assignment and have the marks come back automatically.
Course platforms, membership sites, intranets and anything you built yourself are exactly what the channels on this page are for. An embed plus the completion signal covers most of it.
What there isn't
So you don't go looking for it:
- No endpoint for reading results back. The API creates activities; results leave through the channels on this page, or through the exports in your dashboard.
- No signature on the webhook. There is nothing to verify the request against, which is why a result should not be the only thing standing behind something that matters.
- No account-wide webhook. The URL is a setting on an activity, so an activity you copy carries it along and a new one starts without it.
- Nothing in a team game or a live room. Both message channels and the webhook are for solo play, and the settings turn themselves off when team play is on.
- No delivery queue. Nothing is stored and resent — the next save is the retry, and the run's last call is the one that matters.
Something not behaving?
Send the request you tried and the error you got back and you'll get a real answer, from the person who wrote the endpoint.
Email support