Skip to content

REST API reference

Quizably REST API under quizably/v1: public quiz and submission routes with rate limits and errors, plus the authenticated admin routes.

Quizably registers its REST API in the quizably/v1 namespace. The public routes serve the front-end player and need no login. The admin routes back the Quiz Builder screens and require a capability. This page was written from the route registrations and controllers in plugin version 1.2.0.

The namespace root depends on the site’s permalink setting:

PermalinksBase URL
Prettyhttps://example.com/wp-json/quizably/v1/
Plainhttps://example.com/?rest_route=/quizably/v1/

With plain permalinks the path goes inside rest_route, and any further query parameters are appended with &:

# Pretty
curl "https://example.com/wp-json/quizably/v1/public/confirm-optin?token=TOKEN"

# Plain
curl "https://example.com/?rest_route=/quizably/v1/public/confirm-optin&token=TOKEN"

Both the front-end player and the admin app build their URLs this way, so either shape works. The player receives its root as QUIZABLY_FRONTEND.apiRoot (see Front-end JavaScript API).

Conventions

  • Request bodies are JSON (Content-Type: application/json). Parameters are also read from the query string.
  • Success responses are JSON. Any string key ending in _at that holds a database timestamp (stored as naive UTC Y-m-d H:i:s) is rewritten to ISO 8601 with a Z suffix, for example 2026-10-01T12:00:00Z.
  • JSON columns (settings, design, logic, answers, utm, result_breakdown, extra_fields) are decoded into objects or arrays in responses. They come back as null when empty.
  • Errors use the WordPress WP_Error shape:
{ "code": "quizably_not_found", "message": "Quiz not found", "data": { "status": 404 } }
CodeStatusMeaning
quizably_forbidden403Missing the quizably_manage_quizzes capability (also returned to logged-out callers).
quizably_not_found404Resource does not exist.
quizably_bad_request400Missing or invalid parameter.
quizably_slug_conflict409Quiz slug already used.
quizably_db_error500A database write failed.
quizably_rate_limited429Public rate limit hit.
quizably_quiz_unavailable410Quiz is not published.
quizably_locked409Answer sent to an already completed submission.
quizably_no_scorer500No scorer for the quiz type.
quizably_bad_email400Opt-in email invalid.
quizably_invalid_token400Confirmation token malformed, signature mismatch, or the lead does not exist or never asked for double opt-in.
quizably_not_a_poll400The poll results route was called for a quiz that is not a poll.
quizably_webhook_error500Test webhook request failed at transport level.
quizably_submission_failed500Submission row could not be created.

Public routes

All routes below are under public/ except ping. They are anonymous: no login, no nonce and no CAPTCHA. Abuse protection is the IP rate limit.

Rate limiting

Every public route except GET /public/confirm-optin and GET /ping runs the rate limiter before the callback. The key is a salted, truncated SHA-256 of REMOTE_ADDR (see Capabilities, permissions and security). Limits:

WindowDefaultFilter
Per minute60 requestsquizably_rate_limit_per_minute
Per hour300 requestsquizably_rate_limit_per_hour

When either is reached the response is HTTP 429, code quizably_rate_limited, message Too many attempts. Please slow down. All public calls from one IP share the counters, including the quiz fetch. A request that is allowed counts even if it later fails. A 10 question quiz makes roughly 13 calls (start, one save per answer, complete, optionally opt-in).

GET /ping

Returns { "ok": true, "version": "1.2.0" }, where version is the plugin version. No auth, no rate limit. Useful to check that the REST API is reachable.

GET /public/quiz/{uuid}

Fetch a published quiz for rendering. {uuid} must match [a-f0-9\-]{36}. Draft and archived quizzes return 404 quizably_not_found.

curl "https://example.com/wp-json/quizably/v1/public/quiz/36dd2f0a-1a2b-4c3d-8e9f-0a1b2c3d4e5f"

The response is the quiz row without author_id, with decoded settings and design, plus two arrays:

{
  "id": 3,
  "uuid": "36dd2f0a-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
  "title": "Which tea are you?",
  "slug": "which-tea-are-you",
  "type": "personality",
  "status": "published",
  "template": "classic",
  "settings": {},
  "design": {},
  "view_count": 120,
  "created_at": "2026-09-30T08:00:00Z",
  "updated_at": "2026-10-01T09:30:00Z",
  "questions": [
    {
      "id": 11, "quiz_id": 3, "type": "single", "title": "Pick a morning drink",
      "required": 1, "position": 1, "settings": {}, "logic": null,
      "answers": [
        { "id": 31, "question_id": 11, "label": "Coffee", "value": "coffee",
          "personality_result_id": 7, "media_url": null, "position": 1 }
      ]
    }
  ],
  "results": [
    { "id": 7, "quiz_id": 3, "title": "Chai", "content": "<p>...</p>",
      "score_min": null, "score_max": null, "settings": {}, "position": 1 }
  ]
}

