Contract first
Synchronous and stateless.
QuizOut is synchronous: POST /quizzes returns the complete quiz in the same response. There is no polling endpoint or generation webhook.
QuizOut is stateless for content: article input and generated quiz output are not durable product records. The WordPress plugin stores and renders the returned quiz.
The only transient generation record is an encrypted successful idempotency response plus a request digest in Solid Cache for at most 24 hours.
- Base URL
https://api.quizout.io/v1- Media type
application/json- Contract version
1.1
Authentication
One site key per integration.
Pass a per-site key as Authorization: Bearer <site_api_key>. Site keys are separate from Devise console sessions and are shown in plaintext only once when issued.
qo_live_ live key
qo_live_ keys use the production generation path. A post's first successful quiz consumes quota; later successful regenerations do not consume another post unit.
qo_test_ test key
qo_test_ keys use a deterministic provider-free generator. They never create usage-ledger entries, consume monthly quota or prepaid credits, or count successful regenerations.
A sandbox site is a separate URL classification for localhost, private, .localhost, .test, .local, or staging environments. Site environment and key mode are independent: a live key on a sandbox site still uses real generation and its sandbox allowance; a test key is provider-free on either site environment.
Safe integration loop
Start with a deterministic test key.
Test responses use generic deterministic integration content and never echo the submitted title, article body, or audience. Validation, idempotency, request throttles, key-sharing protection, and last-used tracking still apply. usage.counted is always false and the remaining usage fields are an unchanged snapshot of the site.
When no format hint is supplied, test generation returns comprehension. It honours:
- context.format_hint
- context.question_count
- post.locale
- the site's attribution entitlement
export QUIZOUT_API_KEY='qo_test_REPLACE_WITH_YOUR_KEY'
curl --request POST \
'https://api.quizout.io/v1/quizzes' \
--header "Authorization: Bearer ${QUIZOUT_API_KEY}" \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: 00000000-0000-4000-8000-000000000001' \
--data-binary '{
"post": {
"external_id": "wp_1024",
"url": "https://blog.example.com/sunset-glam",
"title": "Sunset Glam Makeup Look",
"content": "Full article text or HTML",
"published_at": "2026-07-20T17:16:31Z",
"locale": "en"
},
"context": {
"format_hint": null,
"question_count": 4,
"audience": "beauty readers",
"allow_scorecard": true
}
}'
Fair-use controls
Rate limits and permanent guards.
The X-RateLimit-* response headers describe the binding guard. Temporal 429 responses include Retry-After; a nonzero limit with Reset: 0 is permanent.
| Limit | Applies to | Capacity | Window |
|---|---|---|---|
| Authenticated API key | Authenticated endpoints except POST /quizzes | 120 requests per key | 1 minute |
| Authenticated source IP | Authenticated endpoints except POST /quizzes | 300 requests per IP | 1 minute |
| Quiz generation key | POST /quizzes, including qo_test_ keys | 10 requests per key | 1 minute |
| Quiz generation source IP | POST /quizzes, including qo_test_ keys | 30 requests per IP | 1 minute |
| Provisioning attempts | POST /sites | 10 requests per IP | 1 hour |
| Distinct verified live sites | POST /sites | 20 sites per IP | UTC day |
| Distinct sandbox sites | POST /sites | 5 sites per IP | UTC day |
One in flight per site and post
429 rate_limited; reason regeneration_in_progress; Retry-After: 53 per site and post for its lifetime
429 rate_limited; reason regeneration_limit_reached; Reset: 010 per site and post for its lifetime
429 rate_limited; reason regeneration_limit_reached; Reset: 010 distinct source fingerprints
Rolling 24-hour window; 429 rate_limited with reason api_key_ip_limitCommon response headers
| Header | Meaning |
|---|---|
X-Request-Id | Caller-supplied correlation ID, or a server-generated one. |
X-RateLimit-Limit | Limit for the currently binding request, abuse, or regeneration guard. Zero means that no limit is enforced for this operation. |
X-RateLimit-Remaining | Capacity remaining for the binding guard. Zero is also the no-enforcement sentinel when limit is zero. |
X-RateLimit-Reset | Unix reset timestamp. Zero means either no enforced limit when Limit is zero, or no automatic reset for the lifetime regeneration cap when Limit is nonzero. |
QuizOut-Spec-Version | QuizOut response contract version. |
Retry-After | Seconds until a rate-limited request should be retried. |
Predictable failures
One error envelope.
Every non-2xx response uses { "error": { "code", "message", "details" } }. The details value is always an object.
| HTTP | Code | Canonical message | When it happens |
|---|---|---|---|
| 401 | unauthorized |
Invalid or missing API key. | Missing or invalid API key. |
| 403 | forbidden |
This API key is not permitted for this resource. | API key not permitted for the resource. |
| 422 | invalid_request |
The request is invalid. | Request validation failed. |
| 402 | quota_exceeded |
Monthly post limit reached. | Monthly post quota reached for a new post. |
| 429 | rate_limited |
Too many requests. | Too many requests. Temporal limits include Retry-After. details.reason is regeneration_in_progress for a concurrent same-post regeneration (Retry-After: 5), regeneration_limit_reached for the permanent Free 3 or paid 10 lifetime cap (Limit: 3 or 10, Remaining: 0, Reset: 0, and no Retry-After), or api_key_ip_limit after an API key exceeds 10 distinct source fingerprints in a rolling 24-hour window (Limit: 10, Remaining: 0, Reset: window end, and Retry-After present). |
| 502 | generation_failed |
Quiz generation failed. Retry the request. | Quiz generation failed or the same idempotent request is still running. |
| 500 | server_error |
An unexpected error occurred. | Unexpected server error. |
| 501 | not_implemented |
This endpoint is not implemented yet. | Transitional response until this endpoint's owning backlog item ships. |
Endpoint reference
All six v1 operations.
Paths below are relative to https://api.quizout.io/v1. Request and response examples come directly from the OpenAPI document.
Sites
/sites
Auto-provision an anonymous free site
Public HTTPS sites prove control through a redirect-free POST of { proof } to {home_url}/wp-json/quizout/v1/provisioning-proof. The callback must return JSON containing exactly { verified: true, proof: <same>, home_url: <canonical URL> }. Unreachable localhost, private-address, .localhost, .test, and .local sites are provisioned as tightly limited sandboxes without an outbound callback; a public hostname with a staging label is sandboxed but still verified. Anonymous retries reuse the Site and site token while replacing the prior bootstrap key. Provisioning is limited to 10 attempts per IP/hour and 20 distinct verified live or 5 distinct sandbox sites per IP/UTC day.
Authentication: Public endpoint
Request fields
| Field | Type | Required | Description and constraints |
|---|---|---|---|
home_url |
string | Yes | format: uri |
wp_version |
string | No | minimum length: 1 |
timezone |
string | No | minimum length: 1 |
proof |
string | Yes | Short-lived token exposed by the QuizOut WordPress provisioning-proof callback and echoed in its verification response. minimum length: 1 |
Copy-paste request
curl --request POST \
'https://api.quizout.io/v1/sites' \
--header 'Content-Type: application/json' \
--data-binary '{
"home_url": "https://blog.example.com",
"wp_version": "6.8",
"timezone": "Europe/London",
"proof": "proof_abc123"
}'
Request body
{
"home_url": "https://blog.example.com",
"wp_version": "6.8",
"timezone": "Europe/London",
"proof": "proof_abc123"
}
Success response
{
"site_token": "sst_example123",
"api_key": "qo_live_example123",
"plan": "free",
"usage": {
"quota": 10,
"used": 0,
"remaining": 10,
"plan_quota": 10,
"plan_used": 0,
"pack_remaining": 0,
"period_end": "2026-08-01T00:00:00Z"
}
}
Response statuses
| HTTP | Meaning |
|---|---|
201 | Anonymous site provisioned. |
422 | Request validation failed. |
429 | Too many requests. Temporal limits include Retry-After. details.reason is regeneration_in_progress for a concurrent same-post regeneration (Retry-After: 5), regeneration_limit_reached for the permanent Free 3 or paid 10 lifetime cap (Limit: 3 or 10, Remaining: 0, Reset: 0, and no Retry-After), or api_key_ip_limit after an API key exceeds 10 distinct source fingerprints in a rolling 24-hour window (Limit: 10, Remaining: 0, Reset: window end, and Retry-After present). |
500 | Unexpected server error. |
Quiz generation
/quizzes
Generate a quiz synchronously
Validates the article and returns the complete quiz inline; no quiz resource is stored. Limited to 10 requests per API key/minute and 30 requests per IP/minute. With a qo_live_ key, a site/post receives 3 lifetime successful regenerations on Free or 10 on Growth, Pro, or while active prepaid credits remain. With a qo_test_ key, generation is deterministic and provider-free, usage.counted is false, quota and regeneration allowances are untouched, and the normal validation, idempotency, and temporal limits still apply.
Authentication: Bearer site key required
Request fields
| Field | Type | Required | Description and constraints |
|---|---|---|---|
post |
object | Yes | |
post.external_id |
string | Yes | minimum length: 1 |
post.url |
string | No | format: uri |
post.title |
string | Yes | minimum length: 1 |
post.content |
string | Yes | Article text or HTML, limited to 102400 UTF-8 bytes. minimum length: 1; maximum bytes: 102400 |
post.published_at |
string | No | format: date-time |
post.locale |
string | No | minimum length: 2 |
context |
object | No | |
context.format_hint |
string or null | No | Optional explicit format. Null selects automatic live detection or deterministic comprehension for qo_test_ generation. values: null, comprehension, true_false, myth_busting, myth-busting, scenario, scorecard, recommendation |
context.question_count |
integer | No | Number of questions to return; defaults to 4. minimum: 1; maximum: 5 |
context.audience |
string or null | No | |
context.allow_scorecard |
boolean | No |
Copy-paste request
curl --request POST \
'https://api.quizout.io/v1/quizzes' \
--header "Authorization: Bearer ${QUIZOUT_API_KEY}" \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: 00000000-0000-4000-8000-000000000001' \
--data-binary '{
"post": {
"external_id": "wp_1024",
"url": "https://blog.example.com/sunset-glam",
"title": "Sunset Glam Makeup Look",
"content": "Full article text or HTML",
"published_at": "2026-07-20T17:16:31Z",
"locale": "en"
},
"context": {
"format_hint": null,
"question_count": 4,
"audience": "beauty readers",
"allow_scorecard": true
}
}'
Request body
{
"post": {
"external_id": "wp_1024",
"url": "https://blog.example.com/sunset-glam",
"title": "Sunset Glam Makeup Look",
"content": "Full article text or HTML",
"published_at": "2026-07-20T17:16:31Z",
"locale": "en"
},
"context": {
"format_hint": null,
"question_count": 4,
"audience": "beauty readers",
"allow_scorecard": true
}
}
Success response
{
"quiz": {
"generation_id": "gen_9f3",
"format": "comprehension",
"scoring_kind": "graded",
"locale": "en",
"questions": [
{
"position": 1,
"prompt": "Which shade went in the inner corners?",
"answer_type": "single_select",
"explanation": "The look starts with canary yellow in the inner corners.",
"options": [
{
"position": 1,
"text": "Canary yellow",
"correct": true,
"points": null,
"outcome_key": null
},
{
"position": 2,
"text": "Rose pink",
"correct": false,
"points": null,
"outcome_key": null
},
{
"position": 3,
"text": "Bronze",
"correct": false,
"points": null,
"outcome_key": null
}
]
}
],
"result": {
"kind": "graded",
"max_score": null,
"bands": null,
"outcomes": null
},
"attribution": {
"powered_by": true,
"url": "https://quizout.io/?utm_source=badge"
},
"meta": {
"spec_version": "1.0",
"generated_at": "2026-07-21T10:00:05Z"
}
},
"usage": {
"plan": "growth",
"quota": 100,
"used": 38,
"remaining": 62,
"plan_quota": 100,
"plan_used": 38,
"pack_remaining": 0,
"period_end": "2026-08-01T00:00:00Z",
"counted": true
}
}
Response statuses
| HTTP | Meaning |
|---|---|
200 | Quiz generated synchronously. |
401 | Missing or invalid API key. |
402 | Monthly post quota reached for a new post. |
403 | API key not permitted for the resource. |
422 | Request validation failed. |
429 | Too many requests. Temporal limits include Retry-After. details.reason is regeneration_in_progress for a concurrent same-post regeneration (Retry-After: 5), regeneration_limit_reached for the permanent Free 3 or paid 10 lifetime cap (Limit: 3 or 10, Remaining: 0, Reset: 0, and no Retry-After), or api_key_ip_limit after an API key exceeds 10 distinct source fingerprints in a rolling 24-hour window (Limit: 10, Remaining: 0, Reset: window end, and Retry-After present). |
500 | Unexpected server error. |
502 | Quiz generation failed or the same idempotent request is still running. |
Usage
/usage
Get current site usage and attribution entitlement
Returns the allowance for the authenticated site's live or sandbox environment. Key mode does not create a separate usage pool; qo_test_ quiz requests leave these values unchanged.
Authentication: Bearer site key required
Copy-paste request
curl --request GET \
'https://api.quizout.io/v1/usage' \
--header "Authorization: Bearer ${QUIZOUT_API_KEY}"
Success response
{
"usage": {
"plan": "growth",
"quota": 100,
"used": 38,
"remaining": 62,
"plan_quota": 100,
"plan_used": 38,
"pack_remaining": 0,
"period_end": "2026-08-01T00:00:00Z"
},
"attribution": {
"powered_by": false,
"url": "https://quizout.io/?utm_source=badge"
}
}
Response statuses
| HTTP | Meaning |
|---|---|
200 | Current usage and attribution entitlement returned. |
401 | Missing or invalid API key. |
403 | API key not permitted for the resource. |
429 | Too many requests. Temporal limits include Retry-After. details.reason is regeneration_in_progress for a concurrent same-post regeneration (Retry-After: 5), regeneration_limit_reached for the permanent Free 3 or paid 10 lifetime cap (Limit: 3 or 10, Remaining: 0, Reset: 0, and no Retry-After), or api_key_ip_limit after an API key exceeds 10 distinct source fingerprints in a rolling 24-hour window (Limit: 10, Remaining: 0, Reset: window end, and Retry-After present). |
500 | Unexpected server error. |
Sites
/site
Get the site profile, plan, and usage
Returns the authenticated site profile and allowance. The current deployment may return the documented transitional 501 response until this operation ships.
Authentication: Bearer site key required
Copy-paste request
curl --request GET \
'https://api.quizout.io/v1/site' \
--header "Authorization: Bearer ${QUIZOUT_API_KEY}"
Success response
{
"site": {
"id": "ste_1",
"name": "Example Blog",
"plan": "growth",
"usage": {
"quota": 100,
"used": 38,
"remaining": 62,
"plan_quota": 100,
"plan_used": 38,
"pack_remaining": 0,
"period_end": "2026-08-01T00:00:00Z"
}
}
}
Response statuses
| HTTP | Meaning |
|---|---|
200 | Site profile, plan, and usage returned. |
401 | Missing or invalid API key. |
403 | API key not permitted for the resource. |
429 | Too many requests. Temporal limits include Retry-After. details.reason is regeneration_in_progress for a concurrent same-post regeneration (Retry-After: 5), regeneration_limit_reached for the permanent Free 3 or paid 10 lifetime cap (Limit: 3 or 10, Remaining: 0, Reset: 0, and no Retry-After), or api_key_ip_limit after an API key exceeds 10 distinct source fingerprints in a rolling 24-hour window (Limit: 10, Remaining: 0, Reset: window end, and Retry-After present). |
500 | Unexpected server error. |
501 | Transitional response until this endpoint's owning backlog item ships. |
Sites
/site/connect
Verify an API key and register site metadata
Authenticates only with the pasted site Bearer key, refreshes WordPress metadata and canonical URL/environment for that same Site, and preserves any console-defined name and existing keys. URL moves are allowed unless the canonical URL belongs to another Site; current Free-plan usage is returned for the site's live or sandbox pool.
Authentication: Bearer site key required
Request fields
| Field | Type | Required | Description and constraints |
|---|---|---|---|
home_url |
string | Yes | format: uri |
wp_version |
string | No | minimum length: 1 |
timezone |
string | No | minimum length: 1 |
Copy-paste request
curl --request POST \
'https://api.quizout.io/v1/site/connect' \
--header "Authorization: Bearer ${QUIZOUT_API_KEY}" \
--header 'Content-Type: application/json' \
--data-binary '{
"home_url": "https://blog.example.com",
"wp_version": "6.8",
"timezone": "Africa/Lagos"
}'
Request body
{
"home_url": "https://blog.example.com",
"wp_version": "6.8",
"timezone": "Africa/Lagos"
}
Success response
{
"site": {
"id": "ste_1",
"name": "Example Blog",
"plan": "growth",
"usage": {
"quota": 100,
"used": 38,
"remaining": 62,
"plan_quota": 100,
"plan_used": 38,
"pack_remaining": 0,
"period_end": "2026-08-01T00:00:00Z"
}
}
}
Response statuses
| HTTP | Meaning |
|---|---|
200 | Site profile, plan, and usage returned. |
401 | Missing or invalid API key. |
403 | API key not permitted for the resource. |
422 | Request validation failed. |
429 | Too many requests. Temporal limits include Retry-After. details.reason is regeneration_in_progress for a concurrent same-post regeneration (Retry-After: 5), regeneration_limit_reached for the permanent Free 3 or paid 10 lifetime cap (Limit: 3 or 10, Remaining: 0, Reset: 0, and no Retry-After), or api_key_ip_limit after an API key exceeds 10 distinct source fingerprints in a rolling 24-hour window (Limit: 10, Remaining: 0, Reset: window end, and Retry-After present). |
500 | Unexpected server error. |
Sites
/site/claim
Initiate a site claim by email magic link
Creates a 24-hour pending claim and emails a scanner-safe console link. This API call never authenticates a human or moves the Site; the console confirmation POST completes account creation or merge.
Authentication: Bearer site key required
Request fields
| Field | Type | Required | Description and constraints |
|---|---|---|---|
email |
string | Yes | format: email |
Copy-paste request
curl --request POST \
'https://api.quizout.io/v1/site/claim' \
--header "Authorization: Bearer ${QUIZOUT_API_KEY}" \
--header 'Content-Type: application/json' \
--data-binary '{
"email": "owner@example.com"
}'
Request body
{
"email": "owner@example.com"
}
Success response
{
"status": "claim_initiated"
}
Response statuses
| HTTP | Meaning |
|---|---|
202 | Claim email accepted; completion happens in the Devise console. |
401 | Missing or invalid API key. |
403 | API key not permitted for the resource. |
422 | Request validation failed. |
429 | Too many requests. Temporal limits include Retry-After. details.reason is regeneration_in_progress for a concurrent same-post regeneration (Retry-After: 5), regeneration_limit_reached for the permanent Free 3 or paid 10 lifetime cap (Limit: 3 or 10, Remaining: 0, Reset: 0, and no Retry-After), or api_key_ip_limit after an API key exceeds 10 distinct source fingerprints in a rolling 24-hour window (Limit: 10, Remaining: 0, Reset: window end, and Retry-After present). |
500 | Unexpected server error. |
Generate clients from the source.
The human guide and its examples are generated from the same OpenAPI 3.1 document used by contract tests.
Download the OpenAPI JSON