Skip to content

Database schema

Quizably custom tables, columns, keys, JSON shapes, IP hashing, options, transients and what uninstall removes. Read-only reference for developers.

Quizably stores its data in eight custom tables plus a few WordPress options and transients. This page documents them as of plugin version 1.2.0, schema version 1.3.0 (unchanged since 1.0.0), taken from Schema.php and the repository classes.

Read this first

  • Treat the schema as read-only. Do not write to these tables directly. Rows are tied together in code (for example, deleting a quiz deletes its questions, answers, results, submissions and leads in the right order, and imports remap IDs). Use the REST API to create and change data and the hooks to react to events.
  • Reading with SQL for reporting is reasonable, but column layouts can change in a minor release. The plugin upgrades itself through dbDelta() when quizably_db_version differs from the code’s schema version.
  • All table names are {$wpdb->prefix}quizably_{name}. With the default prefix: wp_quizably_quizzes and so on.
  • Timestamps are stored as DATETIME in UTC (current_time( 'mysql', true )). The REST API converts them to ISO 8601 with a Z.
  • JSON columns are LONGTEXT holding wp_json_encode() output. They are not MySQL JSON type columns.

ER-style summary

quizzes 1 ---- * questions 1 ---- * answers
   |  \                                 ^ personality_result_id (soft)
   |   \---- * results <----------------+
   |
   +---- * submissions * ---- 0..1 leads      (submissions.lead_id, soft)
   |          (result_id -> results.id, soft)
   +---- * leads                              UNIQUE (quiz_id, email)

question_bank        standalone templates, copied into questions on insert
settings             key/value store for global settings

No foreign key constraints exist. All relations are by convention, enforced in PHP. Orphans are possible, for example a submission keeps a dangling lead_id after the lead is deleted from the admin.

Tables

quizzes

ColumnTypeMeaning
idBIGINT UNSIGNED, PK, auto incrementNumeric ID used by admin routes and the [quizably_quiz] shortcode.
uuidCHAR(36), uniquePublic identifier used by public routes and hydration markup.
titleVARCHAR(255)Quiz title. Stored as submitted.
slugVARCHAR(255), uniqueMust be unique.
typeVARCHAR(32), default personalitypersonality, trivia, survey or poll have built-in scorers.
statusVARCHAR(16), default draftdraft, published, archived. Only published quizzes render and accept submissions.
templateVARCHAR(64), default classicTemplate key.
settingsLONGTEXT NULLJSON object, see below.
designLONGTEXT NULLJSON object, see below.
view_countBIGINT UNSIGNED, default 0Incremented on each PHP render of the shortcode, block or iframe embed page. Cached pages do not count.
author_idBIGINT UNSIGNED, default 0Creating user ID. Never sent to the browser.
created_at, updated_atDATETIMEUTC.

Keys: PRIMARY (id), UNIQUE uuid, UNIQUE slug, KEY status_type (status, type), KEY author (author_id).

questions

ColumnTypeMeaning
idBIGINT UNSIGNED, PK
quiz_idBIGINT UNSIGNEDParent quiz.
bank_origin_idBIGINT UNSIGNED NULLSet when the question was copied from the question bank.
typeVARCHAR(32), default singleThe player renders single, multi or multiple, true_false or truefalse, dropdown, short_text and rating.
titleTEXTQuestion text. Stored as submitted and rendered as HTML by the player.
descriptionTEXT NULLRich text, filtered with wp_kses_post on save through the REST API.
media_urlVARCHAR(2048) NULL
media_typeVARCHAR(16) NULL
requiredTINYINT(1), default 0
positionINT, default 0Sort order.
settingsLONGTEXT NULLJSON, see below.
logicLONGTEXT NULLJSON branching rules, see below.

Keys: PRIMARY (id), KEY quiz_pos (quiz_id, position), KEY bank_origin (bank_origin_id).

answers

ColumnTypeMeaning
idBIGINT UNSIGNED, PK
question_idBIGINT UNSIGNEDParent question.
labelTEXTOption text shown to the visitor.
valueVARCHAR(255), default emptySlug-like value.
is_correctTINYINT(1), default 0Used by the trivia scorer. Never sent to the browser.
pointsINT, default 0Trivia points for a correct pick. Never sent to the browser.
weightsLONGTEXT NULLJSON. Stored and returned to admin, not read by the built-in scorers.
personality_result_idBIGINT UNSIGNED NULLResult that gets +1 when this answer is picked (personality quizzes). Sent to the browser.
media_urlVARCHAR(2048) NULL
positionINT, default 0

Keys: PRIMARY (id), KEY question_pos (question_id, position), KEY personality (personality_result_id).

results