What is stripped: answers lose is_correct, points and weights; results lose conditions; the quiz loses author_id. What is still exposed: every answer’s personality_result_id, question logic, and all rich text. Do not treat the public payload as secret.

GET /public/quiz/{uuid}/poll-results

The live tally the player shows after someone votes in a poll. Rate limited like the other public routes. {uuid} must be a published quiz, otherwise 404 quizably_not_found. A quiz that is not a poll returns 400 quizably_not_a_poll.

curl "https://example.com/wp-json/quizably/v1/public/quiz/36dd2f0a-1a2b-4c3d-8e9f-0a1b2c3d4e5f/poll-results"
{
  "question_id": 11,
  "total": 40,
  "options": [
    { "answer_id": 31, "label": "Coffee", "votes": 25, "percent": 62.5 },
    { "answer_id": 32, "label": "Tea", "votes": 15, "percent": 37.5 }
  ]
}
  • Only the poll’s first question is counted, and only completed submissions (the newest 20,000 are read).
  • Every picked option counts as one vote, so a multiple choice poll can add up to more than the number of voters. Retakes count again.
  • percent is rounded to one decimal place.
  • The tally is cached for 20 seconds and the cache is cleared whenever any submission completes.

POST /public/submissions/start

Creates an in_progress submission.

ParameterTypeNotes
quiz_uuidstringRequired. The quiz must be published, otherwise 410 quizably_quiz_unavailable.
utm_source, utm_medium, utm_campaign, utm_term, utm_contentstringOptional. Only these five keys are kept, passed through sanitize_text_field.

The server also stores a hashed IP and the first 255 characters of the User-Agent header.

curl -X POST "https://example.com/wp-json/quizably/v1/public/submissions/start" \
  -H "Content-Type: application/json" \
  -d '{"quiz_uuid":"36dd2f0a-1a2b-4c3d-8e9f-0a1b2c3d4e5f","utm_source":"newsletter"}'

Response, HTTP 201:

{ "submission_uuid": "9b0c1d2e-3f40-4a51-8b62-7c8d9e0f1a2b" }

The bundled player calls this when the quiz mounts, not when the visitor presses Start.

POST /public/submissions/{uuid}/answer

Saves or replaces the answer to one question. The latest call per question_id wins.

ParameterTypeNotes
question_idintThe question being answered. It is not checked against the quiz.
answer_idsint[]Chosen answer IDs. Values are cast to int.
text_valuestringShort text or rating value. Tags stripped, trimmed, limited to 2000 characters. Non-scalar input is stored as null.
time_spent_msintAccepted. The bundled player does not send it.
elapsed_msintOptional time on the question.
answer_orderint[]Optional displayed order when answers were shuffled.
curl -X POST "https://example.com/wp-json/quizably/v1/public/submissions/9b0c1d2e-3f40-4a51-8b62-7c8d9e0f1a2b/answer" \
  -H "Content-Type: application/json" \
  -d '{"question_id":11,"answer_ids":[31],"elapsed_ms":4200}'

Response: { "saved": true }. Errors: 404 for an unknown submission, 409 quizably_locked if it is already completed.

POST /public/submissions/{uuid}/complete

Scores the submission, stores score, result_id and result_breakdown, marks it completed, and fires the quizably_submission_completed action. No body is needed.

curl -X POST "https://example.com/wp-json/quizably/v1/public/submissions/9b0c1d2e-3f40-4a51-8b62-7c8d9e0f1a2b/complete"
{
  "result": { "id": 7, "quiz_id": 3, "title": "Chai", "content": "<p>...</p>", "image_url": null,
              "cta_label": null, "cta_url": null, "redirect_url": null,
              "score_min": null, "score_max": null, "settings": {}, "position": 1 },
  "score": null,
  "breakdown": { "tally": { "7": 3 }, "total_answers": 4 }
}
  • result is null when no result matched. score is an integer only for scorers that produce one (trivia).
  • Calling it again for a completed submission returns the stored result without re-scoring and without firing the action again.
  • Errors: 404 unknown submission; 410 if the quiz was unpublished meanwhile; 500 quizably_no_scorer for a quiz type with no scorer (the free plugin has scorers for personality, trivia, survey and poll).
  • Required questions are not re-checked on the server.

