Skip to main content
Flows can expose WordPress AJAX endpoints for real-time UI interactions: calculating discounts, fetching dependent field options, validating documents, and so on. AbstractFlow takes care of registration and provides helpers for nonces, sanitization, and JSON responses.

Registering endpoints

Return a map of action names to method names from get_ajax_endpoints():
public function get_ajax_endpoints(): array
{
    return [
        'my_flow_calculate_price' => 'ajax_calculate_price',
        'my_flow_get_options'     => 'ajax_get_options',
    ];
}
During init(), AbstractFlow registers both the authenticated and unauthenticated WordPress AJAX hooks for every entry:
foreach ($this->get_ajax_endpoints() as $action => $callback) {
    add_action("wp_ajax_{$action}",        [$this, $callback]);
    add_action("wp_ajax_nopriv_{$action}", [$this, $callback]);
}
This means both logged-in and guest users can reach your endpoint at admin-ajax.php?action=my_flow_calculate_price.
If you want an endpoint to require authentication, check is_user_logged_in() inside the handler and return an error if the user is a guest.

Nonce creation and verification

AbstractFlow provides two nonce helpers that scope nonces to your flow ID automatically:
/** Create a nonce string. action is prefixed with your flow's get_id(). */
protected function create_nonce(string $action): string
{
    return wp_create_nonce($this->get_id() . '_' . $action);
}

/** Verify a nonce string. Returns true if valid. */
protected function verify_nonce(string $nonce, string $action): bool
{
    return wp_verify_nonce($nonce, $this->get_id() . '_' . $action);
}
Pass the nonce to the frontend via wp_localize_script():
public function enqueue_assets(): void
{
    // (enqueue your script first)
    wp_localize_script('my-flow-js', 'MY_FLOW', [
        'ajax_url' => admin_url('admin-ajax.php'),
        'nonce'    => $this->create_nonce('main'),
    ]);
}
Verify it at the top of every handler:
public function ajax_calculate_price(): void
{
    if (!$this->verify_nonce($_POST['nonce'] ?? '', 'main')) {
        $this->ajax_error('Session expired or invalid request.', 'security_check_failed', 403);
        return;
    }
    // ... handler logic
}

Response helpers

AbstractFlow wraps wp_send_json_success() and wp_send_json_error():
/** Send a successful JSON response. Terminates execution. */
protected function ajax_success($data = []): void
{
    wp_send_json_success($data);
}

/**
 * Send an error JSON response.
 *
 * @param string $message  Human-readable error description.
 * @param mixed  $data     Additional data or error code string.
 * @param int    $status_code HTTP status code (default 400).
 */
protected function ajax_error(string $message, $data = [], int $status_code = 400): void
{
    $response = is_array($data)
        ? array_merge(['message' => $message], $data)
        : ['message' => $message, 'code' => $data];
    wp_send_json_error($response, $status_code);
}

Sanitization helpers

Use these inherited methods to clean user input before processing or storing it:
/** Sanitize a generic text field (strips tags, trims whitespace, unslashes). */
protected function sanitize_text($value): string
{
    return sanitize_text_field(wp_unslash((string) $value));
}

/** Sanitize an email address. */
protected function sanitize_email_field($value): string
{
    return sanitize_email(wp_unslash((string) $value));
}

/** Cast to integer — safe for IDs and numeric values. */
protected function sanitize_int($value): int
{
    return (int) $value;
}

Real example: ajax_calculate_discount from CEPFlow

The following is the actual AJAX handler in CEPFlow that validates a student’s identity and returns a discount tier. It illustrates all the patterns above.
/**
 * AJAX: Validate document and calculate discount in one step.
 * Action: cep_calculate_discount
 */