ColumnTypeMeaning
idBIGINT UNSIGNED, PK
quiz_idBIGINT UNSIGNED
titleVARCHAR(255)Stored as submitted. Rendered as HTML by the player and sent raw in webhook payloads.
contentLONGTEXT NULLRich text, wp_kses_post on save.
image_url, redirect_url, cta_urlVARCHAR(2048) NULLredirect_url sends the visitor away instead of showing the result.
cta_labelVARCHAR(255) NULL
score_min, score_maxINT NULLInclusive trivia range. NULL means open ended on that side.
conditionsLONGTEXT NULLJSON. Stored, never read by the built-in scorers, never sent to the browser.
settingsLONGTEXT NULLJSON per-result presentation (background, overlay, chart, share and alignment options).
positionINT, default 0Tie-break and match order (position, id).

Keys: PRIMARY (id), KEY quiz_pos (quiz_id, position).

submissions

One row per quiz run. A row is created when the player mounts, so many rows stay in_progress forever.

ColumnTypeMeaning
idBIGINT UNSIGNED, PK
uuidCHAR(36), uniqueThe only handle the browser holds.
quiz_idBIGINT UNSIGNED
user_idBIGINT UNSIGNED NULLColumn exists. The plugin never sets it.
lead_idBIGINT UNSIGNED NULLSet when the lead form is submitted.
answersLONGTEXT NULLJSON array, see below.
scoreINT NULL
result_idBIGINT UNSIGNED NULLMatched result. NULL when none matched.
result_breakdownLONGTEXT NULLJSON, shape depends on scorer.
ip_hashCHAR(64) NULLSalted, truncated hash. Only 16 hex characters are written.
user_agentVARCHAR(255) NULLRaw User-Agent header, cut to 255 characters.
utmLONGTEXT NULLJSON with up to five utm_* keys.
started_at, completed_atDATETIME NULLUTC.
statusVARCHAR(16), default in_progressin_progress or completed. There is no abandoned status.

Keys: PRIMARY (id), UNIQUE uuid, KEY quiz_status (quiz_id, status, completed_at), KEY lead (lead_id).

leads

ColumnTypeMeaning
idBIGINT UNSIGNED, PK
quiz_idBIGINT UNSIGNED
emailVARCHAR(320)
nameVARCHAR(255) NULL
phoneVARCHAR(64) NULLAccepted by the API. The bundled form does not collect it.
extra_fieldsLONGTEXT NULLJSON object of up to 20 string fields.
consent_gdprTINYINT(1), default 0
double_optin_verifiedTINYINT(1) NULLNULL the quiz did not use double opt-in when the lead was saved, 0 pending (confirmation email sent), 1 confirmed. Analytics counts only NULL or 1. The Leads screen lists all three.
integration_sync_statusLONGTEXT NULLJSON map { integration: status }. The plugin does not write it during normal operation.
created_atDATETIMEUTC.

Keys: PRIMARY (id), UNIQUE quiz_email (quiz_id, email), KEY quiz (quiz_id). The unique key means one lead per email per quiz. Repeat submissions update the existing row (name, phone, extra fields and consent), and created_at is kept. The same email on a different quiz is a separate lead.

settings

