Quiz MasterDeveloper

Quiz Master API Reference

Base URL https://quizmaster.clickerapps.com/api/v1 Contract version 2026-08-24 · Machine-readable GET /api/v1/openapi.json

Build quizzes, read marks, and keep another system in step with Quiz Master — with an API key and the published reference, and no access to our database, our source, or any internal service.


Contents

  1. Quick start
  2. Authentication
  3. Scopes
  4. Conventions — envelopes · pagination · time · numbers
  5. Errors
  6. Rate limits
  7. Idempotency
  8. Resources
  9. Webhooks
  10. Staying in step — webhooks vs the change feed
  11. Workflows
  12. Versioning and deprecation
  13. Changelog

1. Quick start

Three calls: prove the key works, list what is there, read a mark.

export QM=https://quizmaster.clickerapps.com/api/v1
export QM_KEY=qmk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# 1. Who am I, and what may this key do?
curl -s "$QM/ping" -H "X-API-Key: $QM_KEY"

# 2. What quizzes exist?
curl -s "$QM/quizzes" -H "X-API-Key: $QM_KEY"

# 3. Who sat quiz 42, and what did they get?
curl -s "$QM/quizzes/42/attempts?limit=10" -H "X-API-Key: $QM_KEY"

/ping answers with the account the key belongs to — which is how somebody who pasted the wrong key finds out immediately rather than three screens later:

{
  "success": true,
  "data": {
    "organizationId": 7,
    "organizationName": "Adom Tutorials",
    "timezone": "Africa/Accra",
    "keyPrefix": "qmk_73c1a7c6",
    "keyName": "Reporting sync",
    "permissions": { "quizRead": true, "quizWrite": true, "quizApprove": false },
    "plan": "pro",
    "scopes": ["attempts.read", "quizzes.read", "results.read"],
    "unrestricted": false
  }
}

A tiny TypeScript client, which is all most integrations need:

export class QuizMaster {
  constructor(
    private readonly key: string,
    private readonly base = 'https://quizmaster.clickerapps.com/api/v1'
  ) {}

  async request<T>(method: string, path: string, body?: unknown, idempotencyKey?: string): Promise<T> {
    const res = await fetch(`${this.base}${path}`, {
      method,
      headers: {
        'X-API-Key': this.key,
        'Content-Type': 'application/json',
        Accept: 'application/json',
        ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {})
      },
      body: body === undefined ? undefined : JSON.stringify(body)
    });

    const text = await res.text();
    let parsed: any = null;
    try { parsed = text ? JSON.parse(text) : null; } catch { /* not JSON */ }

