API and webhook reference
Endpoints, authentication, webhook format and the widget event, for developers.
Public API
The API gives read access to your quizzes and their statistics, from your server. It replies in JSON, at:
https://api.meletia.fr/v1Authentication
Create a key in API and webhooks (up to 10 keys per account). The key is shown only once; Meletia only keeps a fingerprint of it. Send it in the Authorization header of every request. Each key gives access to all of the account's quizzes.
curl https://api.meletia.fr/v1/quizzes \
-H "Authorization: Bearer mk_…"The key is secret: only use it from a server. The API sends no CORS headers, so a browser can't call it from a page.
// Sur votre serveur uniquement : la clé ne doit jamais aller dans le navigateur.
const res = await fetch("https://api.meletia.fr/v1/quizzes/8f3c2a71-0000-4000-8000-000000000000/stats?days=30", {
headers: { Authorization: `Bearer ${process.env.MELETIA_API_KEY}` },
});
const stats = await res.json();
console.log(stats.totals); // { starts, completions, averageScore }GET /v1/quizzes
Your quizzes, drafts included, newest first. publishedAt is null for a draft.
{
"quizzes": [
{
"id": "8f3c2a71-0000-4000-8000-000000000000",
"title": "Capitales du monde",
"description": null,
"locale": "fr",
"distribution": "embed",
"resultsStorage": "hosted",
"publishedAt": "2026-10-20T09:12:00.000Z",
"createdAt": "2026-10-18T16:40:00.000Z",
"updatedAt": "2026-10-20T09:12:00.000Z",
"questionCount": 10
}
]
}GET /v1/quizzes/:id
A quiz with its settings, allowed sites (allowedDomains) and questions in order, solutions included. Each question has a type (mcq, truefalse, text, list, order, match, cloze, poll) and the content specific to its format.
GET /v1/quizzes/:id/stats?days=30
The figures from the Results tab, over 7, 30 or 90 days (UTC, today included): totals, one row per day, and one row per question. Scores are proportions between 0 and 1; null when no game has been finished yet. For a poll, successRate is null and votes gives the votes per choice.
{
"days": 30,
"since": "2026-09-30",
"totals": { "starts": 412, "completions": 358, "averageScore": 0.71 },
"daily": [
{ "day": "2026-09-30", "starts": 12, "completions": 10, "averageScore": 0.68 }
],
"questions": [
{
"id": "dc386adb-…",
"position": 0,
"type": "mcq",
"prompt": "Quelle est la capitale de l'Australie ?",
"answered": 355,
"correct": 201,
"successRate": 0.566,
"votes": null
}
]
}Errors and limits
| Status | error | Cause |
|---|---|---|
| 400 | invalid_input | Invalid parameter (malformed identifier, period other than 7, 30 or 90) |
| 401 | invalid_api_key | Missing, malformed or revoked key |
| 404 | not_found | Quiz doesn't exist or belongs to another account |
| 429 | rate_limited | More than 60 requests per minute with the same key |
Webhooks
For a quiz whose results storage is “Sent to my server (webhook)”, each game, finished or abandoned, is sent as a POST to your account's webhook URL (one per account, using https://), a few seconds after it ends. Meletia only keeps daily totals. Step-by-step guide: Get results.
Headers
Meletia-Event:game.finished.Meletia-Delivery: unique delivery identifier, the same on every retry. Ignore an identifier you have already received.Meletia-Signature:t=<Unix timestamp>,v1=<signature>.
Body
{
"id": "9b2f6c1e-…",
"type": "game.finished",
"createdAt": "2026-10-29T13:15:21.655Z",
"test": false,
"data": {
"quiz": { "id": "8f3c2a71-0000-4000-8000-000000000000", "title": "Capitales du monde" },
"completed": true,
"points": 7,
"maxPoints": 10,
"answers": [
{
"questionId": "dc386adb-…",
"position": 0,
"type": "text",
"reached": true,
"correct": true,
"points": 1,
"maxPoints": 1,
"answer": { "text": "Paris" }
}
]
}
}test:truefor a test delivery from the dashboard.completed:falseif the player left the page before the end.answers: one entry per question drawn.reachedisfalsefor a question not reached (time ran out, out of lives).answeris the player's answer in the question's format, ornull.- No player identifier or IP address.
Verify the signature
Compute a hexadecimal HMAC SHA-256 of t.body (the timestamp, a dot, then the raw request body, before any parsing) with the secret shown in API and webhooks. Compare it to v1 in constant time, and reject deliveries older than 5 minutes.
// app/api/meletia-webhook/route.ts
import crypto from "node:crypto";
export async function POST(request: Request) {
const body = await request.text();
const header = request.headers.get("meletia-signature") ?? "";
const t = header.match(/t=(\d+)/)?.[1];
const v1 = header.match(/v1=([0-9a-f]{64})/)?.[1];
if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > 300) {
return new Response("Invalid signature", { status: 401 });
}
const expected = crypto
.createHmac("sha256", process.env.MELETIA_WEBHOOK_SECRET!)
.update(`${t}.${body}`)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) {
return new Response("Invalid signature", { status: 401 });
}
const event = JSON.parse(body);
if (!event.test) {
// Enregistrez ici event.data dans votre base (en ignorant un event.id déjà reçu).
await saveGame(event.id, event.data);
}
return new Response("ok");
}Expected response and retries
Reply with a 2xx status within 5 seconds. With no response, or a 5xx or 429 status, Meletia retries twice, after 2 then 8 seconds. Redirects aren't followed. Games beyond the plan's monthly quota aren't sent.
Widget event
When the embed.js script is on the page, it fires the meletia:complete event on the iframe at the end of each game; it bubbles up to window. event.detail contains quizId, quizTitle, points, maxPoints, percent (rounded) and reason (done, time or lives). The score is computed in the player's browser: it can be changed, so use the webhook for an authoritative result.
<script>
window.addEventListener("meletia:complete", function (event) {
var result = event.detail;
// result.quizId, result.quizTitle, result.points, result.maxPoints,
// result.percent, result.reason ("done", "time" ou "lives")
console.log("Quiz terminé :", result.percent + " %");
});
</script>