ColumnTypeMeaning
setting_keyVARCHAR(128), PK
valueLONGTEXT NULLScalars as strings. Arrays and objects are JSON. On read, any value starting with { or [ is JSON decoded.

This is a plugin-owned key/value table, separate from wp_options.

question_bank

ColumnTypeMeaning
idBIGINT UNSIGNED, PK
uuidCHAR(36), unique
typeVARCHAR(32), default single
titleTEXT
descriptionTEXT NULL
media_urlVARCHAR(2048) NULL
media_typeVARCHAR(16) NULL
settingsLONGTEXT NULLJSON.
answersLONGTEXT NULLJSON array of answer objects. There is no per-bank answers table.
tagsVARCHAR(512) NULLComma separated, lowercase slugs.
author_idBIGINT UNSIGNED, default 0
usage_countBIGINT UNSIGNED, default 0Incremented on each insert into a quiz.
created_at, updated_atDATETIMEUTC.

Keys: PRIMARY (id), UNIQUE uuid, KEY type_updated (type, updated_at), KEY author (author_id). Inserting into a quiz copies the data. There is no live link back.

JSON column shapes

These are the shapes the code reads and writes. The builder app owns most of the free-form settings and design objects, so treat unknown keys as private.

quizzes.settings

Keys read by the player or server (not exhaustive):

{
  "randomize_questions": true,
  "question": { "auto_advance": true },
  "result": { "review_answers": true },
  "screens": { "intro_title": "Optional override of the quiz title" },
  "optin": {
    "placement": "end",
    "skippable": false,
    "double_optin": false
  },
  "integrations": {
    "webhook": { "enabled": true, "url": "https://example.com/hook", "secret": "optional" }
  }
}
  • optin is the stored key name for the lead form. placement is start, end or none. The legacy values gate (read as start) and optional (read as end, skippable) still exist in old rows.
  • integrations.<key> entries are what the dispatcher uses to decide whether to call an integration. See Hooks and filters.
  • The integrations.webhook.secret is stored in plain text in this column and is returned by GET /quizzes/{id}.

quizzes.design

Keys the player reads include colors (primary, background, text, accent), background_opacity, font_family, font_size, button_style (rounded, pill, sharp), show_border, layout_mode (card or full), card and inner width and height values with unit keys, and the split screen layout values. See Front-end JavaScript API for the CSS custom properties they produce.

questions.settings and questions.logic

{ "required": true, "randomize_answers": false, "image_height": 300,
  "timer": { "enabled": true, "seconds": 30 } }

required here defaults to true unless it is explicitly false. The logic column holds branching rules the player evaluates client side. The first matching rule wins and go_to_question_id: null ends the quiz:

{ "rules": [
    { "if": { "answer_id": 5 }, "then": { "go_to_question_id": 12 } },
    { "default": { "go_to_question_id": null } }
] }

submissions.answers

[
  { "question_id": 11, "answer_ids": [31], "text_value": null,
    "time_spent_ms": 0, "elapsed_ms": 4200, "answer_order": [32, 31, 33] }
]

elapsed_ms and answer_order are present only when the client sent them. Rating and short text answers live in text_value with an empty answer_ids.

submissions.result_breakdown

Quiz typeShape
personality{ "tally": { "<result_id>": votes }, "total_answers": n }
trivia{ "score": n, "correct_count": n, "total_questions": n }
survey, poll{ "answered_questions": n, "total_questions": n }

submissions.utm

An object with any of utm_source, utm_medium, utm_campaign, utm_term, utm_content.

IP hashing and personal data

  • The raw IP address is never written to the database.
  • submissions.ip_hash and the rate limit key are substr( sha256( REMOTE_ADDR . '|' . wp_salt() ), 0, 16 ). Only REMOTE_ADDR is used, optionally replaced through the quizably_rate_limit_ip filter before hashing.
  • The column is CHAR(64) but 16 hex characters are stored. The hash is salted per site and is not used to block repeat submissions.
  • Personal data in the tables: leads (email, name, phone, extra fields, consent), submissions (hashed IP, User-Agent, UTM and the visitor’s typed answers). See Data and privacy.

Options

OptionPurpose
quizably_db_versionInstalled schema version. Compared on admin boot to decide whether to run dbDelta() again.
quizably_versionPlugin version written at activation.
quizably_doi_secretRandom 32 character secret created on first double opt-in use. Signs confirmation tokens.
quizably_integration_{key}Connector credentials saved through PUT /settings/integrations. Not used by the built-in webhook.

The quizably_manage_quizzes capability is stored in the Administrator role (the {prefix}user_roles option) when the plugin is activated.

Settings table keys

The settings REST route accepts exactly these keys, and each is stored as a row in {prefix}quizably_settings. All of them are read by the plugin (see Settings).

default_template, default_optin_placement, branding_primary_color, branding_accent_color, defaults_font_family, defaults_button_radius, gdpr_default_consent_text, notifications_email_on_submission, notifications_email_on_lead, notifications_recipient, email_from_name, email_from_address, email_reply_to_lead, email_template_subject, email_template_body, default_webhook_url.

Boolean keys (notifications_email_on_submission, notifications_email_on_lead, email_reply_to_lead) are stored as the strings "1" and "0". Version 1.2.0 removed the keys for the logo URL, animation speed, lazy loading and the Editor and Author role toggles. If a site saved values for them earlier, those rows stay in the table and nothing reads them.

Transients and scheduled events

NamePurposeLifetime
quizably_rl_m_{hash}_{minute}Per-minute request counter, where {minute} is floor( time() / 60 ).65 seconds
quizably_rl_h_{hash}_{hour}Per-hour request counter, where {hour} is floor( time() / 3600 ).3700 seconds
quizably_poll_{quiz_id}Cached vote counts for a poll, shown to visitors after they vote. Cleared whenever a submission completes.20 seconds
WP-Cron quizably_integration_deliverSingle event that sends a webhook in the background, scheduled to run immediately.Until it runs
WP-Cron quizably_integration_retrySingle events scheduled to retry failed webhook deliveries.60, 120 and 240 seconds after each failure

Queued cron events keep the full webhook payload and the webhook settings (including any signing secret) in the WordPress cron option until they run. Earlier versions also kept a quizably_doi_used_* transient for confirmation tokens. Version 1.2.0 no longer writes it.

With a persistent object cache, transients live in the cache and not in wp_options.

What uninstall deletes

By default, deleting the plugin from the Plugins screen removes nothing. Every table, setting and option stays in the database, so deleting and re-uploading the plugin never loses your work. Deactivation also keeps everything.

To erase everything on delete, add this line to wp-config.php before you delete the plugin:

define( 'QUIZABLY_REMOVE_ALL_DATA', true );

With that constant set, uninstall.php drops all eight tables (DROP TABLE IF EXISTS) and deletes every option whose name starts with quizably_. That includes quizably_db_version, quizably_version, quizably_doi_secret and the quizably_integration_{key} options.

Not removed even then: leftover transients (they expire on their own), pending cron events and the quizably_manage_quizzes capability on the Administrator role.

Last updated October 4, 2026.