    // Branch on `code`, never on the sentence in `error` — the prose may be reworded,
    // the code will not be.
    if (!res.ok) {
      throw Object.assign(new Error(parsed?.error ?? `HTTP ${res.status}`), {
        status: res.status,
        code: parsed?.code,
        requiredScopes: parsed?.requiredScopes
      });
    }
    return parsed as T;
  }

  /** Walk a cursor-paged list to the end. */
  async *paged<T>(path: string, limit = 100): AsyncGenerator<T> {
    let cursor: string | null = null;
    for (;;) {
      const sep = path.includes('?') ? '&' : '?';
      const page: { data: T[]; pagination: { hasMore: boolean; nextCursor: string | null } } =
        await this.request('GET', `${path}${sep}limit=${limit}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
      for (const row of page.data) yield row;
      if (!page.pagination.hasMore || !page.pagination.nextCursor) return;
      cursor = page.pagination.nextCursor;
    }
  }
}

2. Authentication

Every request carries one header:

X-API-Key: qmk_<40 hex characters>

Issue a key in Quiz Master under Integrations → API keys. Name it after the system that will hold it, and give it only the scopes that system needs.

What you must know about a key

  • The plaintext is shown exactly once, at creation. Only its SHA-256 is stored, so it cannot be re-shown. A lost key is replaced, not recovered.
  • The organization comes from the key row, never from a header. There is no X-Org-Id to send and sending one changes nothing. A key reaches exactly one account, by construction.
  • A key acts as a named service user. Its reach is the intersection of the key's scopes and that user's role. A key holding quizzes.write whose service user may not author quizzes is refused — and so is an administrator's key narrowed to attempts.read trying to publish.
  • Absent, malformed, unknown, revoked and expired all answer the same 401. That is deliberate: a distinct message for "no such key" turns the endpoint into an oracle for which prefixes are real.
{ "error": "A valid X-API-Key is required", "code": "api_key_invalid" }
  • Revocation is terminal. A revoked key is never revived and its hash is never reissued, so the request log can never be ambiguous about which credential made a historical call.
  • An account may hold ten live keys. Revoke one before issuing an eleventh.

Rotating

Issue the new key, deploy it, confirm traffic on the new prefix in Integrations → API keys (each row shows its last-used time and request count), then revoke the old one. There is no window in which both stop working.


3. Scopes

A scope narrows what a key may reach. It is written with a dot — quizzes.read — so it can never be confused with an internal role permission, which uses a colon.

Omitting scopes at issue time means the whole surface. That is the default for compatibility with keys issued before scopes existed. An empty list means the key may do nothing at all — the two are opposite answers and are never conflated.

ScopeWhat it opens
organization.readThe account this key belongs to, its timezone, and the plan it is on.
quizzes.readList quizzes and read a quiz, its settings, schedule and registration form.
quizzes.writeCreate and update quizzes, and publish or unpublish them.
questions.readRead the questions, options and pages of a quiz version. Includes answer keys.
questions.writeAdd, change and remove questions on a quiz draft.
question-bank.readRead the reusable question bank.
question-bank.writeAdd and change question-bank entries.
learners.readRead learners, learner fields and group membership.
learners.writeCreate learner fields and run a roster sync.
attempts.readList attempts and read one, including the change feed.
results.readRead a participant's answers, per-question marks and category breakdown.
certificates.readList issued certificates and verify one by number.
courses.readRead courses and the quizzes that make up their syllabus.
invitations.readRead the invitations sent for a quiz and their delivery state.
invitations.writeInvite participants to a quiz.
webhooks.readRead webhook endpoints and their recent delivery attempts.
webhooks.writeCreate, change, disable and delete webhook endpoints.
sso.writeRead and rotate the single sign-on shared secret.

Wildcards. attempts.* grants every scope in that family; a bare * grants everything, present and future. Both are accepted at issue time.

questions.read is separate from quizzes.read on purpose. quizzes.read answers what quizzes exist and when do they open; questions.read answers which option is correct. An integration that only fills a dropdown should not hold every mark scheme in the account.

A refused scope names what it wanted:

{
  "error": "This API key does not hold the certificates.read scope.",
  "code": "insufficient_scope",
  "requiredScopes": ["certificates.read"]
}

GET /ping is the only endpoint that requires no scope. A key nobody can ping with is a key nobody can diagnose.


4. Conventions

One envelope

A single resource:

{ "success": true, "data": { "…": "…" } }

A list — data is always an array at the top level, with pagination as a sibling so a client that ignores it still works:

{
  "success": true,
  "data": [ { "…": "…" } ],
  "pagination": { "limit": 50, "hasMore": true, "nextCursor": "5258" }
}

Pagination

Cursor-based, newest first. Pass limit (1–200, default 50) and cursor (pagination.nextCursor from the previous page).

hasMore is evidence, not a guess. We fetch one row beyond your limit and hand you the limit — so a page that ends exactly on the boundary still reports hasMore: true. A synchronising client can trust it.

A malformed cursor is refused (400 invalid_cursor), never silently treated as "from the beginning". A typo that restarted your sweep at the top would replay an account's entire history into your staging table.

Time comes in two kinds, and they are not interchangeable

KindFieldsFormat
Instant — an absolute momentstartedAt, submittedAt, issuedAt, sentAt, expiresAt (attempt/certificate), cursorsISO-8601 UTC: 2026-08-24T18:29:03.000Z
Wall clock — what a person typedavailableFrom, availableTo, scheduledAt2026-09-01 09:00:00, no zone

A wall clock is judged against the organization's own timezone — which /ping tells you. Converting it as if it were UTC moves the hour a quiz opens or closes, and nothing anywhere will report that.

Numbers are numbers

Every score, point value and percentage is a JSON number or null. You never receive "1.0000" as a string.

null is not zero. While an attempt is pending_manual, percentage, passed and pointsAwarded are null — deliberately. Branch on gradingStatus:

// 🔴 WRONG — writes a fail onto a report card for an unmarked essay.
const score = Number(attempt.percentage) || 0;

// ✅ Right.
const score = attempt.gradingStatus === 'graded' ? attempt.percentage : null;

Identity across versions

Editing a published quiz clones a draft, and the draft's questions and pages get new ids. If you store a reference to a question, store its lineageId — the identity that survives the clone. id is correct for a single call; lineageId is correct for a record you keep.


5. Errors

Every refusal has the same shape:

{ "error": "A sentence for a human reading a log.", "code": "stable_code" }

Branch on code. The prose in error may be reworded; the code will not be.

StatusMeaning
400The request is malformed — see code.
401api_key_invalid — absent, malformed, unknown, revoked or expired.
402quota_exceeded (the plan's allowance) or subscription_required.
403insufficient_scope, or the key's service user lacks the permission.
404not_found — also what another tenant's record looks like, deliberately: a 403 would confirm it exists. Also unknown_endpoint.
409An idempotency conflict — see §7.
422Understood but refused — validation, invalid_media, invalid_equation, too_many_webhooks.
429Rate limited. Honour Retry-After.
500internal_error. Retryable.
503api_key_unavailable — we could not verify credentials. Retry with backoff.

Codes worth handling by name

CodeWhereWhat to do
insufficient_scopeanywhereRead requiredScopes and issue a key that holds them.
invalid_cursorany listYour cursor is corrupt. Restart the sweep from a known-good position.
invalid_filterattempt listsThe message lists the values that work.
validationpublishRead problems — an array of sentences naming each question by position. Render them.
not_published / no_draftquestionsAsk for the other version.
use_identifier_setupPATCH /quizzes/{id}Use the dedicated verb; it also reports whether it created a draft.
quota_exceededcreatesThe response carries limit, used and upgrade.
unknown_endpointany pathThe path does not exist. This is a 404, not a 401 — if you are getting 401 for a path you believe exists, the key is the problem, not the route.

6. Rate limits

60 requests per minute and 2,000 per hour, per key. Exceeding either answers 429 with a Retry-After header in seconds.

The limit is keyed on the verified key, not on your IP, so several servers sharing one key share one budget — and one key's traffic never affects another's.

The last 1,000 requests per key are kept and visible in Quiz Master under Integrations → API keys: method, route, status and duration. Query strings and bodies are never logged.


7. Idempotency

Send an Idempotency-Key header on any write and a replay returns the stored response instead of acting a second time.

curl -s -X POST "$QM/quizzes" \
  -H "X-API-Key: $QM_KEY" -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 9d3f1a2c-1f4e-4b6a-9c0e-2b1d4e5f6a7b' \
  -d '{"title":"Term 2 Assessment"}'
  • The key must be 8–36 characters of [A-Za-z0-9-], starting alphanumeric. A UUID is ideal.
  • Reusing a key with a different body is refused 409 KEY_REUSED. That is a bug in your retry logic, and being told is better than being obeyed.
  • A replay within a few milliseconds of the original may answer 409 IN_FLIGHT. The outcome is recorded just after the response is flushed, so a very fast retry can land in that window. It never double-writes — wait a moment and retry.
  • A 5xx is deliberately left in flight and replays as 409. A server that died after committing cannot be told apart from one that rolled back, and guessing is worse than asking a human.

8. Resources

Full parameter and response detail is in GET /api/v1/openapi.json. What follows is what a person needs to know before they call each one.

Organization

GET /ping

No scope. The handshake — call it at setup. Returns the account, its timezone, the plan, and the key's scopes (null = unrestricted).

Quizzes

GET /quizzes · quizzes.read

Up to 200, newest first.

POST /quizzes · quizzes.write

Creates a draft. The public slug is minted server-side; a client-supplied one is ignored, because slugs are globally unique and a guessable one would let anybody enumerate other accounts' quizzes.

curl -s -X POST "$QM/quizzes" -H "X-API-Key: $QM_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"title":"Geography — Term 2","description":"Capitals and rivers."}'
{ "success": true, "data": { "id": 1058, "status": "draft" } }

GET /quizzes/{id} · quizzes.read

Everything needed to decide whether a quiz is ready to be linked or launched.

{
  "success": true,
  "data": {
    "id": 42, "title": "Geography — Term 2", "status": "published",
    "publicSlug": "geography-term-2-cudyv2a", "published": true,
    "learnerIdentifierFieldKey": "student_number", "accessCodeRequired": false,
    "collectFields": [ { "key": "student_number", "label": "Student number", "type": "text", "required": true } ],
    "hasDraft": true,
    "draftCollectFields": [ { "key": "student_number", "label": "Student number", "type": "text", "required": true } ],
    "questionCount": 12, "totalPoints": 24,
    "maxAttempts": 2, "timeLimitSeconds": 1800,
    "availableFrom": "2026-09-01 09:00:00", "availableTo": "2026-09-30 17:00:00"
  }
}

PATCH /quizzes/{id} · quizzes.write

PATCH semantics: an absent key leaves the column alone; an explicit null clears it. Accepts title, description, folderId, accessCodeRequired, reminderDays (1–365, null switches reminders off), reminderMaxCount (0–10).

learnerIdentifierFieldKey is refused here by name — use identifier-setup, which also tells you whether it had to create a draft.

POST /quizzes/{id}/publish · quizzes.write

Runs the same readiness check the Quiz Master screen runs.

{
  "error": "This quiz is not ready to publish",
  "code": "validation",
  "problems": [
    "Question 3 needs at least two pairs.",
    "Question 7 has no {{blank}} placeholder in its text."
  ]
}

Render problems. They name each question by the position a person sees, and they are the entire value of the refusal — "publish failed" is not actionable.

POST /quizzes/{id}/unpublish · quizzes.write

Questions

GET /quizzes/{id}/questions?version=live|draft · questions.read

Returns pages, questions, options and answer keys. Default live; ask for draft to see unpublished edits.

  • not_published — the quiz has never been published; ask for the draft.
  • no_draft — there are no unpublished edits.

POST /quizzes/{id}/questions · questions.write

curl -s -X POST "$QM/quizzes/1058/questions" -H "X-API-Key: $QM_KEY" \
  -H 'Content-Type: application/json' -d '{
    "questionType": "multiple_choice",
    "questionText": "What is the capital of Ghana?",
    "points": 2,
    "isRequired": true,
    "options": [
      { "label": "Accra",  "isCorrect": true,  "sortOrder": 1 },
      { "label": "Kumasi", "isCorrect": false, "sortOrder": 2 },
      { "label": "Tamale", "isCorrect": false, "sortOrder": 3 }
    ]
  }'
{ "success": true, "data": { "id": 29612, "draftPending": false } }

draftPending: true means the live quiz has not changed. Your write landed on a draft; publish again to make it real. Participants sitting the quiz right now are still on the old version.

gradingMode is derived from the question type when you do not send one. A free_text question becomes manual — do not set it to auto to "keep things simple"; an auto free-text question cannot be un-graded once marked.

Refusals name what was wrong:

  • 400 unknown_question_type — names the value it refused.
  • 422 invalid_equation — names the field and what the TeX parser objected to.
  • 422 invalid_media — names the media kind or address it refused.
  • 422 proc_1021 — a scored choice question with no correct option.

PATCH /questions/{id} · questions.write

Answers draftPending.

DELETE /questions/{id} · questions.write

Answers draftPending too — the removal lands on the draft, not on the live quiz.

Attempts

GET /quizzes/{id}/attempts · attempts.read

Cursor-paged. Filters: status (started, submitted, abandoned, expired) and gradingStatus (auto, graded, pending_manual). An unknown value is refused, and the message lists the ones that work.

{
  "success": true,
  "data": [{
    "id": 5355, "publicId": "at_bb83f87e…",
    "participantName": "Ama Serwaa", "participantEmail": "ama@example.com",
    "status": "submitted", "gradingStatus": "graded", "versionNumber": 3,
    "startedAt": "2026-08-24T18:11:02.000Z", "submittedAt": "2026-08-24T18:29:03.000Z",
    "durationSeconds": 1081,
    "awardedPoints": 3, "totalPoints": 3, "percentage": 100,
    "passingScore": 50, "passed": true,
    "correctCount": 2, "questionCount": 2,
    "integritySignalCount": 0, "integrityForfeitedAt": null
  }],
  "pagination": { "limit": 50, "hasMore": false, "nextCursor": null }
}

GET /attempts/{id} · attempts.read

GET /attempts/{id}/responses · results.read

What they answered and what it scored. Option picks are resolved to labels — you never have to look up an option id.

autoPointsAwarded is non-null when a person replaced the engine's mark, and holds what the engine had said. That is how you tell a corrected answer from an uncorrected one.

GET /attempts/changes?since=<cursor> · attempts.read

See §10. This is the reliable road.

Certificates

GET /certificates · certificates.read

Cursor-paged, newest first. Revoked certificates are included, carrying revokedAt — a withdrawn award must not look identical to one that never existed.

GET /certificates/{number} · certificates.read

One certificate by the number printed on it. pdfUrl may be null: issuance writes the record and a background job draws the document, so fetch it later rather than assuming the response carries it.

Courses

GET /courses · courses.read

quizCount excludes deleted quizzes — a removed quiz is not a module.

GET /courses/{id} · courses.read

The syllabus, in order. requirePassToAdvance is the course's gate: when true, a learner must pass each module before the next unlocks.

Learners

The sync is a run: begin → send chunks → reconcile.

GET /learners · learners.read

Filter with search (name or email) and status (active or suspended).

GET /learner-fields · learners.read

The account's learner properties, including which one identifies a learner.

POST /learner-fields · learners.write

Create a property. Set isIdentifier here, before any roster has landed.

POST /learners/sync/begin · learners.write

Opens a run and returns the runKey every chunk quotes.

POST /learners/sync · learners.write

One chunk, at most 500 rows. Matches on (fieldKey, valueHash) first and email second. Skips are named in the response, never silent.

POST /learners/sync/reconcile · learners.write

Closes the run and returns its totals.

GET /learners/sync/{runKey} · learners.read

Read a run back — what was seen, created, updated, and when it finished.

POST /learners/resolve · learners.read

Turn your own identifiers into Quiz Master learner ids. A value we do not hold comes back with learnerId: null rather than being omitted.

POST /quizzes/{id}/identifier-setup · quizzes.write

Make a quiz recognise learners by an identifier.

Invitations

GET /quizzes/{id}/invitations · invitations.read

Recipients and delivery state. The invitation token is never disclosed — it is the whole credential for sitting that quiz as that person.

POST /quizzes/{id}/invitations · invitations.write

Invite people to sit a quiz. Address them directly with recipients, by cohort with groupIds, or both — a directly named recipient wins on a clash.

channel chooses how each invitation is delivered:

channelWhat goes out
email (default)The invitation email, and a text carrying the same link when a phone number is known — one you supplied, or the one on the recipient's roster record.
smsA text only.
bothThe same as email. Kept for callers that already send it.

Up to 500 recipients per request. This road does not schedule: it sends when you call it. Supply expiresAt (wall-clock, in your organization's timezone) and an optional message of up to 600 characters, which appears in the email only — an SMS carries the link and nothing else.

Your organization's own SMS sender is configured under Administration → Organisation → Organizations (/admin/organizations), on the "Send quiz text messages from your own sender" card — directly below the matching card for your own mail server. Without one, texts go out from the deployment's sender.

Question bank

GET /question-bank · question-bank.read

Filter with category (exact) and search (matches question text).

Single sign-on

Reading a shared secret is as powerful as rotating it, so both verbs sit behind the same write-class scope.

GET /sso-secret · sso.write

POST /sso-secret · sso.write

Rotates it. ⚠️ Breaks every launch already in flight.


9. Webhooks

Register an endpoint and Quiz Master will POST events to it as they happen.

Events

EventFires whenRequires scope
attempt.submittedA participant hands in an attempt. Marks may not be final — check gradingStatus.attempts.read
attempt.gradedAn attempt reaches its final mark, automatically or after a human marks it.attempts.read
attempt.grade_retractedA marker takes an attempt back to unmarked. Whatever you hold for it is no longer final.attempts.read
certificate.issuedA certificate is issued, automatically or by hand.certificates.read
quiz.publishedA quiz goes live, or a new version of it does.quizzes.read

Registering

curl -s -X POST "$QM/webhooks" -H "X-API-Key: $QM_KEY" \
  -H 'Content-Type: application/json' -d '{
    "url": "https://hooks.yourapp.com/quizmaster",
    "description": "Results into the SIS",
    "events": ["attempt.graded", "certificate.issued"]
  }'
{
  "success": true,
  "data": {
    "id": 4, "url": "https://hooks.yourapp.com/quizmaster",
    "events": ["attempt.graded", "certificate.issued"], "isActive": true,
    "secret": "whsec_2f8c…", "shownOnce": true,
    "notice": "Copy this signing secret now — it is not shown again. …"
  }
}

The address must be https://, must not carry a username or password, and must resolve to a public address. The payload carries participant names and marks; the signature proves who sent it but does not hide it.

The signing secret is returned once. Store it where your receiver can read it.

The request we send

POST /quizmaster HTTP/1.1
Content-Type: application/json
User-Agent: QuizMaster-Webhooks/1
X-QuizMaster-Event: attempt.graded
X-QuizMaster-Delivery: evt_9f2b41c7a8de5031
X-QuizMaster-Timestamp: 1756057743
X-QuizMaster-Signature: sha256=6a1f…
{
  "id": "evt_9f2b41c7a8de5031",
  "type": "attempt.graded",
  "organizationId": 7,
  "sentAt": "2026-08-24T18:29:03.000Z",
  "data": {
    "attempt": {
      "id": 5355, "publicId": "at_bb83f87e…",
      "status": "submitted", "gradingStatus": "graded",
      "participantName": "Ama Serwaa", "participantEmail": "ama@example.com",
      "startedAt": "2026-08-24T18:11:02.000Z", "submittedAt": "2026-08-24T18:29:03.000Z",
      "durationSeconds": 1081,
      "awardedPoints": 3, "pendingPoints": 0, "totalPoints": 3,
      "percentage": 100, "passed": true, "correctCount": 2, "questionCount": 2
    },
    "quiz": { "id": 42, "title": "Geography — Term 2", "publicSlug": "geography-term-2-cudyv2a" },
    "version": { "id": 5262, "versionNumber": 3 }
  }
}

Verifying a delivery

The signature is HMAC-SHA256 over <timestamp>.<raw body> — the timestamp is inside the signed string, so a captured delivery cannot be replayed at you for ever.

import crypto from 'crypto';

export function verify(rawBody: string, headers: Record<string, string>, secret: string): boolean {
  const ts = Number(headers['x-quizmaster-timestamp']);
  const sig = String(headers['x-quizmaster-signature'] || '');

  // Refuse anything older than five minutes — this is what the timestamp is for.
  if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;

  const expected = 'sha256=' + crypto.createHmac('sha256', secret)
    .update(`${ts}.${rawBody}`, 'utf8')
    .digest('hex');

  // Constant time — here it IS a secret comparison.
  const a = Buffer.from(sig), b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Delivery, retries and disabling

  • At least once, never exactly once. A retry carries the same id. Deduplicate on it — it is also in X-QuizMaster-Delivery, so you can reject a replay before parsing.
  • Answer 2xx quickly. Anything else is a failure. We wait 10 seconds.
  • Retries follow a fixed ladder — roughly 30s, 2m, 10m, 30m, then hourly, out to about a day — after which that delivery is abandoned.
  • A redirect is a failure, not a hop. Register the final address.
  • 15 consecutive failures disables the endpoint, with a reason in words. Any success resets the counter. Re-enable with PATCH /webhooks/{id} {"isActive": true} — which also clears the counter, so one more failure does not immediately disable it again.

Seeing what happened

curl -s "$QM/webhooks/4/deliveries?limit=20" -H "X-API-Key: $QM_KEY"
curl -s "$QM/webhooks/4/deliveries/912"     -H "X-API-Key: $QM_KEY"   # with the exact body we sent

httpStatus: null means nothing answered at all — refused, timed out, or refused by us because the address resolved to a private network. That is a different fact from a 500, and errorText says which.

Endpoints

GET /webhook-events · webhooks.read

The vocabulary, the signature scheme and the delivery guarantee, from one place. Render a subscription picker from this rather than hard-coding event names.

GET /webhooks · webhooks.read

Your endpoints and their health. The signing secret is never returned — not the ciphertext, not a prefix, not a length.

POST /webhooks · webhooks.write

Register one. The signing secret is in this response and nowhere else, ever.

PATCH /webhooks/{id} · webhooks.write

Change the address, the description or the events; isActive: true re-enables a disabled endpoint and clears its failure counter, so one more failure does not immediately disable it again.

DELETE /webhooks/{id} · webhooks.write

Removes it. Events already queued for it are abandoned.

GET /webhooks/{id}/deliveries · webhooks.read

Every attempt, so "we never got it" can be answered with evidence.

GET /webhooks/{id}/deliveries/{deliveryId} · webhooks.read

One delivery, with the exact body that was signed and sent — compare against it when debugging a signature mismatch.


10. Staying in step

There are two roads, and they are not equivalent.

GET /attempts/changes (pull)Webhooks (push)
If you are offline for an hourYou ask again with the cursor you had. Nothing is lost.We retry on the ladder, then give up.
OrderingMonotonic on (updatedAt, id).None.
LatencyYour poll interval.Seconds.
EffortYou keep a cursor.You run an HTTPS endpoint and verify signatures.

Sweeping the feed

// Persist this between runs.
let cursor = load() ?? '1970-01-01T00:00:00.000Z,0';

for (;;) {
  const page = await qm.request<{
    data: Array<{ attemptId: number; state: 'finalised' | 'unmarked' | 'withdrawn'; percentage: number | null }>;
    meta: { nextCursor: string; more: boolean; lagSeconds: number };
  }>('GET', `/attempts/changes?since=${encodeURIComponent(cursor)}&limit=500`);

  for (const row of page.data) {
    // `state` is the fact to branch on. `unmarked` is NOT a zero: a pupil who has not
    // been marked must stay unmarked, because absent scores 0 and stays in the
    // denominator while unmarked is excluded from both.
    if (row.state === 'finalised') applyMark(row.attemptId, row.percentage);
    else if (row.state === 'withdrawn') stopWaitingFor(row.attemptId);
  }

  cursor = page.meta.nextCursor;
  save(cursor);
  if (!page.meta.more) break;
}

Three things are required, not one:

  1. The feed excludes the most recent minute (meta.lagSeconds) so a transaction that commits after your read is not stepped over.
  2. You re-scan a trailing window — rewind your saved cursor by a few minutes on each sweep.
  3. You deduplicate on your own unique key, which is what makes the re-scan free.

A long transaction beats any lag window. Only the dedupe makes that safe.


11. Workflows

Build a quiz programmatically

const qm = new QuizMaster(process.env.QM_KEY!);

const { data: quiz } = await qm.request<{ data: { id: number } }>(
  'POST', '/quizzes', { title: 'Geography — Term 2' }
);

for (const q of questions) {
  await qm.request('POST', `/quizzes/${quiz.id}/questions`, q);
}

try {
  await qm.request('POST', `/quizzes/${quiz.id}/publish`);
} catch (err: any) {
  if (err.code === 'validation') {
    // Sentences naming each question by position. Show them; do not summarise them.
    console.error(err.problems ?? []);
  }
  throw err;
}

const { data: live } = await qm.request<{ data: { publicSlug: string } }>('GET', `/quizzes/${quiz.id}`);
console.log(`Send people to https://quizmaster.clickerapps.com/q/${live.publicSlug}`);

Get marks into another system

  1. Register a webhook for attempt.graded.
  2. On each delivery: verify the signature, check data.attempt.gradingStatus === 'graded', upsert on data.attempt.id.
  3. Nightly, sweep GET /attempts/changes from a cursor rewound by ten minutes and upsert the same way. The dedupe makes the overlap free.
  4. Treat attempt.grade_retracted and state: 'withdrawn' as stop treating this as final — never as a zero.

Keep a roster in step

  1. Create an identifier property once: POST /learner-fields with { isIdentifier: true }. Do this before the first sync.
  2. POST /learners/sync/begin → a run key.
  3. POST /learners/sync in chunks of ≤ 500.
  4. POST /learners/sync/reconcile to close the run.
  5. Read GET /learners/sync/{runKey} for per-row outcomes. Skips are named, never silent.

Recognise your own users in a quiz

POST /quizzes/{id}/identifier-setup adds the identifier to the registration form.


12. Versioning and deprecation

  • The path carries the major version: /api/v1.
  • info.version in the OpenAPI document (2026-08-24) is the contract date. It moves when the shape of an existing response changes. It does not move for additions.
  • Additive changes ship without notice: new endpoints, new fields on an existing response, new scopes, new webhook events, new error codes on paths that already failed. Write clients that ignore fields they do not recognise.
  • Breaking changes get a new path version and a deprecation window. Removing a field, renaming one, changing a type, tightening validation, or removing an endpoint are all breaking.
  • A webhook event name is a permanent contract. If one is ever wrong we add the new name and send both for the deprecation window; we never rename in place, because a rename silently stops every existing subscription matching with no error on either side.
  • Scopes are additive. An existing key never loses reach because a new scope was introduced. Keys issued with no scopes keep the whole surface.

13. Changelog

2026-08-24 — the public API

  • Scopes are enforced. quiz_api_keys.scopesJson had existed unread since the key system shipped; every key held the entire surface. Keys issued without scopes are unaffected.
  • New resources: questions (with pages and answer keys), attempts, attempt responses, certificates, courses, learners, question bank, invitations.
  • New writes: create/update quizzes, publish, unpublish, and full question CRUD — running the same validation, payload rules and refusals as the Quiz Master screens.
  • Webhooks: five events, HMAC-SHA256 signatures, a retry ladder, automatic disabling with a reason, and a delivery log.
  • Conventions: one list envelope, cursor pagination with hasMore computed from evidence, one error shape, explicit instant-versus-wall-clock time.
  • Idempotency on every write, via Idempotency-Key.
  • GET /openapi.json, asserted against the router in both directions.

Earlier — the integration surface

/ping, learner fields, the roster sync, quiz link preconditions, identifier-setup, the SSO secret and the attempt change feed. All unchanged: same paths, same shapes, same behaviour.


Maintaining this document

Fails silentlyThis file is the canonical reference. server/scripts/check-security-invariants.ts asserts it lists the same scopes, webhook events and attempt states as the code — and that the OpenAPI document and the router agree in both directions. If any of those drift, the build fails.

The version linked from Administration → Developer Tools → Reference is generated from this file and from the endpoint table in code — its third column carries a worked request, response and Node.js/PHP sample for every operation, and none of those samples is hand-written:

cd server
npm run dump:openapi        # openapi.ts + openapiExamples.ts → docs/artifact/openapi.json
npm run build:api-docs      # this markdown + that JSON → docs/artifact/api-reference.html
npm run check:api-examples  # every generated Node and PHP sample must parse
# then republish the artifact

Fails silentlyWorked examples live in server/src/domains/integration/openapiExamples.ts, one per operation, and the guards require every operation to have one and every example to name an operation that exists. Two languages across 39 operations is 78 samples; generating them is what stops 78 chances to document a field the API does not have.

CautionNever edit the generated HTML. Change this markdown and re-run the script — the same one-source rule client/scripts/build-brand-icons.py carries for the brand mark.


Getting help

  • GET /api/v1/openapi.json — the machine-readable contract, always current.
  • Integrations → API keys in Quiz Master — the last 1,000 requests per key, with status and duration.
  • GET /api/v1/webhooks/{id}/deliveries — every attempt to reach your endpoint, including the exact body we sent.