POST /public/submissions/{uuid}/optin

Stores a lead and links it to the submission. On a quiz without double opt-in it fires quizably_lead_captured. On a quiz with double opt-in it sends the confirmation email instead, and quizably_lead_captured fires later, when the lead confirms.

ParameterTypeNotes
emailstringRequired. sanitize_email then is_email, else 400 quizably_bad_email.
namestringsanitize_text_field.
phonestringsanitize_text_field.
extra_fieldsobjectKeys through sanitize_key, values through sanitize_text_field, values over 1000 characters dropped, at most 20 kept.
consent_gdprboolAlso accepted as consent. Stored as 0 or 1.
curl -X POST "https://example.com/wp-json/quizably/v1/public/submissions/9b0c1d2e-3f40-4a51-8b62-7c8d9e0f1a2b/optin" \
  -H "Content-Type: application/json" \
  -d '{"email":"ann@example.com","name":"Ann","consent_gdpr":true}'

Response: { "lead_id": 5 }. If the quiz has double opt-in enabled and the lead has not confirmed yet, the response is { "lead_id": 5, "double_optin_pending": true }. The email is sent either way, and the response does not tell you whether wp_mail() succeeded.

  • Leads are upserted on the unique pair (quiz, email). A repeat submit updates name, phone, extra fields and consent on the existing row and returns the same lead_id.
  • There is no status check: opt-in works on in_progress and completed submissions.
  • Double opt-in: the lead is stored as pending (double_optin_verified is 0). A lead that already confirmed on this quiz is not asked again and is captured at once. Submitting again while still pending sends a new email. The bundled player carries on to the result and shows a “Check your inbox.” notice. See Double opt-in.

GET /public/confirm-optin

ParameterTypeNotes
tokenstringbase64url(lead_id).hmac, produced when the opt-in email is sent.

Not rate limited. On success sets leads.double_optin_verified to 1, fires quizably_lead_confirmed and then quizably_lead_captured, and returns:

{ "confirmed": true, "lead_id": 5, "already_confirmed": false }

already_confirmed is true when the lead had confirmed before. A repeat call is safe and fires nothing a second time.

Error: 400 quizably_invalid_token with the message “Invalid confirmation token”. It is returned for a malformed token, a bad signature, a lead that does not exist, and a lead that never asked for double opt-in. Tokens do not expire.

The link in the email does not point at this route. It points at https://example.com/?quizably_doi=TOKEN, which shows a confirmation page and does the same confirmation. See Double opt-in and Capabilities, permissions and security.

Admin routes

Every route in this section uses the permission callback current_user_can( 'quizably_manage_quizzes' ) and answers 403 quizably_forbidden otherwise. The capability is granted to the Administrator role on plugin activation. Authenticate with the logged-in cookie plus an X-WP-Nonce header (the wp_rest nonce, which the admin app receives as QUIZABLY_ADMIN.nonce), or with any other WordPress REST authentication method such as application passwords.

# Application password (WordPress core feature), HTTPS recommended
curl -u "admin:abcd efgh ijkl mnop qrst uvwx" \
  "https://example.com/wp-json/quizably/v1/quizzes?status=published&limit=5"

Paths below are relative to the base URL. {id} is the numeric database ID. Methods: PUT and PATCH are interchangeable on “update” routes, because they are registered as editable routes (POST, PUT, PATCH).

Quizzes

MethodPathPurpose
GETquizzesList. Query: status, type, search, orderby, order, limit (default 20), offset. Returns { items, total } with submission_count and completion_rate per item.
POSTquizzesCreate. Body: title (required), slug, type (default personality), template (default classic). New quizzes are published. 409 on slug conflict.
POSTquizzes/importImport an export envelope as a new draft quiz.
GETquizzes/{id}Quiz with nested questions (each with answers) and results.
PUTquizzes/{id}Update any of title, slug, type, status, template, settings, design. settings and design replace the stored JSON, they are not merged.
DELETEquizzes/{id}Archive (sets status to archived). With force=1 (or true, yes) deletes the quiz with its questions, answers, results, submissions and leads.
POSTquizzes/{id}/duplicateCopy as a draft named ”… (Copy)”.
POSTquizzes/{id}/publishBody published: false, "false" or 0 unpublishes to draft, anything else publishes.
GETquizzes/{id}/exportJSON export envelope (quiz, questions with answers, results). Excludes leads and submissions.
POSTquizzes/{id}/test-webhookSend a test event. Body: webhook_url (required), webhook_secret. 6 second timeout. Returns { ok, http_status, message }.

