Hooks and filters
Reference for the Quizably action and filter hooks: submissions, leads, scoring, integrations, templates and rate limits, with copy-paste PHP examples.
Quizably exposes a small set of WordPress actions and filters. Every signature on this page was checked against the do_action() and apply_filters() call sites in the plugin source (version 1.2.0). Hooks that exist only for add-on gating are not listed.
Before you start
- All hooks use the
quizably_prefix with underscores. Do not confuse them with the slash-style names (quizably/...), which are internal. - Action and filter callbacks run synchronously inside the visitor’s REST request. A slow or fatal callback delays or breaks the visitor’s response. Keep work short, or defer it with
wp_schedule_single_event()or a non-blocking HTTP call. The one exception is the built-in webhook, which Quizably queues in WP-Cron itself. - Arguments such as
$quizare raw database rows. JSON columns (settings,design) are JSON strings in those rows, not arrays. See Database schema. - Plugin classes under
Quizably\Core,Quizably\Databaseand the repositories are internal. They are not a stable API. The classes documented here as extension points areQuizably\Scoring\ScorerInterface,Quizably\Integration\IntegrationInterface,Quizably\Integration\AbstractIntegrationandQuizably\Integration\Registry.
Hook summary
| Hook | Type | Arguments | Purpose |
|---|---|---|---|
quizably_booted | action | Plugin $plugin | Plugin services are ready. |
quizably_register_integrations | action | none | Register custom integrations. |
quizably_submission_completed | action | string $uuid, array $quiz, ?array $result, array $score | A submission was scored. |
quizably_lead_captured | action | int $lead_id, array $sub, ?WP_REST_Request $req | A lead counts as captured. With double opt-in, this waits for the confirmation click. |
quizably_lead_confirmed | action | int $lead_id, array $lead | A lead clicked the double opt-in link for the first time. |
quizably_integration_dispatched | action | string $key, array $res, array $payload, int $attempt | An integration attempt finished. |
quizably_scorer | filter | ?ScorerInterface $scorer, string $type | Supply or replace a scorer. |
quizably_integration_payload | filter | array $payload, array $quiz | Change the outbound payload. |
quizably_integration_async | filter | bool $async | Return false to send webhooks inline instead of through WP-Cron (default true). |
quizably_doi_email | filter | array $mail, array $lead, ?array $quiz | Change the double opt-in confirmation email. |
quizably_integrations | filter | array $list | Change the list returned by GET /integrations. |
quizably_templates | filter | array $list | Change the list returned by GET /templates. |
quizably_rate_limit_per_minute | filter | int $limit | Per-minute request cap (default 60). |
quizably_rate_limit_per_hour | filter | int $limit | Per-hour request cap (default 300). |
quizably_rate_limit_ip | filter | string $ip | Override the IP used for rate limiting and hashing. |
Actions
quizably_booted
do_action( 'quizably_booted', \Quizably\Core\Plugin $plugin );
Fires once from Plugin::boot() on plugins_loaded (priority 10), after all services are registered, including the integration dispatcher. Because the dispatcher has already run quizably_register_integrations by then, do not use this hook to add a callback for that action. You can still call Registry::register() directly from here (see below).
quizably_register_integrations
do_action( 'quizably_register_integrations' );
Fires inside Dispatcher::register() during boot, before the dispatcher attaches its own listeners. Add your callback while your plugin file loads, or on plugins_loaded at a priority lower than 10, so it is in place before Quizably boots. Inside the callback call \Quizably\Integration\Registry::register( $integration ). See Custom integration.
quizably_submission_completed
do_action( 'quizably_submission_completed', string $uuid, array $quiz, ?array $result, array $score );
| Argument | Type | Contents |
|---|---|---|
$uuid | string | Submission UUID (36 characters). |
$quiz | array | Raw quiz row: id, uuid, title, slug, type, status, template, settings (JSON string), design (JSON string), view_count, author_id, created_at, updated_at. |
$result | array or null | The matched result, as served to the browser: id, quiz_id, title, content, image_url, cta_label, cta_url, redirect_url, score_min, score_max, settings (decoded array), position. The conditions key is removed. null when no result matched. |
$score | array | score (int or null), result_id (int or null), breakdown (array). |
Fires once per submission, immediately after the scorer ran and the row was marked completed. Calling the complete endpoint again for the same submission returns the stored result and does not fire the action again. There is no return value.
Notes:
- The visitor’s lead is not passed as an argument. If a form was submitted earlier, the lead is linked on the submission row (
lead_id). The built-in webhook payload for this event includes that lead (withdouble_optin_verified) when there is one, andlead: nullwhen the visitor skipped the form. - Required questions are not re-validated on the server before this fires.
- Quizably’s own listeners use priority 10 (webhook dispatcher) and 20 (owner email).
quizably_lead_captured
do_action( 'quizably_lead_captured', int $lead_id, array $sub, ?\WP_REST_Request $req );
| Argument | Type | Contents |
|---|---|---|
$lead_id | int | ID in {prefix}quizably_leads. |
$sub | array | When fired from the form submission: the submission row as loaded before the lead was attached, so lead_id is still empty in this array. When fired from a double opt-in confirmation: the lead’s latest submission. Scoring fields are empty when the form comes before the result. |
$req | WP_REST_Request or null | The incoming POST /public/submissions/{uuid}/optin request on a normal capture. Read email, name, phone, extra_fields, consent_gdpr or consent from it. It is null when the action fires because a visitor confirmed their email, so read the lead from $lead_id instead. |
Behavior to plan for:
- It fires on every form submission, including repeat submissions with the same email for the same quiz (the lead row is updated,
$lead_idis unchanged). Deduplicate on your side if you need one event per person. - On a quiz without double opt-in it fires right after the lead is saved. On a quiz with double opt-in it does not fire at submit time. It fires once the visitor clicks the confirmation link, and not at all if they never click. A lead that already confirmed on that quiz is captured again at once, with no new email. If the lead has no submission when it confirms, the action does not fire.
- A rejected form submission (invalid email, rate limit) never reaches this hook.
- Request values on
$reqare the raw input. Only the stored lead row has been sanitized. Sanitize before use.
quizably_lead_confirmed
do_action( 'quizably_lead_confirmed', int $lead_id, array $lead );
Fires once, the first time a visitor opens their confirmation link and the lead moves from pending to confirmed. $lead is the lead row as it was just before the update, so double_optin_verified is still 0 in it. Repeat clicks do not fire it again. Right after it, Quizably fires quizably_lead_captured for the same lead, so the webhook and the owner’s new-lead email go out. See Double opt-in.
quizably_integration_dispatched
do_action( 'quizably_integration_dispatched', string $key, array $res, array $payload, int $attempt );
Fires after every dispatch attempt of a registered integration, including retries. $res is the integration’s return value: status (sent, failed or retry), response (string or null), error (string or null). $attempt is 0 for the first try and 1 to 3 for retries. $key is the registry key (for example webhook). Use it for your own delivery log. The free plugin does not write a delivery log or a lead sync status itself.
Internal cron events
quizably_integration_deliver is the WP-Cron hook for the first delivery attempt and quizably_integration_retry is the one for retries (arguments $key, $payload, $config). Both are internal. Do not schedule or unhook them.
Filters
quizably_scorer
$scorer = apply_filters( 'quizably_scorer', ?ScorerInterface $scorer, string $type );
Called from Quizably\Scoring\Registry::get() when a submission is completed. $scorer is the built-in scorer for personality, trivia, survey and poll, or null for any other $type. Return an object implementing Quizably\Scoring\ScorerInterface. Anything else is treated as “no scorer”, and the complete endpoint answers HTTP 500 with code quizably_no_scorer.
The interface has one method:
public function score( array $quiz, array $questions, array $results, array $answers ): array;
| Parameter | Shape |
|---|---|
$quiz | Full quiz row, including type. |
$questions | Question rows, each with a nested answers array of answer rows (including is_correct, points, weights, personality_result_id). |
$results | Result rows in position, id order. JSON columns are strings. |
$answers | The visitor’s saved answers: [{ question_id, answer_ids[], text_value, time_spent_ms, elapsed_ms?, answer_order? }]. |
Return an array with all three keys. The caller reads them directly:
return [
'result_id' => 12, // int or null
'score' => 7, // int or null; stored in an INT column
'breakdown' => [ 'any' => 'json-serializable data' ],
];
quizably_integration_payload
$payload = apply_filters( 'quizably_integration_payload', array $payload, array $quiz );
Runs once per event before the payload is handed to every enabled integration, including the built-in webhook. The default keys are quiz_uuid, quiz_title, quiz_type, submission_uuid, result ({id, title} or null), score, breakdown, lead ({id, email, name, phone, double_optin_verified} or null) and timestamp (ISO 8601, UTC). The payload also has an event key, which is lead_captured or submission_completed. Use $payload['event'] to tell the two apart. double_optin_verified is true for a confirmed lead, false for a pending one and null when the quiz does not use double opt-in. Return an array. Removing keys or adding your own is allowed, but receivers that expect the default shape may break.
quizably_integration_async
$async = apply_filters( 'quizably_integration_async', bool $async ); // default true
By default each webhook delivery is queued as a single WP-Cron event that runs immediately, and Quizably asks WordPress to spawn cron so it goes out within moments. The visitor never waits for your endpoint. Return false to make Quizably send the request inside the visitor’s own request instead, with the 8 second timeout. Quizably also sends inline if the event cannot be scheduled. Sites that set DISABLE_WP_CRON need a system cron that calls wp-cron.php, or queued webhooks are not delivered.
add_filter( 'quizably_integration_async', '__return_false' );
quizably_doi_email
$mail = apply_filters( 'quizably_doi_email', array $mail, array $lead, ?array $quiz );
Runs just before the double opt-in confirmation email is sent. $mail has two keys, subject and message. The default subject is “Confirm your subscription” and the default message is plain text: “Please click the link below to confirm your subscription:” followed by the link. Keep the link in your message, because it is the only way the lead can confirm. $lead is the lead row and $quiz the quiz row (or null).
add_filter( 'quizably_doi_email', function ( array $mail, array $lead, ?array $quiz ) {
$mail['subject'] = 'One click to get your quiz results by email';
// Keep the confirmation link, which is the last line of the default message.
$link = trim( (string) substr( (string) strrchr( $mail['message'], "\n" ), 1 ) );
$mail['message'] = "Please confirm your email address:\n\n" . $link;
return $mail;
}, 10, 3 );
quizably_integrations
$list = apply_filters( 'quizably_integrations', array $list );
Filters the items returned by GET /quizably/v1/integrations, which feeds the admin Integrations screen. Each item has a key, a label and a configured flag, plus other display keys the admin reads. Adding an item lists it. It does not register dispatch behavior, and the free admin UI is built around the built-in webhook, so do not expect a configuration form for a custom key to appear.
quizably_templates
$list = apply_filters( 'quizably_templates', array $list );
Filters the items returned by GET /quizably/v1/templates, which fills the template picker in the Design tab. Each item has a key, a label and a thumbnail, plus other display keys the admin reads. To make a new key render, also register a Vue component for it in JavaScript with registerTemplate(). See Front-end JavaScript API.
Rate limit filters
apply_filters( 'quizably_rate_limit_per_minute', int $limit ); // default 60
apply_filters( 'quizably_rate_limit_per_hour', int $limit ); // default 300
apply_filters( 'quizably_rate_limit_ip', string $ip ); // default REMOTE_ADDR
The limits are cast to int and apply per hashed IP across the public routes. quizably_rate_limit_ip receives REMOTE_ADDR (or 0.0.0.0 when absent) before it is salted and hashed. Quizably deliberately ignores X-Forwarded-For and similar headers. If the site sits behind a trusted proxy that does not rewrite REMOTE_ADDR, resolve the real client address yourself and return it here. See Capabilities, permissions and security.
Examples
Send a Slack message on a new lead
The callback runs in the visitor’s request, so use a non-blocking request with a short timeout. It reads the email from the lead row, because $req is null when the action fires after a double opt-in confirmation.
add_action( 'quizably_lead_captured', function ( int $lead_id, array $sub, ?WP_REST_Request $req ) {
global $wpdb;
$email = (string) $wpdb->get_var(
$wpdb->prepare( "SELECT email FROM {$wpdb->prefix}quizably_leads WHERE id = %d", $lead_id )
);
if ( ! is_email( $email ) ) {
return;
}
// Quizably fires this on every form submit, so dedupe repeats for 1 hour.
$key = 'myext_lead_' . $lead_id;
if ( get_transient( $key ) ) {
return;
}
set_transient( $key, 1, HOUR_IN_SECONDS );
wp_remote_post( 'https://hooks.slack.com/services/T000/B000/XXXX', [
'timeout' => 3,
'blocking' => false,
'headers' => [ 'Content-Type' => 'application/json' ],
'body' => wp_json_encode( [
'text' => sprintf( 'New quiz lead: %s (submission %s)', $email, $sub['uuid'] ?? '' ),
] ),
] );
}, 10, 3 );
Custom scorer for a custom quiz type
The quiz row must carry your type string. PUT /quizably/v1/quizzes/{id} with {"type":"nps"} stores it, since the server does not validate the value. The free admin builder only knows the built-in types, so treat custom types as an API level feature.
use Quizably\Scoring\ScorerInterface;
final class My_Nps_Scorer implements ScorerInterface {
public function score( array $quiz, array $questions, array $results, array $answers ): array {
$total = 0;
$count = 0;
foreach ( $answers as $a ) {
if ( isset( $a['text_value'] ) && is_numeric( $a['text_value'] ) ) {
$total += (int) $a['text_value'];
$count++;
}
}
$avg = $count ? (int) round( $total / $count ) : null;
// Pick the first result whose [score_min, score_max] contains the score.
$result_id = null;
foreach ( $results as $r ) {
$min = $r['score_min'];
$max = $r['score_max'];
if ( null !== $avg && ( null === $min || $avg >= (int) $min ) && ( null === $max || $avg <= (int) $max ) ) {
$result_id = (int) $r['id'];
break;
}
}
return [
'result_id' => $result_id,
'score' => $avg,
'breakdown' => [ 'rated_questions' => $count ],
];
}
}
add_filter( 'quizably_scorer', function ( $scorer, string $type ) {
return 'nps' === $type ? new My_Nps_Scorer() : $scorer;
}, 10, 2 );
Custom integration
An integration implements Quizably\Integration\IntegrationInterface (key(): string and dispatch( array $payload, array $config ): array). Extending AbstractIntegration gives you three helpers that build the return array: ok( string $response = '' ), fail( string $error ) and retry( string $error ). Returning retry schedules up to three WP-Cron retries (60, 120 and 240 seconds later).
use Quizably\Integration\AbstractIntegration;
use Quizably\Integration\Registry;
final class My_Crm_Integration extends AbstractIntegration {
public function key(): string {
return 'mycrm';
}
public function dispatch( array $payload, array $config ): array {
$resp = wp_remote_post( (string) ( $config['url'] ?? '' ), [
'timeout' => 5,
'headers' => [ 'Content-Type' => 'application/json' ],
'body' => wp_json_encode( $payload ),
] );
if ( is_wp_error( $resp ) ) {
return $this->retry( $resp->get_error_message() );
}
$code = (int) wp_remote_retrieve_response_code( $resp );
if ( $code >= 200 && $code < 300 ) {
return $this->ok();
}
return $code >= 500 ? $this->retry( 'HTTP ' . $code ) : $this->fail( 'HTTP ' . $code );
}
}
add_action( 'quizably_register_integrations', function () {
Registry::register( new My_Crm_Integration() );
} );
Registration alone does not make it run. The dispatcher calls an integration only when the quiz’s settings contain an enabled entry under its key:
{ "integrations": { "mycrm": { "enabled": true, "url": "https://crm.example.com/hook" } } }
Quizably calls dispatch() with that object as $config. The free builder only edits the webhook entry, so write yours through the REST API. PUT /quizzes/{id} replaces the whole settings column, so read the quiz first, merge your key into the existing settings, and send the full object back. See REST API reference.
If you do not need retries, per-quiz switches or the registry, the simplest option is a plain add_action( 'quizably_submission_completed', ... ) callback.
Built-in listeners
- The webhook integration and the owner email notifications already listen on both actions. See Webhooks and Email notifications.
- The webhook is queued in WP-Cron and sent in the background (see
quizably_integration_async). It fires once per event, so one visit with a form produces up to two payloads:lead_capturedandsubmission_completed. The completed one carries the lead, so you can act on that one alone. Join them onsubmission_uuidif you use both. - There is no pre-save filter for answers, leads or results.
Related
Last updated October 4, 2026.