public function ajax_calculate_discount(): void
{
    // 1. Nonce verification — CEPFlow uses check_ajax_referer() directly here.
    if (!check_ajax_referer('utb_cep_nonce', 'nonce', false)) {
        $this->ajax_error(
            'La sesión ha expirado o la petición no es válida.',
            'security_check_failed',
            403
        );
        return;
    }

    // 2. Input validation.
    $programa_codigo = isset($_POST['programa_codigo'])
        ? sanitize_text_field($_POST['programa_codigo'])
        : '';
    $documento = isset($_POST['documento']) ? absint($_POST['documento']) : 0;

    if (empty($programa_codigo) || empty($documento)) {
        $this->ajax_error('Datos incompletos', 'missing_data', 400);
        return;
    }

    $primer_nombre   = isset($_POST['primer_nombre'])   ? sanitize_text_field($_POST['primer_nombre'])   : '';
    $primer_apellido = isset($_POST['primer_apellido']) ? sanitize_text_field($_POST['primer_apellido']) : '';

    // 3. Transient cache to avoid redundant API calls.
    $cache_key = 'cep_disc_' . md5($documento . '|' . $programa_codigo);
    $cached    = get_transient($cache_key);
    if ($cached && is_array($cached)) {
        $cached['cached'] = true;
        $this->ajax_success($cached);
        return;
    }

    // 4. Business logic — external API call.
    $roles_data = $this->roles_validator->get_user_roles($documento);
    if (is_wp_error($roles_data)) {
        $this->ajax_error($roles_data->get_error_message(), 'user_validation_error', 400);
        return;
    }

    $roles_norm = (array) ($roles_data['roles_norm'] ?? []);
    if (empty($roles_norm)) {
        $this->ajax_error('No se encontraron roles activos para el documento.', 'no_roles', 400);
        return;
    }

    $result = $this->discount_calculator->get_discount_for_program($programa_codigo, $roles_norm);
    if (is_wp_error($result)) {
        $this->ajax_error($result->get_error_message(), $result->get_error_code(), 400);
        return;
    }

    // 5. Cache the result and respond.
    set_transient($cache_key, $result, 15 * MINUTE_IN_SECONDS);
    $this->ajax_success($result);
}

Security checklist

Before shipping an AJAX handler, verify each item:

Verify the nonce

Always call $this->verify_nonce() or check_ajax_referer() as the first statement. Return a 403 error immediately if it fails.

Sanitize every input

Use $this->sanitize_text(), $this->sanitize_email_field(), $this->sanitize_int(), or the equivalent WordPress functions on every $_POST value before using it.

Validate before processing

Check that required fields are present and in range before running business logic or queries. Return a descriptive 400 error for invalid input.

Use capability checks where appropriate

If an action should be restricted to logged-in users or administrators, call is_user_logged_in() or current_user_can() and return a 403 error for unauthorized requests.

Cache external API calls

Use WordPress transients to cache results of slow or rate-limited API calls. CEPFlow caches discount lookups for 15 minutes.

Never trust $data for DB queries

Use $wpdb->prepare() for any database query that includes user-supplied values. Never interpolate $_POST values directly into SQL.

Enqueuing assets and passing the nonce to JS

A complete enqueue_assets() override showing how to pass the AJAX URL and nonce to your frontend script:
public function enqueue_assets(): void
{
    if (!function_exists('is_product') || !is_product()) {
        return;
    }

    if (!$this->applies_to_product(get_queried_object_id())) {
        return;
    }

    wp_enqueue_script(
        'my-flow-js',
        plugin_dir_url(__FILE__) . 'assets/my-flow.js',
        ['jquery'],
        '1.0.0',
        true
    );

    wp_localize_script('my-flow-js', 'MY_FLOW', [
        'ajax_url' => admin_url('admin-ajax.php'),
        'nonce'    => $this->create_nonce('main'),
    ]);
}
In your JavaScript, send the nonce with every AJAX request:
jQuery.post(MY_FLOW.ajax_url, {
    action: 'my_flow_calculate_price',
    nonce:  MY_FLOW.nonce,
    price:  selectedPrice,
}, function (response) {
    if (response.success) {
        console.log(response.data);
    }
});

Build docs developers (and LLMs) love