Questions, answers and results

MethodPathPurpose
GETquizzes/{id}/questionsList a quiz’s questions.
POSTquizzes/{id}/questionsCreate. Body: title (required), type (default single), description, media_url, media_type, required, position, settings, logic, and an optional nested answers array.
POSTquizzes/{id}/questions/reorderBody order: array of question IDs.
GET, PUT, DELETEquestions/{id}Read, update or delete a question (delete also removes its answers).
GETquestions/{id}/answersList answers.
POSTquestions/{id}/answersCreate. Body: label (required), value, is_correct, points, position, weights, personality_result_id, media_url.
POSTquestions/{id}/answers/reorderBody order: array of answer IDs.
PUT, DELETEanswers/{id}Update or delete an answer.
GETquizzes/{id}/resultsList results.
POSTquizzes/{id}/resultsCreate. Body: title (required), content, image_url, cta_label, cta_url, redirect_url, score_min, score_max, conditions, settings, position.
PUT, DELETEresults/{id}Update or delete a result.

Leads and submissions

MethodPathPurpose
GETleadsList all leads. Query: quiz_id, search (email substring), date_from, date_to, limit (default 20, max 200), offset. Returns { items, total }.
GETquizzes/{id}/leadsSame, scoped to a quiz.
GETleads/{id}/detailLead with its latest completed submission, answers enriched with question titles and option labels.
DELETEleads/{id}Delete the lead row only. The linked submission stays.
GETleads/exportCSV download, optional quiz_id and the same filters.
GETquizzes/{id}/leads/exportCSV download for one quiz.
GETquizzes/{id}/submissionsSubmissions. Query: status (in_progress or completed), date_from, date_to, limit (default 20), offset.
GETsubmissions/{id}One submission, with its lead when linked.

The CSV starts with id, email, name, phone, consent_gdpr, created_at, quiz_id, quiz_title, result_title, score, double_optin_status (not_required, pending or verified) and the five utm_* columns. After those come one extra_<key> column per custom form field and one column per question title. Cells that start with =, +, -, @, a tab or a carriage return get a leading apostrophe, so spreadsheets do not run them as formulas. See Leads dashboard.

Analytics

MethodPathPurpose
GETanalyticsSite-wide overview used by the dashboard.
GETquizzes/{id}/analyticsPer-quiz totals, result distribution, view count.
GETanalytics/questions/{quiz_id}Per-question reach, answers, drop-off and average time (built from completed submissions only).
GETanalytics/{quiz_id}/exportCSV. Add include_questions=1 for the per-question block.

Settings, integrations, templates, presets and question bank

MethodPathPurpose
GETsettingsAll rows from the settings table as a key to value map.
PUTsettingsUpdate known keys; any unknown key returns 400 listing the allowed ones. Keys are validated per type (booleans stored as "1" or "0", emails checked with is_email). Allowed keys are listed in Database schema.
GETintegrationsIntegration catalog (key, label, configured). Filterable with quizably_integrations.
GET, PUTsettings/integrations/{key}, settings/integrationsConnector credential storage. Accepts only a fixed allowlist of built-in connector keys; other keys return 400. Secrets are masked on read.
GETtemplatesTemplate catalog. Filterable with quizably_templates.
GETpresetsPreset quiz metadata for the gallery.
POSTpresets/{slug}/importCreate a quiz from a preset. Optional body title.
GET, POSTquestion-bankList (search, type, tags, orderby, order, limit default 50, offset) or create.
GETquestion-bank/tagsTag list.
GET, PUT, DELETEquestion-bank/{id}Read, update or delete a bank question.
POSTquestion-bank/{id}/duplicateDuplicate a bank question.
POSTquestion-bank/{id}/insertCopy into a quiz. Body: quiz_id (required), position.

Allowed bank question types are single, multi and truefalse.

Notes for integrators

  • Quiz status and type are not validated by the server. Use the known values: statuses draft, published, archived; types personality, trivia, survey, poll.
  • Question, answer and result titles and labels are stored unsanitized. Only description and result content go through wp_kses_post.
  • Admin routes serve the bundled app and may change between versions. Prefer the hooks for reacting to submissions.

Last updated October 4, 2026.