Embedded Analytics Developer Guide (API)
Version 0.4.4 | Last Updated: June 2026
QuantumLayers exposes its chart data, statistical analysis, and AI insights through standard WordPress AJAX endpoints secured with Bearer token authentication. This guide explains how to pull live data from QuantumLayers into any page, without rebuilding the data pipeline.
This guide assumes you already understand QL’s authentication model and have Chart.js set up. If you haven’t read it yet, start with Embedded Analytics Developer Guide for prerequisites, Chart.js setup, and a full walkthrough of all three authentication options with code examples.
Table of Contents
- Authentication Endpoints
- Analytics & Charting Endpoints
- Dataset Management Endpoints
- Scheduled Reports Endpoints
- Monitor Endpoints
- Monitor Webhook & Slack Delivery
- Organization Management Endpoints
- Utility & Connection Endpoints
- QL-Agent Endpoint
- Complete Code Example
- Chart Type Quick Reference
Authentication Endpoints
Every AJAX request below (and every endpoint in the rest of this guide) must carry a Bearer token in the Authorization header. There are three ways to obtain that token — see Authentication in the developer guide for the concepts, trade-offs, and full JWT-minting code samples. This section is the exact contract of every account and token endpoint: the POST parameters each one accepts and every JSON structure it can return.
Response envelopes
The rest of this guide uses the standard WordPress envelope — { "success": true, "data": { … } } on success and { "success": false, "data": { "message": "…" } } on failure. The account endpoints in this section are older and do not all follow it. There are three shapes, and which one you get depends on the endpoint — and, for ql_user_signup, on which error fired:
| Shape | Success | Failure | Used by |
|---|---|---|---|
| Flat | { "success": true, "message": "…", …fields } | { "success": false, "message": "…" } | ql_user_signin, ql_google_login, ql_user_signout, ql_get_profile, ql_update_profile, ql_delete_account, ql_user_signup (success, and errors raised during registration) |
| Wrapped | { "success": true, "data": { …fields } } | { "success": false, "data": { "message": "…" } } | ql_third_party_signin, ql_check_auth, ql_resend_verification, ql_list_api_tokens, ql_create_api_token, ql_revoke_api_token, and every authentication failure |
| Mixed | { "success": true, "data": { "message": "…" } } | { "success": false, "data": { "message": "…" } } | ql_forgot_password, ql_reset_password, ql_user_signup (missing-field and terms errors) |
Because endpoints that call ql_verify_auth() can fail authentication before their own code runs, a flat-shape endpoint such as ql_get_profile can still return a wrapped-shape error. The HTTP status is 200 for every response except where an endpoint below lists another status, so always branch on success rather than the status code. A client that reads both shapes:
function qlMessage(res) { // error or confirmation text, whatever the shape
return (res.data && res.data.message) || res.message || '';
}
function qlPayload(res) { // the fields of a successful response
return res.data !== undefined ? res.data : res;
}
Authentication failures
Every endpoint that requires a token (it says ql_verify_auth() under Auth) can stop before doing any work and return one of these, with HTTP 200:
// No token in the Authorization header or the session_token POST field
{ "success": false, "data": { "message": "Authentication required" } }
// A token was sent but is unknown, expired, revoked, or belongs to a deactivated user
{ "success": false, "data": { "message": "Invalid or expired token" } }
// On-premise installs only, once the license's grace period has passed
{ "success": false, "data": {
"message": "Your on-premise license has expired. Ask your administrator to load a renewed license.",
"code": "license_locked" } }
Token formats and lifetimes
All three token kinds are sent the same way (see Bearer token usage). QL tells them apart by prefix:
| Kind | Format | Issued by | Lifetime |
|---|---|---|---|
| Session token | 64 lowercase hex characters, no prefix | ql_user_signin, ql_google_login, ql_third_party_signin | 24 hours; 30 days when ql_user_signin is called with remember; always 30 days from ql_third_party_signin |
| API token | qlpak_ + 48 hex characters | ql_create_api_token (the API Tokens page) | Until revoked, or expires_days days if set at creation |
| MCP OAuth access token | qloat_ + 64 hex characters | The MCP OAuth authorization server | 1 hour (refreshable); intended for MCP clients |
The user object
The three sign-in endpoints return the same summary of the signed-in account in user:
{
"id": "42", // numeric string (see note below)
"email": "jane@example.com",
"first_name": "Jane",
"last_name": "Doe",
"role": "viewer" // account-level role; every self-service account is "viewer".
// Unrelated to organization roles (member / admin / manager).
}
Value types: values read from the database are returned as JSON strings unless the endpoint says otherwise — IDs arrive as "42", flags as "0" / "1". Parse them (Number(user.id)) rather than comparing with === against a number. The exceptions are called out per endpoint (for example, user.id is an integer in the new-account response of ql_google_login). Timestamps are "YYYY-MM-DD HH:MM:SS" in the server’s timezone, with no offset; nullable fields are null when unset. The full account record is returned by ql_get_profile.
ql_user_signup
Creates an email/password account. The account starts unverified and a verification link is emailed to it; ql_user_signin refuses the account until that link is clicked. Does not return a token.
Auth: none — public endpoint.
| Param | Type | Required | Description |
|---|---|---|---|
email | string | yes | Must be a valid email address not already registered. |
password | string | yes | At least 8 characters. |
first_name | string | yes | |
last_name | string | yes | |
terms_agreed | string | yes | Must be exactly true. |
company | string | no | |
marketing_consent | string | no | true to opt in to marketing email; anything else leaves it off. |
country | string | no | Free text. |
referred_by_code | string | no | A referral code. Falls back to the ql_ref cookie; an unknown code is silently ignored. |
Response — flat on success. Errors come in two shapes depending on where they were raised:
// Success. user_id is an integer here.
{ "success": true,
"message": "Registration successful! Please check your email to verify your account.",
"user_id": 42 }
// Missing required field, terms not agreed, or unexpected server error (mixed shape)
{ "success": false, "data": { "message": "All fields except company are required." } }
{ "success": false, "data": { "message": "You must agree to the terms and conditions to create an account." } }
// Validation during registration (flat shape). message is one of:
// "Invalid email address" | "Email already registered"
// "Password must be at least 8 characters" | "Registration failed: <database error>"
{ "success": false, "message": "Email already registered" }
ql_user_signin
Email/password sign-in. Verifies the credentials, requires the account’s email to already be verified, and returns a session token. Public endpoint — callable from any origin, not just quantumlayers.com.
Auth: none — this endpoint issues the token.
| Param | Type | Required | Description |
|---|---|---|---|
email | string | yes | |
password | string | yes | |
remember | any | no | Extends the session from 24 hours to 30 days. Presence-based: sending the field with any value — including 0 or false — turns it on. Omit the field entirely for a 24-hour session. |
curl -s -X POST https://quantumlayers.com/wp-admin/admin-ajax.php \
-d "action=ql_user_signin" \
-d "email=jane@example.com" \
-d "password=your-password"
Response — flat shape. Use session_token as the Bearer token on all subsequent requests.
{
"success": true,
"message": "Login successful",
"session_token": "3f9a0c…", // 64 hex characters
"user": { "id": "42", "email": "jane@example.com", "first_name": "Jane",
"last_name": "Doe", "role": "viewer" } // see "The user object"
}
// Unknown email, wrong password, or deactivated account
{ "success": false, "message": "Invalid email or password" }
// The account signs in another way; <Provider> is "Google" or "Third_party"
{ "success": false, "message": "Please login with <Provider>" }
// Email not verified yet. The verification link is re-sent automatically (at most once a minute per address).
{ "success": false,
"message": "Please verify your email address before logging in. Check your inbox for the verification link.",
"require_verification": true,
"email": "jane@example.com" }
ql_google_login
Exchanges a Google Identity Services credential (ID token) for a QL session. If an active account with the Google email exists it is signed in; otherwise a new account is created with role viewer and a verified email. Public endpoint — callable from any origin.
Side effect on existing accounts: if the matching account doesn’t already use Google sign-in (an email/password or third-party account), it is converted to Google sign-in (auth_provider becomes google, the avatar is replaced with the Google picture, and the email is marked verified). From then on ql_user_signin rejects it with Please login with Google.
Auth: none — this endpoint issues the token.
| Param | Type | Required | Description |
|---|---|---|---|
credential | string | yes | The ID token returned by Google Identity Services. Its audience must be QL’s Google client ID and Google must report the email as verified. |
terms_agreed | string | no | true records terms acceptance. New accounts only. |
marketing_consent | string | no | true to opt in. New accounts only. |
country | string | no | New accounts only. |
referred_by_code | string | no | As for ql_user_signup. New accounts only. |
curl -s -X POST https://quantumlayers.com/wp-admin/admin-ajax.php \
-d "action=ql_google_login" \
--data-urlencode "credential=$GOOGLE_ID_TOKEN"
Response — flat shape. Both success responses carry session_token; use new_user to tell them apart:
// Existing account
{
"success": true,
"message": "Login successful",
"session_token": "3f9a0c…",
"user": { "id": "42", "email": "jane@example.com", "first_name": "Jane",
"last_name": "Doe", "role": "viewer" },
"new_user": false
}
// New account — note user.id is an integer here
{
"success": true,
"message": "Account created and logged in successfully",
"session_token": "3f9a0c…",
"user": { "id": 43, "email": "sam@example.com", "first_name": "Sam",
"last_name": "Lee", "role": "viewer" },
"new_user": true
}
// Failures. message is one of:
// "No credential provided" | "Google Sign-In not configured"
// "Invalid Google token" (bad token, wrong audience, or email not verified by Google)
// "Failed to create account" | "Server error: <detail>"
{ "success": false, "message": "Invalid Google token" }
ql_third_party_signin
Exchanges an HS256 JWT signed by your backend for a QL session token. Finds or creates a shadow user keyed on provider_id + sub; email and name are updated from the JWT on every sign-in.
Auth: none — this endpoint issues the token.
| Param | Type | Required | Description |
|---|---|---|---|
jwt | string | yes | The signed JWT (structure below). |
JWT structure:
| Part | Field | Required | Description |
|---|---|---|---|
| Header | alg | yes | HS256. The signature is HMAC-SHA256 with your provider’s jwt_secret. |
| Header | kid | yes | Your registered provider_id (the id column in ql_auth_providers). The provider must be active. |
| Payload | sub | yes | Stable, unique user ID in your system. |
| Payload | email | yes | The user’s email. |
| Payload | exp | yes | Expiry as a Unix timestamp. A JWT without it is rejected as expired. |
| Payload | first_name | no | |
| Payload | last_name | no |
curl -s -X POST https://quantumlayers.com/wp-admin/admin-ajax.php \
-d "action=ql_third_party_signin" \
--data-urlencode "jwt=$SIGNED_JWT"
Response — wrapped shape, so the token is at res.data.session_token. The session always lasts 30 days. Errors carry an HTTP status:
{
"success": true,
"data": {
"session_token": "3f9a0c…",
"user": { "id": "57", "email": "jane@example.com", "first_name": "Jane",
"last_name": "Doe", "role": "viewer" }
}
}
{ "success": false, "data": { "message": "JWT required" } } // HTTP 400
{ "success": false, "data": { "message": "JWT header must contain kid (provider id)" } } // HTTP 400
{ "success": false, "data": { "message": "Unknown or inactive provider" } } // HTTP 401
{ "success": false, "data": { "message": "Invalid or expired JWT" } } // HTTP 401 — bad signature, malformed, or missing/past exp
{ "success": false, "data": { "message": "JWT must contain sub and email" } } // HTTP 400
{ "success": false, "data": { "message": "Failed to authenticate user" } } // HTTP 500
ql_user_signout
Signs the caller out: the session token sent with the request (as a Bearer header or session_token field) is expired immediately, and the server-side PHP session and ql_remember cookie are cleared. No parameters. Always succeeds, including when no valid token is sent.
Auth: none.
{ "success": true, "message": "Logged out successfully" }
API tokens (qlpak_) and MCP OAuth tokens (qloat_) are not affected by signing out — revoke API tokens with ql_revoke_api_token. Clients should still discard the session token locally (for example with QLAuth.clearSessionToken()).
ql_check_auth
Returns a summary of the account the token belongs to. No parameters.
Auth: ql_verify_auth().
{
"success": true,
"data": {
"logged_in": true,
"user": {
"id": "42",
"email": "jane@example.com",
"first_name": "Jane",
"last_name": "Doe",
"avatar": "https://lh3.googleusercontent.com/…" // null unless set by Google sign-in
}
}
}
Note the key is avatar here but avatar_url in ql_get_profile. When the token is missing or invalid this endpoint returns an authentication failure (success: false), not logged_in: false — treat success: false as signed out.
const res = await _ajax({ action: 'ql_check_auth' });
if (res.success && res.data.logged_in) {
const { id, email, first_name, last_name, avatar } = res.data.user;
} else {
// token is missing or expired — redirect to login
}
ql_resend_verification
Re-sends the verification email for an unverified account. Sends at most once a minute per address (a sign-in attempt on the unverified account counts too); the response is the same whether or not a mail was sent, so it cannot be used to discover accounts.
Auth: none — public endpoint (an unverified account cannot sign in, so it has no token).
| Param | Type | Required | Description |
|---|---|---|---|
email | string | yes | The unverified account’s email. |
{ "success": true,
"data": { "message": "If an unverified account exists for this email, a new verification link has been sent. Please check your inbox." } }
// Only when email is empty
{ "success": false, "data": { "message": "Email address is required" } }
ql_forgot_password
Emails a password-reset link valid for 1 hour. The link points to /ql-reset-password?key=<key>&email=<email>; pass both values to ql_reset_password. The response is identical whether or not an account exists, so it cannot be used to discover registered emails.
Auth: none — public endpoint.
| Param | Type | Required | Description |
|---|---|---|---|
email | string | yes |
{ "success": true,
"data": { "message": "If an account exists with this email, you will receive a password reset link shortly." } }
// data.message is one of:
// "Email is required" | "Invalid email address"
// "Failed to send reset email. Please try again later."
{ "success": false, "data": { "message": "Invalid email address" } }
ql_reset_password
Sets a new password using the key from the reset email. The key is single-use. Does not sign the user in or return a token, and does not end existing sessions.
Auth: none — public endpoint.
| Param | Type | Required | Description |
|---|---|---|---|
email | string | yes | The email value from the reset link. |
key | string | yes | The key value from the reset link. |
password | string | yes | The new password, at least 8 characters. |
{ "success": true, "data": { "message": "Password reset successful! Redirecting to login..." } }
// data.message is one of:
// "All fields are required" | "Password must be at least 8 characters"
// "Invalid reset link" | "Invalid or expired reset link"
// "Reset link has expired. Please request a new one."
// "Failed to reset password. Please try again."
{ "success": false, "data": { "message": "Reset link has expired. Please request a new one." } }
ql_get_profile
Returns the full account record of the token’s user. No parameters.
Auth: ql_verify_auth().
Response — flat shape; the record is at res.user, not res.data.user:
{
"success": true,
"user": {
"id": "42",
"email": "jane@example.com",
"first_name": "Jane",
"last_name": "Doe",
"company": "Acme" , // string or null
"role": "viewer",
"auth_provider": "email", // "email" | "google" | "third_party"
"email_verified": "1", // string "0" or "1"
"avatar_url": null, // string (Google accounts) or null
"created_at": "2026-01-15 09:30:00",
"last_login": "2026-09-27 18:02:11", // string or null
"marketing_consent": false, // boolean
"marketing_consent_at": null, // when consent was last given; string or null
"reengagement_opt_out": false, // boolean — true = no automatic reminder emails
"reengagement_opt_out_at": null, // string or null
"country": "CA", // string or null
"ui_mode": "dark" // "dark" | "light"; "light" when never set
}
}
{ "success": false, "message": "Failed to load profile data" }
ql_update_profile
Updates any subset of the account’s profile fields. Every parameter is optional, but at least one must be sent. All fields are validated before anything is saved, so a failed call changes nothing.
Auth: ql_verify_auth().
| Param | Type | Required | Description |
|---|---|---|---|
first_name | string | no | |
last_name | string | no | |
company | string | no | |
country | string | no | |
marketing_consent | string | no | true opts in; any other value opts out. |
reengagement_opt_out | string | no | true stops automatic reminder emails; any other value resumes them. |
ui_mode | string | no | dark or light. |
email | string | no | A new email address. Must not belong to another account. Changing it marks the account unverified and emails a verification link to the new address; existing tokens keep working, but ql_user_signin refuses the account until the new address is verified. |
password | string | no | A new password, at least 8 characters. Only for accounts whose auth_provider is email. |
Response — flat shape. There are two success responses:
// Email unchanged. user is a subset of ql_get_profile's record
// (no email_verified, avatar_url, created_at or last_login).
{
"success": true,
"message": "Profile updated successfully!",
"user": {
"id": "42", "email": "jane@example.com", "first_name": "Jane", "last_name": "Doe",
"company": "Acme", "role": "viewer", "auth_provider": "email",
"marketing_consent": true, "marketing_consent_at": "2026-09-28 10:04:51",
"reengagement_opt_out": false, "reengagement_opt_out_at": null,
"country": "CA", "ui_mode": "dark"
}
}
// Email changed. No user object is returned.
{
"success": true,
"message": "Profile updated successfully! Please check your new email address to verify it.",
"email_changed": true,
"verification_required": true
}
// Failures. message is one of:
// "Invalid UI mode. Must be either \"dark\" or \"light\"."
// "Invalid email address format" | "This email address is already registered"
// "Password must be at least 8 characters long"
// "Cannot change password for <Provider> authenticated accounts"
// "No data provided to update" | "Failed to update profile. Please try again."
// "Server error: <detail>"
{ "success": false, "message": "No data provided to update" }
ql_delete_account
Permanently deletes the account and its data. This cannot be undone.
Auth: ql_verify_auth().
| Param | Type | Required | Description |
|---|---|---|---|
password | string | for email accounts | The account’s current password. |
confirm_email | string | for google / third_party accounts | The account’s email address, retyped (case-insensitive). These accounts have no password to confirm with. |
{ "success": true,
"message": "Your account and its data have been permanently deleted. Billing and commission records are retained where we are legally required to keep them." }
// message is one of:
// "Please enter your password to confirm account deletion" (email account, no password sent)
// "Incorrect password"
// "Please enter your account email address to confirm account deletion" (Google/third-party, missing or wrong confirm_email)
// "Failed to delete account. Please try again or contact support."
{ "success": false, "message": "Incorrect password" }
ql_list_api_tokens
Lists the caller’s API tokens, newest first. No parameters. Token values are never returned — only their metadata.
Auth: ql_verify_auth().
{
"success": true,
"data": {
"tokens": [
{
"id": "3", // numeric string
"name": "Nightly ETL",
"last_used_at": "2026-09-27 02:00:14", // null if never used
"expires_at": null, // null = never expires
"created_at": "2026-08-01 14:22:09"
}
]
}
}
ql_create_api_token
Creates a long-lived API token. The raw token is returned only in this response — QL stores only its SHA-256 hash, so it cannot be shown again.
Auth: ql_verify_auth().
| Param | Type | Required | Description |
|---|---|---|---|
name | string | yes | A label for the token. |
expires_days | int | no | Days until expiry. Omit or send 0 for a token that never expires. |
{
"success": true,
"data": {
"id": 3, // integer here
"name": "Nightly ETL",
"token": "qlpak_9f2b…", // qlpak_ + 48 hex characters
"expires_at": "2026-12-27 10:15:00", // null when expires_days is 0 or omitted
"created_at": "2026-09-28 10:15:00"
}
}
{ "success": false, "data": { "message": "Token name is required" } } // HTTP 400
{ "success": false, "data": { "message": "Failed to create token" } } // HTTP 500
ql_revoke_api_token
Deletes one of the caller’s API tokens. Takes effect immediately.
Auth: ql_verify_auth().
| Param | Type | Required | Description |
|---|---|---|---|
token_id | int | yes | The token’s id. |
{ "success": true, "data": { "message": "Token revoked" } }
{ "success": false, "data": { "message": "token_id is required" } } // HTTP 400
{ "success": false, "data": { "message": "Token not found" } } // HTTP 404 — also when it belongs to another user
Bearer token usage (session token or API token)
Every token kind is used identically — as an Authorization: Bearer <token> header on every request:
curl -s -X POST https://quantumlayers.com/wp-admin/admin-ajax.php \
-H "Authorization: Bearer $TOKEN" \
-d "action=ql_get_dashboard_data"
If a proxy or server configuration strips the Authorization header, send the token as a session_token POST field instead; it is only read when the header is absent.
Note: ql_try_auth() allows unauthenticated access for public datasets; ql_verify_auth() requires a valid token and is used by all write and analysis endpoints — each endpoint below states which one it uses.
Analytics & Charting Endpoints
All analytics endpoints are reached via POST to https://quantumlayers.com/wp-admin/admin-ajax.php with the action field set as shown.
Dataset Metadata
ql_get_dataset_detail
Queries the ql_datasets table for the dataset record and ql_dataset_columns for its column schema. Access is checked via ql_user_can_read_dataset(), which allows access to datasets owned by the user or shared with their organisation. The column rows include pre-computed statistics (min, max, mean, stddev, distinct count, null count) calculated at upload time — these are the cheapest way to understand a dataset without loading raw rows.
Auth: ql_verify_auth() — valid Bearer token required.
POST params:
dataset_id(int, required)
Response:
{
"dataset": {
"id": 42,
"name": "sales_2024",
"row_count": 15000,
"created_at": "2024-01-15 10:30:00"
// ... full ql_datasets row
},
"columns": [
{
"col_name": "product_category",
"inferred_type": "string", // string | integer | float | date | datetime
"col_index": 0,
"distinct_count": 8,
"null_count": 0,
"min_val": null,
"max_val": null,
"mean_val": null,
"stddev_val": null
}
// ... one object per column, ordered by col_index
]
}
Chart Data
ql_get_chart_data
Routes to a private method based on chart_type, then returns a complete Chart.js config object (type + data + options) ready to pass to new Chart(ctx, data). All data is loaded from the raw dataset rows up to the plan row limit. Filters are applied in PHP before aggregation. Below is what each chart type actually does internally:
- histogram — validates the column is numeric, loads raw values, auto-computes 20 equal-width bins using Sturges-style bin width (
(max−min)/20), returns abarchart config with bin ranges as labels and frequency counts as data. - time_series / stacked_area — parses every value in
time_columnviastrtotime(), sorts points chronologically, then optionally aggregates into calendar buckets (interval: day/week/month/year) using sum/avg/count/min/max. Ifcumulative=1, a running total is applied per series after aggregation. Gaps (null Y values) are preserved; Chart.js is instructed not to interpolate across them (spanGaps: false). Returns alinechart config;stacked_areasetsfill: trueon each dataset. - bar / horizontal_bar — validates that
category_columnis either a string column or a numeric column with fewer distinct values than 10% of row count (i.e. categorical). Groups all rows by category, applies the chosen aggregation (sum/avg/count/min/max) across eachvalue_columnsentry, and returns abarconfig (indexAxis: 'y'for horizontal). - pie / doughnut — groups by
category_column, aggregatesvalue_column, sorts descending, and trims tolimitcategories (default 10). Returns apieordoughnutconfig with auto-assigned colours. - scatter — loads raw
x_columnandy_columnvalues as{x, y}point pairs. Ifcolor_columnis provided, points are split into one dataset per distinct category value. Ifsize_columnis provided, the point radius is scaled proportionally to the column value. - bubble — identical to scatter but always requires
size_column; the radius (r) of each bubble is normalised to a 5–30 px range across the column’s min/max. - box_plot / violin — groups raw
y_columnvalues by the categories inx_column. Returns data in the format expected by@sgratzl/chartjs-chart-boxplot: arrays of raw values per group, from which the library computes quartiles, whiskers, and outlier dots client-side. - heatmap — aggregates
z_columnfor every unique(x_column, y_column)coordinate pair and returns a dataset in the matrix format required bychartjs-chart-matrix: an array of{x, y, v}objects. - regression — delegates to
analytics.performLinearRegression()(OLS, supports multiple predictors). Returns a bar chart of coefficients with error bars (±1 std error), colour-coded by p-value significance.
Auth: ql_try_auth() — token optional for public datasets, required for private.
Common POST params (all chart types):
dataset_id(int, required)chart_type(string, required) — see table belowfilter_category_column(string, optional) — column to filter rows by exact valuefilter_category_value(string, optional) — value to match infilter_category_columnfilter_date_column(string, optional) — date column to range-filterfilter_date_from/filter_date_to(string, optional) — ISO date bounds for the date filter
Type-specific params:
| chart_type | Additional POST params | Defaults |
|---|---|---|
bar | category_column, value_columns[], aggregation | aggregation: sum |
horizontal_bar | category_column, value_columns[], aggregation | aggregation: sum |
pie / doughnut | category_column, value_column, aggregation, limit | aggregation: sum, limit: 10 |
scatter | x_column, y_column, color_column (opt), size_column (opt) | — |
bubble | x_column, y_column, size_column, color_column (opt) | — |
box_plot / violin | x_column (categories), y_column (values) | — |
heatmap | x_column, y_column, z_column, aggregation | aggregation: average |
time_series | time_column, value_columns[], aggregation, interval, cumulative | aggregation: sum, interval: day, cumulative: 0 |
stacked_area | time_column, value_columns[], aggregation, interval | aggregation: none, interval: day |
histogram | column_name | — |
regression | y_column, x_columns[] | — |
Response: A Chart.js config object you can pass directly to new Chart(ctx, data). On error: { success: false, data: { error: "..." } }.
ql_get_recommended_charts
Runs the full rule-based insight pipeline (same as ql_get_insights but without the AI call) and converts each insight into a chart candidate. Candidates are scored by insight_score (0–100), the top max_charts are kept, and actual Chart.js data is generated for each one via generateChartData(). Any candidate whose chart generation returns an error is silently dropped. The result is an opinionated, ranked list of charts that best reveal patterns in the data — useful for auto-populating a dashboard without any user configuration.
Auth: ql_verify_auth() — valid Bearer token required.
POST params:
dataset_id(int, required)max_charts(int, optional, default: 20)selected_columns[](array of strings, optional) — restrict analysis to these columns- Filter params: same
filter_category_column/valueandfilter_date_*asql_get_chart_data
Response:
{
"charts": [
{
"chart_type": "scatter",
"chart_data": { /* Chart.js config — pass directly to new Chart() */ },
"insight_score": 87, // 0–100 relevance ranking
"reason": "Strong correlation (r=0.91) between price and revenue",
"params": { "x_column": "price", "y_column": "revenue" },
"metadata": {}
}
// ... up to max_charts entries, sorted by insight_score desc
],
"analysis_summary": {
"total_columns": 12,
"numeric_columns": 7,
"categorical_columns": 4,
"datetime_columns": 1,
"candidates_evaluated": 43,
"filter_applied": false
}
}
ql_save_chart
Recursively sanitises the chart_params array and serialises it to JSON, then inserts a row into ql_saved_charts (columns: dataset_id, user_id, chart_name, chart_type, chart_params, created_at). The chart_params value is the exact set of type-specific params you would pass to ql_get_chart_data — store the params from a chart you already rendered and you can reconstruct it later without re-running analysis.
Auth: ql_verify_auth() — valid Bearer token required.
POST params:
dataset_id(int, required)chart_type(string, required)chart_params(array, required) — the same type-specific params used inql_get_chart_data, stored as JSONchart_name(string, optional) — human-readable label
Response: { "message": "Chart saved successfully", "chart_id": 17 }
ql_get_saved_charts
Selects all rows from ql_saved_charts where dataset_id and user_id match, ordered newest-first. The chart_params field is a JSON string — parse it with JSON.parse() and pass the result directly to ql_get_chart_data to re-render the chart on demand.
Auth: ql_verify_auth() — valid Bearer token required.
POST params: dataset_id (int, required)
Response:
{
"charts": [
{
"id": 17,
"dataset_id": 42,
"user_id": 5,
"chart_name": "Revenue by Category",
"chart_type": "bar",
"chart_params": "{\"category_column\":\"product_category\",\"value_columns\":[\"revenue\"]}",
"created_at": "2024-03-01 14:22:00"
}
]
}
Note: chart_params is a JSON string. Parse it and pass to ql_get_chart_data to re-render the chart.
ql_delete_saved_chart
Fetches the ql_saved_charts row by chart_id and user_id before deleting — a chart belonging to a different user returns a 404-style error even if the chart_id exists. This ownership check is the only authorisation gate; no dataset access check is performed.
Auth: ql_verify_auth() — valid Bearer token required.
POST params: chart_id (int, required)
Response: { "message": "Chart deleted successfully" }
Statistical Analysis
ql_get_statistical_summary
Iterates every numeric column (inferred_type of integer or float) whose distinct_count exceeds 10% of the dataset’s row count — this threshold filters out effectively categorical numerics like status codes or boolean flags. For each qualifying column, min/max/mean/std are read directly from the pre-computed ql_dataset_columns row (no raw-data load needed). The raw column values are then loaded once to compute median, Q1, Q3, skewness (third standardised moment), and kurtosis (fourth standardised moment minus 3 — i.e. excess kurtosis).
Auth: ql_verify_auth() — valid Bearer token required.
POST params: dataset_id (int, required)
Response: array of per-column objects:
[
{
"column": "revenue",
"type": "float",
"count": 14850, // non-null rows
"missing": 150, // null count
"min": 0.99,
"max": 9999.0,
"mean": 312.45,
"std": 198.72,
"median": 265.10,
"q1": 124.00,
"q3": 478.50,
"skewness": 1.34,
"kurtosis": 2.87
}
]
ql_get_correlation_matrix
Selects all continuous numeric columns (same 10%-distinct threshold as the statistical summary), loads their raw values, then computes the full Pearson r matrix in PHP. The result is cached as a WordPress transient for one hour — the cache key includes the dataset ID, so a second call within the hour returns instantly without reloading data. Requires at least two qualifying columns; returns an error otherwise. The cache is per-dataset (not per-user), so all users sharing a dataset see the same cached matrix.
Auth: ql_verify_auth() — valid Bearer token required.
POST params: dataset_id (int, required)
Response:
{
"columns": ["price", "quantity", "revenue"],
"matrix": [
[1.0, -0.12, 0.91],
[-0.12, 1.0, 0.43],
[0.91, 0.43, 1.0]
],
"method": "pearson"
}
matrix[i][j] is the Pearson r between columns[i] and columns[j]. Values range from −1 to 1.
ql_get_distribution_analysis
Loads all non-null numeric values for the requested column, sorts them, and computes: mean, variance, std, range, IQR, full percentile set (p5/Q1/median/Q3/p95), skewness (third standardised moment), and excess kurtosis. Outliers are detected using the 1.5×IQR fence rule: values below Q1 − 1.5×IQR or above Q3 + 1.5×IQR are flagged. Up to 50 outlier values are returned in the response. Normality is classified as approximately normal (|skewness| < 0.5 and |kurtosis| < 0.5), non-normal (either exceeds 1), or moderately skewed otherwise. The column must be integer or float; string and date columns return an error.
Auth: ql_verify_auth() — valid Bearer token required.
POST params: dataset_id (int, required), column_name (string, required)
Response:
{
"column_name": "revenue",
"count": 14850,
"mean": 312.45,
"std": 198.72,
"variance": 39489.48,
"range": 9998.01,
"iqr": 354.50,
"percentiles": {
"min": 0.99, "p5": 12.50, "q1": 124.00,
"median": 265.10, "q3": 478.50, "p95": 720.00, "max": 9999.00
},
"skewness": 1.34,
"kurtosis": 2.87,
"normality": "non-normal", // "approximately normal" | "moderately skewed" | "non-normal"
"outliers": {
"count": 312,
"percentage": 2.10,
"lower_fence": -407.75,
"upper_fence": 1010.25,
"values": [1200.00, 1350.00] // up to 50 outlier values
}
}
ql_get_pca_analysis
Selects continuous numeric columns (10%-distinct threshold), loads raw values, z-score standardises each column (subtract mean, divide by std), then builds the covariance matrix. Eigenvalues and eigenvectors are computed using the Power Iteration method implemented in PHP. Components are sorted by eigenvalue descending. The transformed data (PC scores) is capped at the first 1,000 rows in the response to limit payload size. Results are cached for one hour keyed by dataset_id + n_components. If n_components is less than 2 or omitted, all components are returned. Requires at least 2 qualifying columns and 10 rows.
Auth: ql_verify_auth() — valid Bearer token required.
POST params:
dataset_id(int, required)n_components(int, optional) — number of components to return; if omitted or less than 2, defaults to the total number of eligible columns
Response:
{
"n_components": 3,
"feature_names": ["price", "quantity", "revenue"],
"explained_variance": [4.21, 1.83, 0.96],
"explained_variance_ratio": [0.60, 0.26, 0.14],
"cumulative_variance": [0.60, 0.86, 1.00],
"total_variance_explained": 1.00,
"components": [[...], [...], [...]], // eigenvectors (loadings)
"transformed_data": [ // PC scores, first 1000 rows
[2.31, -0.84, 0.12],
...
]
}
ql_get_anova_analysis
For every pair of (categorical column × continuous numeric column), runs a one-way ANOVA: groups numeric values by category label, computes Sum of Squares Between (SSB) and Within (SSW), derives the F-statistic (MSB/MSW), and approximates the p-value using an incomplete beta function (no external statistics library). Additionally computes Cohen’s d between the two groups with the largest pairwise mean difference, as a standardised effect-size measure. eta_squared = SSB / (SSB + SSW) gives the proportion of variance explained by the grouping. The significance label is derived from the p-value: not_significant (p ≥ 0.05), moderately_significant (p < 0.05), highly_significant (p < 0.01), or insufficient_data (fewer than 3 rows or fewer than 2 groups).
Auth: ql_verify_auth() — valid Bearer token required.
POST params: dataset_id (int, required)
Response:
{
"categorical_columns": ["region", "product_category"],
"numerical_columns": ["revenue", "quantity"],
"sample_size": 14850,
"anova_matrix": {
"region": {
"revenue": {
"f_statistic": 18.43,
"p_value": 0.000021,
"confidence_level": 0.99998,
"significance": "highly_significant",
// possible values: "insufficient_data" | "not_significant"
// | "moderately_significant" | "highly_significant"
"df_between": 4,
"df_within": 14845,
"groups": 5,
"sample_size": 14850,
"ssb": 12450.3,
"ssw": 98231.7,
"msb": 3112.6,
"msw": 6.62,
"eta_squared": 0.112, // effect size (0–1)
"cohens_d": 0.84, // largest pairwise effect size
"cohens_d_groups": ["North", "South"]
}
}
}
}
AI Insights
ql_get_insights
Two-stage pipeline. Stage 1 — rule-based insights (always runs): nine parallel analyses each produce typed insight objects, which are merged into one list:
- Correlation — flags strong Pearson r relationships (|r| > threshold) between numeric columns.
- ANOVA — flags categorical columns that significantly explain variance in a numeric column (F-test p < 0.05).
- Distribution — flags high skewness, excess kurtosis, and columns with a high outlier rate.
- Temporal — detects monotonic trends and seasonality in date/time columns.
- Regression — runs OLS on numeric column pairs and surfaces strong predictive relationships.
- Categorical — measures distribution entropy per categorical column; flags highly imbalanced or highly uniform distributions.
- Categorical relationships — runs chi-square tests between pairs of categorical columns to detect non-independence.
- Cross-correlation — detects lagged relationships between time series; first runs an ADF stationarity test and differences non-stationary series before computing cross-correlation.
- Multicollinearity (VIF) — computes Variance Inflation Factor for each numeric column; flags predictors with VIF above threshold as redundant.
All insights that carry a p-value in their metadata are then subjected to Benjamini-Hochberg FDR correction at α = 0.05, removing insights that become non-significant after accounting for multiple comparisons. The surviving insights are sorted by importance descending and truncated to max_insights.
Stage 2 — AI holistic analysis (runs only if an Anthropic or OpenAI key is configured and the user’s monthly token budget is not exhausted): compiles a dataset context object (column stats, top rule-based insights, top 5 chart candidates) and sends it to ql_call_llm() with a 4,000-token budget and the system prompt “You are an expert data analyst who provides clear, actionable insights for business users.” If a user_prompt is supplied, it is appended to steer the analysis. The AI response is prepended to the insight array as a holistic_analysis type object with importance: 100. If the token budget is exceeded, a warning insight is prepended instead and the AI call is skipped.
Auth: ql_verify_auth() — valid Bearer token required.
POST params:
dataset_id(int, required)selected_columns[](array, optional) — restrict insights to these columnsmax_insights(int, optional) — capped at the plan limit; defaults to plan maximumuser_prompt(string, optional) — freeform question or instruction passed to the AI for the holistic analysisinclude_ai(bool, optional) — defaults totrue. Set tofalseto skip Stage 2 (AI holistic analysis) entirely and return only the rule-based statistical insights, saving tokens.- Filter params:
filter_category_column,filter_category_value,filter_date_column,filter_date_from,filter_date_to
Response:
{
"insights": [
{
"type": "holistic_analysis", // or "correlation" | "distribution" | "outlier" | etc.
"severity": "info", // "info" | "warning" | "critical"
"importance": 95, // 0–100 sort key
"title": "Holistic Dataset Analysis",
"message": "Revenue is strongly driven by region...",
"ai_analysis": "## Key Findings\n...", // markdown; null for rule-based insights
"recommendation": "Focus acquisition spend on the North region.",
"chart_suggestion": null // chart_type string or null
}
],
"generated_at": "2024-04-25 12:00:00",
"count": 14
}
Note: The first element is the AI holistic insight (if AI is configured and the token budget is not exhausted). Remaining elements are rule-based insights sorted by importance descending.
Dataset Management Endpoints
These endpoints create, update, sync, and delete datasets. Every source type (CSV upload, SQL, REST API, SFTP, Google Sheets, and merged) has its own set of handlers. All write operations use ql_verify_auth() and check dataset ownership via ql_user_can_write_dataset() before acting. The visibility parameter is accepted by all create/update handlers and must be one of private, public, org, or group.
Listing Datasets
ql_get_dashboard_data
Returns all datasets owned by the authenticated user, ordered newest-first. This is the natural starting point for any integration: call it once to discover which dataset IDs you want to pass to the analytics, charting, and insight endpoints.
Auth: ql_verify_auth() — valid Bearer token required. POST params: none.
Response:
{
"user": {
"id": 5,
"email": "jane@example.com",
"first_name": "Jane",
"last_name": "Doe",
"role": "user",
"auth_provider": "email"
},
"datasets": [
{
"id": 42,
"name": "sales_2024",
"source_type": "upload", // "upload" | "db" | "api" | "sftp" | "google_sheet" | "merged"
"status": "ready", // "ready" | "processing" | "failed"
"row_count": 15000,
"visibility": "private", // "private" | "public" | "org" | "group"
"created_at": "2024-01-15 10:30:00"
// ... full ql_datasets row
}
],
"datasets_count": 7
}
CSV Upload
ql_upload_dataset
Creates a new dataset from a CSV file upload. The file is validated against an allowlist of MIME types (text/csv, text/plain, application/csv, application/vnd.ms-excel) using finfo — not just the file extension. File size is checked against the plan limit before the file is moved. The file is saved to wp-uploads/quantumlayers/<dataset_id>_<timestamp>.csv and its SHA-256 checksum stored in ql_dataset_files. The dataset row is created with status = 'processing' and then ql_process_csv_file() is called synchronously: it reads the CSV header to detect column names, samples rows to infer types (integer/float/string/date/datetime), and computes per-column statistics (min, max, mean, stddev, distinct count, null count) stored in ql_dataset_columns. The status is set to ready on success.
Auth: ql_verify_auth(). Request type: multipart/form-data.
csv_file(file, required) — the CSV filedataset_name(string, required)visibility(string, optional, default:private)
Response: { "message": "Upload successful!", "dataset_id": 42, "dataset_name": "sales_2024" }
ql_update_upload_dataset
Updates metadata and/or replaces the CSV data for an existing source_type = 'upload' dataset. Rejects datasets of other source types with an explicit error. If a new csv_file is included it is processed via the shared ql_process_uploaded_csv() helper, which replaces the stored file, re-runs column analysis, and updates ql_dataset_columns and row_count. Metadata changes (dataset_name, visibility) and CSV replacement can be combined in a single call — the response message describes which operations occurred.
Auth: ql_verify_auth() + ownership check. Request type: multipart/form-data.
dataset_id(int, required)dataset_name(string, optional)visibility(string, optional)csv_file(file, optional) — if provided, replaces the dataset’s data
Response: { "message": "...", "row_count": 15000 } (row_count present only when a CSV was processed).
ql_resync_upload_dataset
Replaces a CSV upload dataset’s data with a newly uploaded file, without changing any metadata. Equivalent to ql_update_upload_dataset with only a csv_file and no metadata fields. A convenience endpoint for sync-only workflows.
Auth: ql_verify_auth() + ownership check. Request type: multipart/form-data.
dataset_id(int, required)csv_file(file, required)
Response: { "success": true, "message": "...", "row_count": 15000 }
Excel Upload
Excel files are uploaded in two steps. First ql_inspect_excel_file stores the workbook in a temporary area and returns its sheets, the detected header row, and a preview, along with a token that identifies the stored file. You can call ql_excel_sheet_preview to preview a different sheet or header row. Then ql_upload_excel_dataset (new dataset) or ql_update_upload_excel_dataset (existing upload dataset) converts the chosen sheet to CSV and processes it exactly like a CSV upload. Accepted formats: .xlsx, .xlsm, .xls. The token is tied to the user who uploaded the file, is consumed by a successful create/update, and unused temporary files are deleted after 24 hours.
ql_inspect_excel_file
Auth: ql_verify_auth(). Request type: multipart/form-data.
| Param | Type | Required | Description |
|---|---|---|---|
excel_file | file | yes | The workbook. Subject to your plan’s maximum file size. |
Response — an inspection object describing the workbook:
{
"success": true,
"data": {
"token": "9c1f…e2-xlsx", // 40 hex chars + "-" + extension; pass to the calls below
"sheets": ["Q3 Sales", "Notes"], // every worksheet name, in workbook order
"sheet_name": "Q3 Sales", // the sheet this preview is for (first sheet by default)
"header_row": 3, // 1-based row detected as the header
"headers": ["Region", "Revenue", "Units"],
"preview_rows": [["East", "12040", "310"], ["West", "9981", "244"]], // up to 8 data rows
"total_data_rows": 1520 // data rows below the header
}
}
Errors (data.message): No file uploaded, Upload error: <PHP upload error code>, Please upload a valid Excel file (.xlsx, .xlsm, or .xls), a file-size limit message, Could not read the Excel file. It may be corrupted or password-protected., No worksheets were found in this file., Excel support is not available on this server. Please contact support.
ql_excel_sheet_preview
Re-inspects the already-uploaded file for another sheet or header row. Nothing is re-uploaded.
Auth: ql_verify_auth().
| Param | Type | Required | Description |
|---|---|---|---|
token | string | yes | From ql_inspect_excel_file. |
sheet_name | string | no | Sheet to preview. Omit for the first sheet. |
header_row | int | no | 1-based header row. Omit or send 0 to auto-detect. |
Response — the same inspection object as ql_inspect_excel_file, including the same token. Fails with Uploaded Excel file has expired. Please re-upload it. if the token is unknown, belongs to another user, or has already been consumed.
ql_upload_excel_dataset
Creates a new source_type = 'upload' dataset from an inspected workbook. The chosen sheet becomes the dataset’s CSV; the source workbook is deleted afterwards. Counts against your plan’s dataset limit, row limit, and file-size limit.
Auth: ql_verify_auth().
| Param | Type | Required | Description |
|---|---|---|---|
token | string | yes | From ql_inspect_excel_file. |
dataset_name | string | yes | |
sheet_name | string | no | Sheet to import. Omit, or send a name that doesn’t exist, to use the first sheet. |
header_row | int | no | 1-based header row. Omit or 0 to auto-detect. |
visibility | string | no | private (default), public, org, or group. |
{
"success": true,
"data": {
"message": "Upload successful!",
"dataset_id": 42, // integer
"dataset_name": "Q3 Sales",
"sheet_name": "Q3 Sales", // the sheet actually imported
"header_row": 3, // the header row actually used
"row_count": 1520
}
}
If processing fails the new dataset row is removed and data.message explains why (token expired, dataset/row/size limit reached, unreadable worksheet, Failed to secure file).
ql_update_upload_excel_dataset
Renames an existing upload dataset, changes its visibility, and/or replaces its data from a newly inspected workbook. Only works on datasets you own whose source_type is upload. At least one change is required.
Auth: ql_verify_auth() + ownership check.
| Param | Type | Required | Description |
|---|---|---|---|
dataset_id | int | yes | |
dataset_name | string | no | New name. |
visibility | string | no | As for ql_upload_excel_dataset. Legacy alternative: is_private (0 = public, anything else = private). |
token | string | no | From ql_inspect_excel_file. When present, the dataset’s data is replaced by the chosen sheet. |
sheet_name | string | no | Only used with token. |
header_row | int | no | Only used with token. |
{
"success": true,
"data": {
"message": "Dataset updated and Excel sheet synchronized successfully",
// or "Excel sheet synchronized successfully" / "Dataset updated successfully"
"row_count": 1611, // these three only when a token was sent
"sheet_name": "Q3 Sales",
"header_row": 3
}
}
Errors (data.message): Dataset ID required, Dataset not found or access denied, This dataset is not an uploaded file. Use the appropriate update method for connected datasets., No data to update, Failed to update dataset, and any Excel processing error listed above.
SQL Database
ql_connect_sql_database
Creates a live SQL-backed dataset. Supported db_type values are mysql and postgresql. The connection is tested via ql_test_database_connection() before any database rows are written — a failed test returns an error immediately. On success: a ql_datasets row is created with source_type = 'db', the password is encrypted with ql_encrypt_credential() and stored in ql_sql_connections, then ql_sync_sql_connection() executes sql_query against the remote database, streams the result set into QL’s internal storage, and runs full column analysis. The default port is 3306 for MySQL and 5432 for PostgreSQL if omitted.
Auth: ql_verify_auth().
dataset_name(string, required)db_type(string, required) —mysqlorpostgresqlhost(string, required)port(int, optional) — defaults to 3306 / 5432database(string, required) — database/schema nameusername(string, required)password(string, required)sql_query(string, required) — the SELECT query whose result set becomes the datasetvisibility(string, optional)
Response: { "message": "Database connected successfully", "dataset_id": 42, "connection_id": 7, "rows_fetched": 5000 }. If the initial sync fails but the connection was stored, a "warning": true flag is added and rows_fetched is omitted.
ql_update_sql_connection
Updates credentials, query, or metadata for an existing SQL connection. Identified by connection_id. Any field can be omitted to leave it unchanged; if a new password is supplied it is re-encrypted. Does not re-sync data — call ql_resync_connection after updating to pull fresh data.
Auth: ql_verify_auth() + ownership check on connection_id.
connection_id(int, required)dataset_name,db_type,host,port,database_name,username,password,sql_query,visibility(all optional)
Response: { "message": "Connection updated successfully" }
ql_test_sql_connection
Checks that QuantumLayers can connect to a database with the given credentials, without saving anything. Use it to validate a form before calling ql_connect_sql_database.
Auth: ql_verify_auth().
| Param | Type | Required | Description |
|---|---|---|---|
db_type | string | yes | mysql, postgresql, redshift, sqlserver, synapse, clickhouse, or bigquery. |
host | string | yes* | *Not required for bigquery. |
port | int | no | Omit for the engine’s default port. |
database | string | yes | Database name; for bigquery, the project ID. |
username | string | no | |
password | string | no |
{ "success": true, "data": { "message": "Connected successfully", "version": "8.0.36" } }
// version is the server version string when the driver reports one, otherwise ""
// data.message is one of: "Host is required" | "Database name is required"
// | "Project ID is required in the Database field" | "Unsupported database type"
// | "Connection failed: <driver error>" | another driver-specific failure message
{ "success": false, "data": { "message": "Connection failed: Access denied for user 'etl'" } }
ql_generate_sql_query
Writes a SELECT query from a plain-language description. QuantumLayers connects with the given credentials, reads the live schema, and asks the AI model for a query in that database’s dialect. Nothing is executed or saved; review the SQL, then pass it as sql_query to ql_connect_sql_database. Uses AI tokens from your plan’s monthly budget.
Auth: ql_verify_auth().
| Param | Type | Required | Description |
|---|---|---|---|
intent | string | yes | What the query should return, in plain language. |
db_type | string | no | mysql, postgresql, redshift, sqlserver, synapse, clickhouse, or bigquery. Default mysql. |
host | string | yes* | *Not required for bigquery. |
port | int | no | Default port for the engine when omitted. |
database | string | yes | |
username | string | no | |
password | string | no |
{
"success": true,
"data": {
"success": true,
"sql": "SELECT region, SUM(amount) AS revenue FROM orders GROUP BY region",
"explanation": "Totals order amounts per region." // "" if the model gave none
}
}
// data.message is one of:
// "intent, database, and (for host-based connectors) host are required."
// "Failed to extract database schema: <reason>"
// "AI service unavailable. Please configure an API key in QuantumLayers settings."
{ "success": false, "data": { "message": "Failed to extract database schema: Connection refused" } }
If the model’s reply isn’t the expected JSON, sql contains the raw reply and explanation is empty, so check the SQL before using it.
REST API
ql_connect_api
Creates a dataset backed by a REST API endpoint. The URL is validated with a scheme check (http:// or https://) rather than FILTER_VALIDATE_URL to support URLs with colons in path segments (e.g. GA4’s /properties/123:runReport). A live connection test is performed before any rows are written. The API key is encrypted with ql_encrypt_credential(). Supports OAuth2: if a refresh_token is provided, QL stores it and automatically refreshes the access token when it is within 5 minutes of expiry at sync time. Custom request_headers are stored as a JSON string and merged with the default Authorization and Accept: application/json headers on every sync. response_path is a dot-notation path into the JSON response used to locate the array of records (e.g. data.items).
Auth: ql_verify_auth().
dataset_name(string, required)api_url(string, required)http_method(string, optional) —GET(default) orPOSTapi_key(string, optional) — sent asAuthorization: Bearer <key>request_headers(JSON string or array, optional) — extra headers merged at sync timerequest_body(string, optional) — raw body for POST requests; setsContent-Type: application/jsonresponse_path(string, optional) — dot-notation path to the records array in the JSON responserefresh_token(string, optional) — OAuth2 refresh tokenoauth_provider_name(string, optional)oauth_scope(string, optional)visibility(string, optional)
Response: { "message": "API connected successfully", "dataset_id": 42, "connection_id": 9, "rows_fetched": 1200 }
ql_update_api_connection
Updates credentials, headers, URL, or metadata for an existing API connection identified by connection_id. Any field can be omitted to leave it unchanged. Does not re-sync — call ql_resync_connection afterwards.
Auth: ql_verify_auth() + ownership check. POST params: connection_id (required) + any subset of the fields accepted by ql_connect_api.
SFTP
ql_create_sftp_connection
Creates a dataset backed by a file on an SFTP server. Supports password auth or private key auth — at least one must be supplied. Both credentials are encrypted with ql_encrypt_credential() before storage. A live connection test (ql_sftp_test_connection()) is performed first; if it fails no rows are written. On success, ql_sftp_sync_dataset() downloads the file at remote_path/filename, processes it as a CSV, and runs full column analysis. The dataset is created with source_type = 'sftp'.
Auth: ql_verify_auth().
dataset_name(string, required)host(string, required)port(int, optional, default: 22)username(string, required)password(string, optional) — required ifprivate_keynot providedprivate_key(string, optional) — PEM private key; required ifpasswordnot providedremote_path(string, required) — directory path on the SFTP serverfilename(string, required) — filename withinremote_pathvisibility(string, optional)
Response: { "dataset_id": 42, "connection_id": 3, "rows_fetched": 8000 }
ql_update_sftp_connection
Updates host, credentials, remote path, filename, or metadata for an existing SFTP connection. Any field can be omitted. Does not re-sync — call ql_sync_sftp_dataset after updating.
Auth: ql_verify_auth() + ownership check. POST params: connection_id (required) + any subset of ql_create_sftp_connection fields.
ql_sync_sftp_dataset
Triggers an immediate re-download and re-import of the file from the SFTP server for an existing SFTP connection. Decrypts stored credentials, opens the SSH connection, downloads the file, replaces the stored CSV data, and re-runs column analysis.
Auth: ql_verify_auth() + ownership check. POST params: connection_id (int, required).
Response: { "message": "Dataset synced successfully", "row_count": 8500 }
ql_delete_sftp_connection
Deletes an SFTP connection record. Pass delete_dataset: true to also drop the associated dataset and its data table.
Auth: ql_verify_auth() + ownership check. POST params: connection_id (int, required), delete_dataset (bool, optional).
Response: { "message": "Connection deleted successfully" }
ql_test_sftp_connection
Connects to an SFTP server and checks that the target file exists and is readable, without saving anything. filename may contain * wildcards, in which case the most recently modified match is reported — the same file ql_create_sftp_connection would import.
Auth: ql_verify_auth().
| Param | Type | Required | Description |
|---|---|---|---|
host | string | yes | |
port | int | no | Default 22. |
username | string | yes | |
password | string | one of | Password auth. |
private_key | string | one of | PEM private key, for key auth. |
remote_path | string | yes | Directory containing the file. |
filename | string | yes | File name, or a pattern such as export_*.csv. |
Response — note that the result object, including its own success and message, is the whole data payload on both success and failure:
// Exact filename
{ "success": true,
"data": { "success": true, "message": "Connection successful and file found",
"file_size": 482113, "file_modified": "2026-09-28 06:00:12" } }
// Wildcard filename — describes the newest match
{ "success": true,
"data": { "success": true,
"message": "Connection successful and 3 file(s) found matching pattern. Most recent: export_0928.csv",
"file_size": 482113, "file_modified": "2026-09-28 06:00:12",
"matched_file": "export_0928.csv", "total_matches": 3 } }
// data.message is one of: "Could not establish SFTP connection" | "File not found: <name>"
// | "No files found matching pattern: <pattern>" | "File exists but is not readable"
{ "success": false, "data": { "success": false, "message": "File not found: export.csv" } }
file_size is in bytes; file_modified is in the server’s timezone.
Google Sheets
ql_connect_google_sheet
Creates a dataset backed by a Google Sheet. Requires an OAuth2 access_token obtained via the Google Picker flow (scope: drive.file). Because drive.file tokens are rejected by the Sheets API, QL uses the Drive API export endpoint (/export?mimeType=text/csv) to download the sheet as CSV. A connection test is performed before writing any rows. The access token is stored in plaintext in ql_google_sheet_connections and a 1-hour expiry is calculated; at sync time, if the token is within 1 hour of expiry, ql_refresh_google_token() is called automatically using the stored refresh_token. If range_notation is omitted, the entire sheet is imported.
Auth: ql_verify_auth().
spreadsheet_id(string, required) — Google Sheets spreadsheet IDaccess_token(string, required) — current OAuth2 access tokenconnection_name(string, optional, default:"Google Sheet Connection") — becomes the dataset namesheet_name(string, optional, default:"Sheet1")range_notation(string, optional) — A1 notation range, e.g.A1:F500refresh_token(string, optional)visibility(string, optional)
Response: { "message": "Google Sheets connected successfully", "dataset_id": 42, "connection_id": 5 }
ql_update_google_sheet_connection
Updates the spreadsheet ID, sheet name, range, tokens, or dataset metadata for an existing Google Sheet connection. Any field can be omitted. Does not re-sync — call ql_resync_connection after updating.
Auth: ql_verify_auth() + ownership check. POST params: connection_id (required) + any subset of ql_connect_google_sheet fields.
ql_sync_google_sheet
Re-downloads a Google Sheet connection’s data now, refreshing the access token first if it is close to expiry. Equivalent to ql_resync_connection for Google Sheets, but addressed by connection_id.
Auth: ql_verify_auth() + ownership check.
| Param | Type | Required | Description |
|---|---|---|---|
connection_id | int | yes | The Google Sheet connection’s ID. |
{ "success": true, "data": { "message": "Sync successful" } }
// data.message is "Invalid connection ID", "Connection not found or access denied", or the sync error
{ "success": false, "data": { "message": "Connection not found or access denied" } }
Kaggle
Connect directly to a public or private Kaggle dataset using your Kaggle credentials. QuantumLayers calls the Kaggle API v1 download endpoint, receives a ZIP archive, extracts the first CSV file it finds, and processes it the same way as a regular upload — running column type inference and computing per-column statistics. The connection is stored internally as an API connection, so ql_update_api_connection can be used to update credentials or metadata, and ql_resync_connection (or the dedicated sync below) triggers a fresh download.
ql_connect_kaggle
Creates a new dataset by downloading a Kaggle dataset. kaggle_owner and kaggle_dataset are the two slug segments in the Kaggle dataset URL — e.g. for kaggle.com/datasets/acmecorp/sales-data the owner is acmecorp and the dataset is sales-data. The API key is your Kaggle account API token (found under Account → API in your Kaggle profile).
Auth: ql_verify_auth().
dataset_name(string, required) — name for the dataset in QuantumLayerskaggle_username(string, required) — your Kaggle account usernamekaggle_api_key(string, required) — your Kaggle API keykaggle_owner(string, required) — the dataset owner slug from the Kaggle URLkaggle_dataset(string, required) — the dataset slug from the Kaggle URLvisibility(string, optional, default:private)
Response: { "message": "Kaggle dataset connected successfully", "dataset_id": 42, "connection_id": 11, "rows": 8000 }
ql_sync_kaggle_dataset
Re-downloads the Kaggle dataset ZIP using the stored credentials, extracts the first CSV, replaces the stored data, and re-runs column analysis. Use this to pull an updated version of the Kaggle dataset on demand.
Auth: ql_verify_auth() + ownership check. POST params: dataset_id (int, required).
Response: { "success": true, "row_count": 8500 }
ql_update_kaggle_dataset
Updates the Kaggle connection metadata (name, owner/dataset slugs, visibility, optionally new credentials) and immediately re-syncs the dataset. Omit kaggle_username and kaggle_api_key to keep the existing stored credentials.
Auth: ql_verify_auth() + ownership check. POST params: dataset_id (required), dataset_name (required), kaggle_owner (required), kaggle_dataset (required), kaggle_username (optional), kaggle_api_key (optional), visibility (optional).
Response: { "success": true, "row_count": 8500 }
Merged Datasets
ql_create_merged_dataset
Joins two or more existing datasets on shared columns and materialises the result as a new dataset with source_type = 'merged'. Requires at least 2 datasets. The first dataset in the array is the base; each subsequent entry specifies a join column (which must exist in that dataset) and a join type. Joins are chained left-to-right: dataset 2 joins onto dataset 1, dataset 3 joins onto the result of that join, and so on. Join records are stored in ql_dataset_joins. After the join records are created, ql_build_merged_columns() reads the column schemas of all source datasets and writes the merged column schema. The actual row data is assembled at query time from the source datasets — no copy of the rows is stored. Always created as visibility = 'private'.
Auth: ql_verify_auth().
// POST body
{
"name": "Sales + Customers",
"datasets": [
{ "dataset_id": 10 }, // base (no join needed)
{ "dataset_id": 11, "join_column": "customer_id", "join_type": "inner" },
{ "dataset_id": 12, "join_column": "customer_id", "join_type": "left" }
]
}
// join_type: "inner" | "left" | "right" | "outer"
Response: { "success": true, "dataset_id": 55 }
ql_update_merged_dataset
Replaces the name and entire join configuration of an existing merged dataset. All existing ql_dataset_joins rows for the dataset are deleted and re-created from the new datasets array using the same chaining logic as ql_create_merged_dataset. The underlying source data is not touched.
Auth: ql_verify_auth() + ownership check.
dataset_id(int, required) — must be asource_type = 'merged'datasetname(string, required)datasets(array, required) — same structure asql_create_merged_dataset
Response: { "message": "Merged dataset updated successfully" }
ql_sync_merged_dataset
Rebuilds a merged dataset’s metadata from the current state of its source datasets: the column list, types, and statistics are re-derived, and the row count is recomputed by running the join. Call it after a source dataset has been re-synced. The join configuration is unchanged.
Auth: ql_verify_auth() + ownership check (you must own the merged dataset).
| Param | Type | Required | Description |
|---|---|---|---|
dataset_id | int | yes | A dataset whose source_type is merged. |
{ "success": true, "data": { "row_count": 5200 } }
// data.message is "Dataset ID required", "Dataset not found or access denied", or a rebuild error
{ "success": false, "data": { "message": "Dataset not found or access denied" } }
Common Operations
ql_resync_connection
Re-fetches data from the remote source for any connected dataset (source_type of db, api, or google_sheet). Looks up the connection record for the dataset’s source type, then delegates to the appropriate sync function: ql_sync_sql_connection(), ql_sync_api_connection(), or ql_sync_google_sheet_connection(). For Google Sheets, optional fresh access_token / refresh_token values can be passed in the same request to update tokens before syncing.
Auth: ql_verify_auth() + ownership check.
dataset_id(int, required)access_token(string, optional) — Google Sheets onlyrefresh_token(string, optional) — Google Sheets only
Response: { "message": "Data synced successfully", "row_count": 5200 }
ql_delete_dataset
Permanently deletes a dataset and all associated data. Access is checked via ql_user_can_write_dataset(). Deletion cascades through every related table in order: physical CSV files are deleted from wp-uploads/quantumlayers/ first, then rows are removed from ql_dataset_files, ql_dataset_columns, ql_dataset_joins, ql_google_sheet_connections, ql_api_connections, ql_sql_connections, ql_sftp_connections, and finally ql_datasets. Saved charts and insights for the dataset are also removed. This operation is irreversible.
Auth: ql_verify_auth() + ql_user_can_write_dataset().
dataset_id(int, required)
Response: { "message": "Dataset deleted successfully" }
ql_set_dataset_visibility
Changes the visibility of any dataset. Also registered as ql_toggle_dataset_privacy for backward compatibility. Accepts the modern visibility string parameter or the legacy is_public boolean (0 = private, 1 = public); if both are sent, visibility takes precedence. Access is checked via ql_user_can_write_dataset().
Auth: ql_verify_auth() + ql_user_can_write_dataset().
dataset_id(int, required)visibility(string) —private|public|org|groupis_public(int, legacy) —0= private,1= public; ignored ifvisibilityis present
Response: { "message": "Visibility updated to public" }
Scheduled Reports Endpoints
Scheduled reports automatically generate PDF or HTML summaries of your datasets and email them to recipients on a recurring schedule. All endpoints require authentication.
List Reports
Action: ql_get_scheduled_reports
Params: none
const res = await _ajax({ action: 'ql_get_scheduled_reports' });
// res.data.reports — array of report objects
// res.data.count — total count
Response: { "reports": [{ "id", "report_name", "frequency", "schedule_time", "timezone", "recipients", "format", "is_active", "next_run", "last_run" }], "count": N }
Create Report
Action: ql_create_scheduled_report
| Param | Type | Description |
|---|---|---|
report_name | string | Display name for the report |
frequency | string | daily, weekly, monthly, or custom |
schedule_time | string | Time in HH:MM:SS format (UTC unless timezone set) |
timezone | string | IANA timezone, e.g. America/New_York |
schedule_day_of_week | int | 0–6 (Sun–Sat); used when frequency is weekly |
schedule_day_of_month | int | 1–31; used when frequency is monthly |
recipients | string | Comma-separated email addresses |
format | string | pdf, html, or both |
datasets | JSON | Array of dataset config objects (see below) |
Each object in the datasets array supports these fields:
| Field | Type | Description |
|---|---|---|
dataset_id | int | Required. Dataset to include |
include_insights | bool | Generate AI insights for this dataset |
include_suggested_charts | bool | Auto-generate chart suggestions |
suggested_chart_count | int | Number of charts to suggest (default 3) |
saved_chart_ids | array | IDs of saved charts to include |
insight_columns | array | Columns to focus AI insights on |
insight_filter_column | string | Column name to filter data before insights |
insight_filter_value | string | Value to match in the filter column |
const res = await _ajax({
action: 'ql_create_scheduled_report',
report_name: 'Weekly Sales Summary',
frequency: 'weekly',
schedule_time: '08:00:00',
timezone: 'America/New_York',
schedule_day_of_week: 1, // Monday
recipients: 'alice@example.com,bob@example.com',
format: 'pdf',
datasets: JSON.stringify([{
dataset_id: 42,
include_insights: true,
include_suggested_charts: true,
suggested_chart_count: 3,
}]),
});
// res.data.report_id — ID of the new report
// res.data.next_run — ISO timestamp of the first scheduled run
Update Report
Action: ql_update_scheduled_report
Send report_id plus any subset of the fields from Create Report that you want to change.
const res = await _ajax({
action: 'ql_update_scheduled_report',
report_id: 7,
recipients: 'newteam@example.com',
format: 'both',
});
// res.data.message — confirmation string
Delete Report
Action: ql_delete_scheduled_report
Params: report_id
const res = await _ajax({ action: 'ql_delete_scheduled_report', report_id: 7 });
// res.data.message — "Report deleted successfully"
Get Report Details
Action: ql_get_report_details
Params: report_id
const res = await _ajax({ action: 'ql_get_report_details', report_id: 7 });
// res.data.report — report metadata object
// res.data.datasets — array of dataset configs attached to this report
// res.data.executions — recent execution history (status, sent_at, error)
Run Report Now
Action: ql_run_report_now
Params: report_id
Triggers an immediate out-of-schedule execution and emails the recipients.
const res = await _ajax({ action: 'ql_run_report_now', report_id: 7 });
// res.data.message — "Report queued for immediate execution"
// res.data.execution_id — ID of the execution record created
Toggle Report Status
Action: ql_toggle_report_status
Params: report_id, is_active (1 to enable, 0 to pause)
const res = await _ajax({ action: 'ql_toggle_report_status', report_id: 7, is_active: 0 });
// res.data.message — "Report paused"
// res.data.is_active — 0 or 1
Monitor Endpoints
A monitor re-runs the insight detectors on one or more datasets on a schedule, compares the findings with the previous run, and notifies you only when a finding appears or disappears (for example, a correlation that becomes significant, or a trend that ends). Notifications go by email, signed webhook, and/or Slack — see Monitor Webhook & Slack Delivery below for those fields and the webhook payload.
All endpoints use the wrapped envelope ({ success, data }, HTTP 200) and require ql_verify_auth(). Endpoints that take a monitor_id return Monitor not found or access denied when the monitor doesn’t exist or belongs to someone else. As elsewhere, database values come back as strings unless noted.
The monitor object
Returned by ql_get_monitors and ql_get_monitor_details. Times are in the server’s timezone except next_run, which is UTC; schedule_time is interpreted in the monitor’s timezone.
{
"id": "5",
"user_id": "42",
"monitor_name": "Weekly revenue drift",
"frequency": "weekly", // hourly | daily | weekly | monthly | custom
"schedule_time": "09:00:00",
"schedule_day_of_week": "1", // null unless set
"schedule_day_of_month": null,
"timezone": "America/New_York",
"recipients": "alice@example.com,bob@example.com",
"format": "html", // html | pdf | both
"delivery_methods": "email,webhook", // comma-separated
"email_active": "1",
"webhook_url": "https://hooks.example.com/ql", // null when not set
"webhook_finding_types": "[\"correlation\",\"trend\"]", // JSON-encoded array, or null for all types
"webhook_active": "1",
"webhook_cooldown_minutes": "60",
"slack_active": "0",
"webhook_secret_configured": true, // the secret itself is never returned here
"slack_webhook_configured": false, // nor is the Slack URL
"is_active": "1",
"last_run": "2026-09-21 13:00:04", // null before the first run
"next_run": "2026-09-28 13:00:00", // UTC
"created_at": "2026-08-02 10:11:12",
"updated_at": "2026-09-21 13:00:04"
}
ql_get_monitors adds three computed fields to each monitor: dataset_count (number of watched datasets), last_successful_run (time of the latest successful run, or null) and last_run_had_changes ("1" if that run found changes, "0" if not, null if there has been no successful run).
Dataset configuration
ql_create_monitor and ql_update_monitor take datasets as a JSON-encoded array. Each element configures one watched dataset; the array order becomes display_order.
| Field | Type | Required | Description |
|---|---|---|---|
dataset_id | int | yes | A dataset you can read. |
section_title | string | no | Heading for this dataset in notifications. Defaults to the dataset name. |
watched_columns | string[] | no | Only report findings that involve these columns. Omit or send an empty array for all columns. |
insight_filter_column | string | no | With insight_filter_value: analyse only rows where this column equals that value. |
insight_filter_value | string | no | |
insight_filter_date_column | string | no | With insight_filter_date_from / insight_filter_date_to: analyse only rows whose date falls in the range. |
insight_filter_date_from | string | no | Start date, e.g. 2026-01-01. |
insight_filter_date_to | string | no | End date. |
max_findings | int | no | Maximum findings tracked per run for this dataset. Minimum 1, default 30. |
ql_create_monitor
Creates an active monitor and schedules its first run. Requires a Pro subscription; without one the call fails with HTTP 403 and Monitors require a Pro subscription. Upgrade to unlock statistical monitoring.
| Param | Type | Required | Description |
|---|---|---|---|
monitor_name | string | yes | |
datasets | JSON string | yes | Array of dataset configurations; at least one. |
frequency | string | no | hourly, daily, weekly (default), monthly, or custom. custom is currently scheduled like daily. Runs are picked up by a job that checks every 15 minutes, so they can start up to 15 minutes late. |
schedule_time | string | no | HH:MM:SS or HH:MM, default 09:00:00. For hourly, only the minutes and seconds are used. |
timezone | string | no | IANA timezone, default America/Montreal. An invalid value is treated as UTC. |
schedule_day_of_week | int | no | 0–6 (0 = Sunday), for weekly. Out-of-range values are clamped. |
schedule_day_of_month | int | no | 1–31, for monthly; clamped to the month’s last day when needed. |
recipients | string | when email is on | Comma-separated email addresses. |
format | string | no | Email format: html (default), pdf, or both. Other values become html. |
| Delivery fields | no | delivery_methods, webhook_url, webhook_secret, webhook_finding_types, webhook_cooldown_minutes (0–10080), slack_webhook_url — see Monitor Webhook & Slack Delivery. |
{ "success": true,
"data": { "message": "Monitor created successfully",
"monitor_id": 5, // integer
"next_run": "2026-10-05 13:00:00" } } // UTC
// data.message is one of: "Monitor name is required" | "At least one dataset must be selected"
// | "Access denied to dataset ID <id>" | "Failed to create monitor"
// | delivery validation errors, joined with spaces, e.g.
// "webhook_url is required to enable Webhook delivery."
// "At least one delivery method (Email, Webhook, or Slack) must be enabled."
{ "success": false, "data": { "message": "At least one dataset must be selected" } }
ql_update_monitor
Changes any subset of a monitor’s settings. Send monitor_id plus only the fields to change; omitted fields keep their values. Accepts every ql_create_monitor field. Changing any schedule field recalculates next_run. Sending datasets replaces the whole dataset list and must contain at least one dataset. Sending an empty string for schedule_day_of_week or schedule_day_of_month clears it. The Pro check is not repeated.
{ "success": true, "data": { "message": "Monitor updated successfully" } }
// data.message: "Monitor ID is required" | "Monitor not found or access denied"
// | "At least one dataset must be selected" | "Access denied to dataset ID <id>"
// | delivery validation errors
All fields are validated before anything is saved, so a rejected call leaves the monitor unchanged.
ql_get_monitors
Lists your monitors, newest first. No parameters.
{ "success": true, "data": { "monitors": [ /* monitor objects + dataset_count, last_successful_run, last_run_had_changes */ ] } }
ql_get_monitor_details
Returns one monitor with its datasets, its 10 most recent runs, and the findings that are currently active.
| Param | Type | Required | Description |
|---|---|---|---|
monitor_id | int | yes |
{
"success": true,
"data": {
"monitor": { /* the monitor object */ },
"datasets": [
{
"id": "11", "monitor_id": "5", "dataset_id": "42", "display_order": "0",
"section_title": null,
"watched_columns": ["revenue", "region"], // decoded array, or null for all columns
"insight_filter_column": null, "insight_filter_value": null,
"insight_filter_date_column": null, "insight_filter_date_from": null, "insight_filter_date_to": null,
"max_findings": "30", "created_at": "2026-08-02 10:11:12",
"dataset_name": "sales_2024", "source_type": "upload", "row_count": "15000"
}
],
"executions": [ // newest first, at most 10
{
"id": "301", "monitor_id": "5",
"execution_time": "2026-09-21 13:00:01",
"status": "success", // success | failed | in_progress
"error_message": null,
"appeared_count": "2", "disappeared_count": "0",
"changes_detected": "1",
"recipients_sent": "2",
"created_at": "2026-09-21 13:00:01"
}
],
"active_findings": [ // newest first
{
"id": "88", "monitor_id": "5", "dataset_id": "42",
"fingerprint": "3b1e…", // 40-char stable ID of this finding
"insight_type": "correlation", // correlation | trend | outliers | …
"involved_columns": "revenue,units",
"direction": "positive", // type-specific; may be null
"is_active": "1",
"consecutive_absent_count": "0",
"first_seen_at": "2026-09-14 13:00:02", "last_seen_at": "2026-09-21 13:00:02",
"disappeared_at": null,
"last_insight_snapshot": "{…}", // JSON-encoded insight from the latest run
"created_at": "2026-09-14 13:00:02", "updated_at": "2026-09-21 13:00:02"
}
]
}
}
ql_run_monitor_now
Runs the monitor immediately and waits for it to finish, including sending notifications for any changes. It does not change the schedule. Can take a while on large datasets, so allow a generous client timeout.
| Param | Type | Required | Description |
|---|---|---|---|
monitor_id | int | yes |
{ "success": true,
"data": { "message": "Monitor checked successfully",
"execution_id": 302,
"appeared_count": 1, // findings new since the previous run
"disappeared_count": 0 } } // findings gone since the previous run
// On a failed run: the run's error, plus the execution ID when one was recorded
{ "success": false, "data": { "message": "<error>", "execution_id": 303 } }
ql_toggle_monitor_status
Pauses or resumes a monitor. A paused monitor keeps its settings and findings but is not run on schedule.
| Param | Type | Required | Description |
|---|---|---|---|
monitor_id | int | yes | |
is_active | int | yes | 1 to resume, 0 to pause. Any other value, or leaving it out, is rejected. |
{ "success": true, "data": { "message": "Monitor paused", "is_active": 0 } } // or "Monitor activated", 1
{ "success": false, "data": { "message": "is_active is required and must be 1 (resume) or 0 (pause)" } }
ql_delete_monitor
Permanently deletes a monitor together with its dataset list, run history, and findings.
| Param | Type | Required | Description |
|---|---|---|---|
monitor_id | int | yes |
{ "success": true, "data": { "message": "Monitor deleted successfully" } }
ql_reveal_monitor_secret
Returns the decrypted value of a monitor’s webhook signing secret or Slack webhook URL. These are never included in the monitor object, so this is the only way to read them back.
| Param | Type | Required | Description |
|---|---|---|---|
monitor_id | int | yes | |
field | string | yes | webhook_secret or slack_webhook_url. Anything else fails with Invalid field. |
{ "success": true, "data": { "webhook_secret": "4f9c0a…" } } // key matches the requested field; "" if unset
ql_rotate_monitor_webhook_secret
Replaces the webhook signing secret with a newly generated one and returns it. The old secret stops verifying signatures immediately, so update your receiver at the same time.
| Param | Type | Required | Description |
|---|---|---|---|
monitor_id | int | yes |
{ "success": true,
"data": { "webhook_secret": "81d07e…", // new secret, 64 hex characters
"message": "Webhook secret rotated. Update your receiver — the old secret no longer verifies signatures." } }
ql_get_monitor_delivery_log
Returns webhook and Slack delivery state for a monitor: the 50 most recent deliveries (one per event and channel, including retries still pending) and the 100 most recent individual attempts.
| Param | Type | Required | Description |
|---|---|---|---|
monitor_id | int | yes |
{
"success": true,
"data": {
"deliveries": [
{
"id": "907", "channel": "webhook", // webhook | slack
"event_id": "evt_5f2c…", // same event_id as in the payload
"direction": "appeared", // appeared | disappeared
"fingerprint": "3b1e…",
"attempt": "2",
"status": "success", // pending | success | failed (retries exhausted)
"last_http_status": "200", "last_error": null,
"next_attempt_at": "2026-09-21 13:01:00",
"created_at": "2026-09-21 13:00:03", "delivered_at": "2026-09-21 13:01:02"
}
],
"log": [
{
"delivery_row_id": "907", "channel": "webhook", "attempt": "1",
"http_status": "503", "success": "0",
"response_snippet": "Service Unavailable", // truncated response body
"error_message": null,
"created_at": "2026-09-21 13:00:03"
}
]
}
}
Monitor Webhook & Slack Delivery
Monitors (statistical change detectors — see ql_create_monitor / ql_update_monitor) can deliver findings by email/PDF, an outbound webhook, and/or a Slack Incoming Webhook, in any combination. QuantumLayers only detects and notifies — it sends a signed event describing exactly what changed; deciding what to do about it, and doing it, is entirely on your side.
Enabling Email / Webhook / Slack Delivery
Pass these fields to ql_create_monitor or ql_update_monitor alongside the usual monitor fields. All three channels require a Pro subscription (the same entitlement that already gates Monitors and Scheduled Reports), and all three are optional — enable any combination, but at least one must stay enabled. recipients is only required while email is included in delivery_methods.
| Field | Type | Description |
|---|---|---|
delivery_methods | array | Subset of ["email","webhook","slack"]. Any combination; defaults to ["email"] when omitted entirely. Rejected if it would leave every channel disabled. |
webhook_url | string | HTTPS endpoint to receive the signed monitor.finding event. Required if webhook is enabled. Rejected if it isn’t https:// or resolves to a private/loopback/link-local/reserved address. |
webhook_secret | string | HMAC signing secret. Omit to auto-generate a high-entropy one the first time Webhook delivery is enabled. View or rotate it later via ql_reveal_monitor_secret / ql_rotate_monitor_webhook_secret — it is never included in ql_get_monitor_details‘s response. |
webhook_finding_types | array | Optional filter — only these insight types (e.g. correlation, trend, outliers) fire the webhook. Omit for all types the monitor tracks. |
webhook_cooldown_minutes | int | Debounce window per finding + direction before re-firing (default 60). A finding that disappears and later reappears is always treated as a new event, regardless of this window. |
slack_webhook_url | string | A Slack Incoming Webhook URL (see setup steps below). Required if slack is enabled. Masked the same way as webhook_secret on read. |
Only findings that already passed the monitor’s own statistical significance threshold (the same detector output the email alert uses) are ever considered — there is no separate, looser threshold for Webhook/Slack.
Webhook Payload
Each finding is delivered as an HTTP POST with this JSON body — the whole finding, not prose:
{
"event_id": "evt_5f2c...", // idempotency key — STABLE across retries of this event
"event_type": "monitor.finding",
"delivery_id": "dlv_9a01...", // unique per delivery ATTEMPT
"created_at": "2026-09-07T12:00:00Z",
"monitor": { "id": 123, "name": "Weekly revenue monitor" },
"dataset": { "id": 456, "name": "Orders + CRM merged" },
"finding": {
"id": 789,
"type": "correlation", // correlation | trend | outliers | ...
"direction": "appeared", // appeared | disappeared | changed
"summary": "Plain-language summary (same text the email alert uses).",
"columns": ["satisfaction_score", "repeat_purchase_rate"],
"statistic": { "name": "pearson_r", "value": 0.72 },
"effect_size": 0.72,
"p_value": 0.001,
"significance_score": 0.94,
"sample_size": 4213,
"previous": null // prior state when direction = "changed"
},
"links": { "finding_url": "https://quantumlayers.com/ql-monitor?123" }
}
links.finding_url is always present — open it to verify the finding in the dashboard before taking any irreversible action, or hand it to an agent that can pull richer context through the QuantumLayers MCP server.
Headers
| Header | Description |
|---|---|
X-QL-Signature | sha256=<hex> — HMAC-SHA256 of the raw request body using your webhook_secret. |
X-QL-Timestamp | Unix seconds the request was sent. Reject any request with a timestamp more than 5 minutes from your clock — this bounds replay of a captured request. |
X-QL-Nonce | A UUID unique to this delivery attempt, for additional replay protection if you keep a short-lived cache of seen nonces. |
X-QL-Event-Id | Mirrors event_id in the body — stays constant across retries, so dedupe on this for idempotent processing. |
X-QL-Delivery-Id | Mirrors delivery_id — unique per attempt. |
Verifying the Signature (PHP)
$raw_body = file_get_contents('php://input'); // read RAW, before any JSON-decoding
$secret = getenv('QL_WEBHOOK_SECRET');
$signature = $_SERVER['HTTP_X_QL_SIGNATURE'] ?? ''; // "sha256=<hex>"
$timestamp = (int) ($_SERVER['HTTP_X_QL_TIMESTAMP'] ?? 0);
if (abs(time() - $timestamp) > 300) {
http_response_code(400);
exit('Timestamp outside the 5-minute tolerance window');
}
$expected = 'sha256=' . hash_hmac('sha256', $raw_body, $secret);
// Constant-time comparison — never use === or == on secrets/signatures.
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
// Dedupe on X-QL-Event-Id (stable across retries) before acting on the event.
$event = json_decode($raw_body, true);
http_response_code(200); // ack fast; do slow work after responding if needed
Reliability
- Delivery is asynchronous — a monitor run never blocks on your endpoint’s response time.
- Up to 5 attempts with exponential backoff (30s, 60s, 120s, 240s, capped at 15 minutes) on a non-2xx response or connection failure.
- 10-second connect/read timeout per attempt.
- Redirects are followed (up to 3 hops) with the same HTTPS/SSRF validation re-applied to each hop.
- Every attempt (timestamp, HTTP status, attempt number, truncated response) is logged and visible in the monitor’s Delivery Log panel — fetch it via
ql_get_monitor_delivery_log.
Slack Setup
- In Slack, go to your workspace → Settings & administration → Manage apps (or api.slack.com/apps) and create an app, or open an existing one.
- Enable Incoming Webhooks, then Add New Webhook to Workspace and pick the channel that should receive alerts.
- Copy the generated
https://hooks.slack.com/services/...URL and pass it asslack_webhook_urlwhen creating or updating the monitor, withslackincluded indelivery_methods.
Each finding posts as a Block Kit message: a header (finding type + dataset), the same plain-language summary as the email, a fields row (effect size, significance score, direction, sample size), a timestamp context line, and a “View in QuantumLayers” button linking to finding_url. A future release adds an Approve action alongside it (a signed link, not implemented yet) — the actions block already reserves room for it, so existing Slack integrations won’t need to change when it ships.
Organization Management Endpoints
Organizations allow multiple users to share datasets and collaborate under a single subscription. Seats are billed through Paddle; adding or removing members automatically adjusts the seat quantity. All endpoints require authentication; admin-level operations additionally require that the caller holds an admin or manager role within the org.
List My Organizations
Action: ql_get_my_organizations
Params: none
const res = await _ajax({ action: 'ql_get_my_organizations' });
// res.data.organizations — array of org objects (id, name, role, license_type, max_users, …)
// res.data.count — total count
Create Organization
Action: ql_create_organization
| Param | Type | Description |
|---|---|---|
name | string | Organization display name |
license_type | string | startup or enterprise |
max_users | int | Maximum number of seats |
billing_email | string | Email used for Paddle billing |
const res = await _ajax({
action: 'ql_create_organization',
name: 'Acme Analytics',
license_type: 'startup',
max_users: 10,
billing_email: 'billing@acme.com',
});
// res.data.organization — created org object
// res.data.checkout_url — Paddle checkout URL to complete billing
// res.data.checkout_transaction_id — Paddle transaction ID
Get Organization
Action: ql_get_organization
Params: organization_id
const res = await _ajax({ action: 'ql_get_organization', organization_id: 3 });
// res.data.organization — org object with metadata and seat counts
Update Organization
Action: ql_update_organization
Send organization_id plus any subset of name, license_type, max_users, billing_email.
const res = await _ajax({
action: 'ql_update_organization',
organization_id: 3,
name: 'Acme Data Team',
max_users: 20,
});
// res.data.message — confirmation
// res.data.organization — updated org object
Delete Organization
Action: ql_delete_organization
Params: organization_id
const res = await _ajax({ action: 'ql_delete_organization', organization_id: 3 });
// res.data.message — "Organization deleted successfully"
Member Management
List members — ql_get_organization_members
const res = await _ajax({
action: 'ql_get_organization_members',
organization_id: 3,
include_inactive: 'true', // optional — include removed members
});
// res.data.members — array of { user_id, email, role, is_active, joined_at }
// res.data.count — total
Add member — ql_add_organization_member
Role must be one of member, admin, or manager. The seat count is synced to Paddle automatically.
const res = await _ajax({
action: 'ql_add_organization_member',
organization_id: 3,
user_email: 'alice@example.com',
role: 'member',
});
// res.data.message — "Member added successfully"
// res.data.user — { user_id, email, role }
Remove member — ql_remove_organization_member
const res = await _ajax({
action: 'ql_remove_organization_member',
organization_id: 3,
user_id: 99,
});
// res.data.message — "Member removed successfully"
Update role — ql_update_member_role
const res = await _ajax({
action: 'ql_update_member_role',
organization_id: 3,
user_id: 99,
role: 'admin',
});
// res.data.message — "Role updated successfully"
Billing History
Action: ql_get_organization_billing
Params: organization_id, limit (optional, default 12)
const res = await _ajax({
action: 'ql_get_organization_billing',
organization_id: 3,
limit: 12,
});
// res.data.billing_history — array of { amount, currency, status, billed_at }
// res.data.count — total records returned
Utility & Connection Endpoints
These endpoints cover auth introspection, user profile, subscription limits, connection metadata, merged-dataset internals, and template lookups. All require a valid Bearer token unless noted otherwise.
Check Auth
Action: ql_check_auth — documented with its full request and response structure under Authentication Endpoints → ql_check_auth. Note that it returns success: false (not logged_in: false) when the token is missing or invalid.
Get Profile
Action: ql_get_profile — documented with its full response structure under Authentication Endpoints → ql_get_profile, alongside ql_update_profile and ql_delete_account.
Subscription Status
Action: ql_get_subscription_status
Params: none
Useful for gating features in your embedded UI before calling endpoints that enforce subscription limits.
const res = await _ajax({ action: 'ql_get_subscription_status' });
// res.data.has_pro — boolean
// res.data.subscription — { plan, status, current_period_end, … }
// res.data.limits — {
// max_file_size, max_file_size_formatted,
// max_rows, max_insights, max_datasets,
// can_use_scheduler,
// token_budget, token_budget_used
// }
Connection Endpoints
List all connections — ql_get_user_connections (no params)
Returns all four connection types in a single call.
const res = await _ajax({ action: 'ql_get_user_connections' });
// res.data.sql_connections — [{ id, dataset_id, db_type, host, port, database_name, last_synced, sync_status, dataset_name }]
// res.data.api_connections — [{ id, dataset_id, api_url, method, auth_type, last_synced, sync_status, dataset_name }]
// res.data.sftp_connections — [{ id, dataset_id, host, port, remote_path, is_active, last_synced, sync_status, dataset_name }]
// res.data.sheet_connections — [{ id, dataset_id, spreadsheet_id, sheet_name, last_synced, sync_status, dataset_name }]
Get a specific connection — each getter accepts connection_id or dataset_id (one is required). Credentials are stripped from the response.
| Action | Returns |
|---|---|
ql_get_sql_connection | { connection } — SQL metadata (no password) |
ql_get_api_connection | { connection } — API URL, method, auth type, headers (JSON-decoded) |
ql_get_sftp_connection | { connection } — SFTP host/port/path (no password or private key) |
ql_get_google_sheet_connection | { connection } — spreadsheet ID, sheet name (no credentials JSON) |
Get connection by dataset ID (any type) — ql_get_connection_details
Inspects the dataset’s source_type and returns the matching connection object with a type field (sql, api, sftp, google_sheets, kaggle, or upload).
const res = await _ajax({ action: 'ql_get_connection_details', dataset_id: 42 });
// res.data.connection — { type, name, last_synced, sync_status, sync_error, … }
Merged Dataset Helpers
These complement the ql_create_merged_dataset and ql_update_merged_dataset endpoints already documented in the Dataset Management section.
List non-merged datasets available for joining — ql_get_available_datasets (no params)
Only returns datasets with status = ready and source_type != merged.
const res = await _ajax({ action: 'ql_get_available_datasets' });
// res.data.datasets — [{ id, name, slug, row_count, source_type, column_count }]
Get columns of a dataset (for join key selection) — ql_get_dataset_columns_for_merge
Params: dataset_id
const res = await _ajax({ action: 'ql_get_dataset_columns_for_merge', dataset_id: 7 });
// res.data.columns — [{ col_name, inferred_type, distinct_count }]
Get a merged dataset with its join config — ql_get_merged_dataset
Params: dataset_id
const res = await _ajax({ action: 'ql_get_merged_dataset', dataset_id: 15 });
// res.data.merged_dataset — dataset row (source_type='merged')
// res.data.joins — [{ left_dataset_id, left_dataset_name, right_dataset_id,
// right_dataset_name, join_type, join_key_left,
// join_key_right, join_order }]
Get join config only (lightweight) — ql_get_merge_config
Params: dataset_id — same as above but skips the parent dataset row. Returns an error if the dataset is not of source_type = merged.
const res = await _ajax({ action: 'ql_get_merge_config', dataset_id: 15 });
// res.data.dataset — dataset metadata
// res.data.joins — same join array as ql_get_merged_dataset
Delete a merged dataset — ql_delete_merged_dataset
Params: dataset_id
const res = await _ajax({ action: 'ql_delete_merged_dataset', dataset_id: 15 });
// res.data — { success: true } on success
Templates
Pre-built connection templates stored in the plugin database. Both endpoints accept an optional service filter.
SQL query templates — ql_get_query_templates
Params: service (optional, e.g. MySQL — omit or pass Other for all)
const res = await _ajax({ action: 'ql_get_query_templates', service: 'MySQL' });
// res.data.templates — [{ id, service_name, template_name, sql_query, description }]
// res.data.services — distinct service names available
REST API templates — ql_get_api_templates
Params: service (optional, e.g. Stripe — omit or pass Custom for all)
const res = await _ajax({ action: 'ql_get_api_templates', service: 'Stripe' });
// res.data.templates — [{ id, service_name, template_name, api_url, http_method,
// request_body, response_path, description,
// auth_type, oauth_provider_name, oauth_scope }]
// res.data.services — distinct service names available
OAuth Helpers
These support the OAuth flows used when connecting Google Sheets and OAuth-protected REST APIs.
ql_get_oauth_providers
Lists the names of the OAuth providers configured on this install, for use as oauth_provider_name in ql_connect_api. Credentials are never returned. No parameters.
Auth: ql_verify_auth().
{ "success": true, "data": { "providers": ["GitHub", "Google", "HubSpot"] } } // alphabetical
ql_get_oauth_provider_config
Returns the public part of one provider’s configuration, which is enough to send the user to the provider’s authorization page. The client secret is never returned.
Auth: ql_verify_auth().
| Param | Type | Required | Description |
|---|---|---|---|
provider_name | string | yes | A name from ql_get_oauth_providers. |
{
"success": true,
"data": {
"provider_name": "HubSpot",
"client_id": "a1b2c3…",
"auth_endpoint": "https://app.hubspot.com/oauth/authorize",
"userinfo_endpoint": null,
"extra_auth_params": { "access_type": "offline" } // extra query params for the auth URL; {} if none
}
}
// data.message: "provider_name is required" | "Provider not found"
ql_google_exchange_code
Exchanges a Google authorization code from the Google Identity Services popup flow for access and refresh tokens, using QuantumLayers’ Google client credentials. Use the tokens for ql_connect_google_sheet.
Request format differs from other endpoints: put the action in the URL (admin-ajax.php?action=ql_google_exchange_code) and send a JSON body with Content-Type: application/json. Form fields are ignored.
Auth: ql_verify_auth(). Because the body is JSON, send the token in the Authorization: Bearer header; the session_token form-field fallback doesn’t apply.
POST /wp-admin/admin-ajax.php?action=ql_google_exchange_code
Authorization: Bearer <token>
Content-Type: application/json
{ "code": "4/0AfJohX…" } // code from the popup; the redirect URI must be "postmessage"
Response — Google’s token response, passed through unchanged as data:
{
"success": true,
"data": {
"access_token": "ya29.a0…",
"expires_in": 3599,
"refresh_token": "1//0g…", // only when Google issues one (first consent / offline access)
"scope": "https://www.googleapis.com/auth/drive.file",
"token_type": "Bearer",
"id_token": "eyJ…" // when the openid scope was requested
}
}
{ "success": false, "data": { "message": "Authorization code is required" } } // HTTP 400
{ "success": false, "data": { "message": "Google OAuth error: invalid_grant" } } // HTTP 400
{ "success": false, "data": { "message": "Google OAuth not configured" } } // HTTP 500
{ "success": false, "data": { "message": "Token exchange failed: <network error>" } } // HTTP 500
ql_google_refresh_token
Gets a new Google access token from a refresh token. Same request format and authentication as ql_google_exchange_code: action in the URL, JSON body, and the token in the Authorization header.
POST /wp-admin/admin-ajax.php?action=ql_google_refresh_token
Authorization: Bearer <token>
Content-Type: application/json
{ "refresh_token": "1//0g…" }
Response — Google’s response in data: access_token, expires_in, scope, token_type (no new refresh token). Errors match ql_google_exchange_code, with Refresh token is required (HTTP 400) and Token refresh failed: … (HTTP 500).
Internal Endpoints
These actions also exist but aren’t part of the integration API. Payment-provider webhooks are called by the provider and verify its signature: ql_paddle_webhook and ql_stripe_webhook. Admin tools only work for WordPress administrators: ql_admin_delete_orphan_files, ql_admin_run_csv_migration, ql_generate_license_signing_key, ql_generate_on_premise_license, and ql_download_on_premise_package.
QL-Agent Endpoint
The QL-Agent is a multi-turn conversational AI that can autonomously list datasets, generate charts, run statistical analysis, create and modify scheduled reports, and more. You can embed the agent loop directly in your own UI by calling ql_run_agent for each user turn.
ql_run_agent
Runs one turn of the agentic loop. The agent receives the user’s natural-language message plus the prior conversation history, calls Claude with a set of QL tool definitions (list datasets, generate charts, get insights, manage reports, etc.), executes any tool calls autonomously, and returns both the updated message history and a final plain-text reply. The loop runs up to 10 iterations per call. Requires an Anthropic API key to be configured in QuantumLayers settings.
Auth: ql_verify_auth() — valid Bearer token required. Token budget is checked against the user’s plan before each iteration.
POST params:
message(string, required) — the user’s natural-language request for this turnhistory(JSON string, optional) — themessagesarray returned by the previous call; pass[]or omit for the first turn
Response:
{
"messages": [ // full updated conversation — pass back as "history" on the next turn
{ "role": "user", "content": "Which dataset has the most rows?" },
{ "role": "assistant", "content": [...] }, // may contain tool_use blocks
{ "role": "user", "content": [...] }, // tool_result blocks
{ "role": "assistant", "content": [{ "type": "text", "text": "Your largest dataset is..." }] }
],
"final_text": "Your largest dataset is sales_2024 with 15,000 rows."
}
Multi-turn usage:
const QL_AJAX = 'https://quantumlayers.com/wp-admin/admin-ajax.php';
const API_TOKEN = 'qlpak_xxxxxxxxxxxxxxxx';
let history = [];
async function chat(userMessage) {
const res = await fetch(QL_AJAX, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_TOKEN}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
action: 'ql_run_agent',
message: userMessage,
history: JSON.stringify(history),
}),
});
const json = await res.json();
if (!json.success) throw new Error(json.data?.message);
history = json.data.messages; // store for next turn
return json.data.final_text; // display to the user
}
// First turn
console.log(await chat('List my datasets'));
// Second turn — context is preserved
console.log(await chat('Show me a bar chart of revenue by region for the first one'));
Complete Code Example
End-to-end: authenticate via a third-party JWT, fetch a bar chart for dataset 42, and render it with Chart.js.
<!-- Load libraries -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://quantumlayers.com/wp-content/plugins/quantumlayers/assets/auth.js"></script>
<!-- Canvas target -->
<canvas id="my-chart" width="800" height="400"></canvas>
<script>
const QL_AJAX = 'https://quantumlayers.com/wp-admin/admin-ajax.php';
const SIGNED_JWT = '<jwt-minted-by-your-backend>';
// Step 1: exchange JWT for QL session token
$.post(QL_AJAX, { action: 'ql_third_party_signin', jwt: SIGNED_JWT }, function(res) {
if (!res.success) return console.error('Sign-in failed', res);
QLAuth.setSessionToken(res.data.session_token);
// Step 2: fetch chart data
$.ajax({
url: QL_AJAX,
method: 'POST',
headers: { 'Authorization': 'Bearer ' + QLAuth.getSessionToken() },
data: {
action: 'ql_get_chart_data',
dataset_id: 42,
chart_type: 'bar',
x_column: 'product_category',
y_column: 'total_amount',
aggregation: 'sum'
},
success: function(chartRes) {
if (!chartRes.success) return console.error('Chart error', chartRes);
// Step 3: render
const ctx = document.getElementById('my-chart').getContext('2d');
new Chart(ctx, chartRes.data);
}
});
});
</script>
Chart Type Quick Reference
Pass one of the following values as chart_type to ql_get_chart_data.
| chart_type | Required params | Chart.js type rendered |
|---|---|---|
bar | x_column, y_column, aggregation | bar |
horizontal_bar | x_column, y_column, aggregation | bar (indexAxis: ‘y’) |
line | x_column, y_column, aggregation | line |
scatter | x_column, y_column | scatter |
pie | category_column, value_column, aggregation | pie |
doughnut | category_column, value_column, aggregation | doughnut |
bubble | x_column, y_column, size_column | bubble |
box_plot | category_column, value_column | boxplot (chartjs-chart-boxplot) |
heatmap | x_column, y_column, value_column | matrix (chartjs-chart-matrix) |
time_series | date_column, value_column, aggregation | line (time scale) |
histogram | column_name, optional bins | bar (computed bins) |
area | x_column, y_column, aggregation | line (fill: true) |
