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.
Base URL and permalink shapes
The namespace root depends on the site’s permalink setting:
| Permalinks | Base URL |
|---|---|
| Pretty | https://example.com/wp-json/quizably/v1/ |
| Plain | https://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
_atthat holds a database timestamp (stored as naive UTCY-m-d H:i:s) is rewritten to ISO 8601 with aZsuffix, for example2026-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 asnullwhen empty. - Errors use the WordPress
WP_Errorshape:
{ "code": "quizably_not_found", "message": "Quiz not found", "data": { "status": 404 } }
| Code | Status | Meaning |
|---|---|---|
quizably_forbidden | 403 | Missing the quizably_manage_quizzes capability (also returned to logged-out callers). |
quizably_not_found | 404 | Resource does not exist. |
quizably_bad_request | 400 | Missing or invalid parameter. |
quizably_slug_conflict | 409 | Quiz slug already used. |
quizably_db_error | 500 | A database write failed. |
quizably_rate_limited | 429 | Public rate limit hit. |
quizably_quiz_unavailable | 410 | Quiz is not published. |
quizably_locked | 409 | Answer sent to an already completed submission. |
quizably_no_scorer | 500 | No scorer for the quiz type. |
quizably_bad_email | 400 | Opt-in email invalid. |
quizably_invalid_token | 400 | Confirmation token malformed, signature mismatch, or the lead does not exist or never asked for double opt-in. |
quizably_not_a_poll | 400 | The poll results route was called for a quiz that is not a poll. |
quizably_webhook_error | 500 | Test webhook request failed at transport level. |
quizably_submission_failed | 500 | Submission 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:
| Window | Default | Filter |
|---|---|---|
| Per minute | 60 requests | quizably_rate_limit_per_minute |
| Per hour | 300 requests | quizably_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.
percentis 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.
| Parameter | Type | Notes |
|---|---|---|
quiz_uuid | string | Required. The quiz must be published, otherwise 410 quizably_quiz_unavailable. |
utm_source, utm_medium, utm_campaign, utm_term, utm_content | string | Optional. 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.
| Parameter | Type | Notes |
|---|---|---|
question_id | int | The question being answered. It is not checked against the quiz. |
answer_ids | int[] | Chosen answer IDs. Values are cast to int. |
text_value | string | Short text or rating value. Tags stripped, trimmed, limited to 2000 characters. Non-scalar input is stored as null. |
time_spent_ms | int | Accepted. The bundled player does not send it. |
elapsed_ms | int | Optional time on the question. |
answer_order | int[] | 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 }
}
resultisnullwhen no result matched.scoreis 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_scorerfor a quiz type with no scorer (the free plugin has scorers forpersonality,trivia,surveyandpoll). - 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.
| Parameter | Type | Notes |
|---|---|---|
email | string | Required. sanitize_email then is_email, else 400 quizably_bad_email. |
name | string | sanitize_text_field. |
phone | string | sanitize_text_field. |
extra_fields | object | Keys through sanitize_key, values through sanitize_text_field, values over 1000 characters dropped, at most 20 kept. |
consent_gdpr | bool | Also 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_progressandcompletedsubmissions. - Double opt-in: the lead is stored as pending (
double_optin_verifiedis0). 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
| Parameter | Type | Notes |
|---|---|---|
token | string | base64url(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
| Method | Path | Purpose |
|---|---|---|
| GET | quizzes | List. Query: status, type, search, orderby, order, limit (default 20), offset. Returns { items, total } with submission_count and completion_rate per item. |
| POST | quizzes | Create. Body: title (required), slug, type (default personality), template (default classic). New quizzes are published. 409 on slug conflict. |
| POST | quizzes/import | Import an export envelope as a new draft quiz. |
| GET | quizzes/{id} | Quiz with nested questions (each with answers) and results. |
| PUT | quizzes/{id} | Update any of title, slug, type, status, template, settings, design. settings and design replace the stored JSON, they are not merged. |
| DELETE | quizzes/{id} | Archive (sets status to archived). With force=1 (or true, yes) deletes the quiz with its questions, answers, results, submissions and leads. |
| POST | quizzes/{id}/duplicate | Copy as a draft named ”… (Copy)”. |
| POST | quizzes/{id}/publish | Body published: false, "false" or 0 unpublishes to draft, anything else publishes. |
| GET | quizzes/{id}/export | JSON export envelope (quiz, questions with answers, results). Excludes leads and submissions. |
| POST | quizzes/{id}/test-webhook | Send a test event. Body: webhook_url (required), webhook_secret. 6 second timeout. Returns { ok, http_status, message }. |
Questions, answers and results
| Method | Path | Purpose |
|---|---|---|
| GET | quizzes/{id}/questions | List a quiz’s questions. |
| POST | quizzes/{id}/questions | Create. Body: title (required), type (default single), description, media_url, media_type, required, position, settings, logic, and an optional nested answers array. |
| POST | quizzes/{id}/questions/reorder | Body order: array of question IDs. |
| GET, PUT, DELETE | questions/{id} | Read, update or delete a question (delete also removes its answers). |
| GET | questions/{id}/answers | List answers. |
| POST | questions/{id}/answers | Create. Body: label (required), value, is_correct, points, position, weights, personality_result_id, media_url. |
| POST | questions/{id}/answers/reorder | Body order: array of answer IDs. |
| PUT, DELETE | answers/{id} | Update or delete an answer. |
| GET | quizzes/{id}/results | List results. |
| POST | quizzes/{id}/results | Create. Body: title (required), content, image_url, cta_label, cta_url, redirect_url, score_min, score_max, conditions, settings, position. |
| PUT, DELETE | results/{id} | Update or delete a result. |
Leads and submissions
| Method | Path | Purpose |
|---|---|---|
| GET | leads | List all leads. Query: quiz_id, search (email substring), date_from, date_to, limit (default 20, max 200), offset. Returns { items, total }. |
| GET | quizzes/{id}/leads | Same, scoped to a quiz. |
| GET | leads/{id}/detail | Lead with its latest completed submission, answers enriched with question titles and option labels. |
| DELETE | leads/{id} | Delete the lead row only. The linked submission stays. |
| GET | leads/export | CSV download, optional quiz_id and the same filters. |
| GET | quizzes/{id}/leads/export | CSV download for one quiz. |
| GET | quizzes/{id}/submissions | Submissions. Query: status (in_progress or completed), date_from, date_to, limit (default 20), offset. |
| GET | submissions/{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
| Method | Path | Purpose |
|---|---|---|
| GET | analytics | Site-wide overview used by the dashboard. |
| GET | quizzes/{id}/analytics | Per-quiz totals, result distribution, view count. |
| GET | analytics/questions/{quiz_id} | Per-question reach, answers, drop-off and average time (built from completed submissions only). |
| GET | analytics/{quiz_id}/export | CSV. Add include_questions=1 for the per-question block. |
Settings, integrations, templates, presets and question bank
| Method | Path | Purpose |
|---|---|---|
| GET | settings | All rows from the settings table as a key to value map. |
| PUT | settings | Update 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. |
| GET | integrations | Integration catalog (key, label, configured). Filterable with quizably_integrations. |
| GET, PUT | settings/integrations/{key}, settings/integrations | Connector credential storage. Accepts only a fixed allowlist of built-in connector keys; other keys return 400. Secrets are masked on read. |
| GET | templates | Template catalog. Filterable with quizably_templates. |
| GET | presets | Preset quiz metadata for the gallery. |
| POST | presets/{slug}/import | Create a quiz from a preset. Optional body title. |
| GET, POST | question-bank | List (search, type, tags, orderby, order, limit default 50, offset) or create. |
| GET | question-bank/tags | Tag list. |
| GET, PUT, DELETE | question-bank/{id} | Read, update or delete a bank question. |
| POST | question-bank/{id}/duplicate | Duplicate a bank question. |
| POST | question-bank/{id}/insert | Copy into a quiz. Body: quiz_id (required), position. |
Allowed bank question types are single, multi and truefalse.
Notes for integrators
- Quiz
statusandtypeare not validated by the server. Use the known values: statusesdraft,published,archived; typespersonality,trivia,survey,poll. - Question, answer and result titles and labels are stored unsanitized. Only
descriptionand resultcontentgo throughwp_kses_post. - Admin routes serve the bundled app and may change between versions. Prefer the hooks for reacting to submissions.
Related
Last updated October 4, 2026.