Skip to content
Developer integrations

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?

Out of Puzzel
Into Puzzel

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

Setting it up
  1. 1 Open the activity in the builder and go to the Developer menu.
  2. 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. 3 Play the activity once yourself. The first POST lands as soon as you answer something.
A receiver, end to end
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);
});
It fires as you play, not once at the end

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.

Telling a finish from a save

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.

It is sent by the player's browser

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.

Treat the payload as a claim, not a proof

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.

index.html
<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>
Check where the message came from

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.

The same payload, the same timing

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.

index.html
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);
});
What arrives
{
  "completed": true,
  "activityKey": "-Nq8sample_activity_key"
}
The result is already saved when it arrives

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.

Only inside an iframe

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.

FieldTypeWhat it does
activityKey
always
string
stringThe activity the result belongs to. The same key you see in the activity's own URL, after ?p=.
playerUid
always
string
stringWho 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
objectThe 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
numberHow far through, as a percentage. 100 means finished.
timePassed
always
number
numberTime on the activity, in milliseconds.
lastPlayedAt
always
number
numberWhen this result was saved, as a Unix timestamp in milliseconds.
createdAt
sometimes
number
numberWhen the attempt was started, as a Unix timestamp in milliseconds.
playerInput
sometimes
object
objectWhat 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
objectWhich of those items are right, keyed the same way. Absent while nothing has been answered yet.
score
sometimes
number
numberPoints 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
numberA type's own measure of how well it went, where it keeps one — words per minute in typing practice, for instance.
attempts
sometimes
number
numberWhich 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
booleanThe run ended on a wrong answer and is over without being complete.
hasAlternateCompletion
sometimes
boolean
booleanThe 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
arrayCharacters the player kept getting wrong, most-missed first. Typing practice only.
contentVersion
sometimes
number
numberWhich 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.
A finished quiz, as it arrives
{
  "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
}
Absent, not empty

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.

reset
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');
Reset is the only trigger

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.

Only the embedding page is heard

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.

Post us the player

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.

Post us a token

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.

index.html
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();
});
Ask us to switch it on

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 identity

Creating 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 reference

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

Not one of those?

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