XUNL EXPERIMENTS / JS SDK · WORDPRESS · SERVER API

A/B Testing Setup Guide

A/B testing on any website, with inquiries counted correctly.

Public guide · no sign-in
Want to run A/B tests on your own website?

XunL Experiments: edit pages visually, assign visitors consistently, confirm accepted inquiries on the server and get reports with confidence intervals and a clear decision status.

Open A/B experiments →

Choose how to connect

Both use the same site connection, experiments and reports; only installation and inquiry confirmation differ.

JS SDKWordPress plugin
WebsitesAny website: Shopify, Webflow, Wix, Squarespace, tag managers, custom sitesWordPress 6.2 or later
InstallAdd one script line to the shared headUpload the plugin ZIP and enter the connection details
Page, click and custom event goalsSupportSupport
Accepted inquiry goalSigned by your server (Node.js, Python and PHP examples below)Automatic for Contact Form 7, Elementor Pro, WPForms and Gravity Forms

Any website: install the JS SDK

  1. Sign in to XunL, add the website under Sites, then create a connection under A/B experiments → Sites & installation.
  2. Copy the script the console shows and place it in the head shared by every page, as early as possible. Replace SITE_PUBLIC_KEY in the example with your site's public key.
  3. Connect visitor consent (below), then select Check connection in the console.
HTML
<script defer src="https://www.xunl.net/js/xunl-experiments-sdk.js" data-xunl-key="SITE_PUBLIC_KEY" data-xunl-origin="https://www.xunl.net" data-xunl-consent="pending"></script>

The script only reads the public experiment configuration; never put the connector secret in a web page. In single-page apps, call XunLExperiments.refresh() after changing routes with history.pushState.

Where to add it on each platform

Shopify
Online Store → Themes → Edit code → layout/theme.liquid, paste just before the closing head tag. Checkout cannot be edited except on Shopify Plus, so put goals on pages before checkout.
Webflow
Site settings → Custom code → Head code, paste and publish. Requires a paid site plan.
Wix
Settings → Advanced → Custom code → Add custom code, choose All pages and Head. Requires a premium plan with a connected domain.
Squarespace
Settings → Advanced → Code injection → Header. Requires the Business plan or higher.
Google Tag Manager
Create a Custom HTML tag, paste the script and trigger it on All Pages or Initialization – All Pages. Loading through a tag manager is later than writing it in the head, so visual changes are more likely to flicker.
Custom sites, React, Next.js
Put it in the HTML template head, such as the root layout or _document in Next.js, or index.html in Vue and React projects.

By default data-xunl-consent="pending": before consent the SDK does not touch browser storage, create a visitor ID or send events. Call one of the following from your cookie banner's callback.

JavaScript
// After the visitor accepts analytics cookies in your consent banner
XunLExperiments.consent(true);

// If they decline or withdraw consent
XunLExperiments.consent(false);

// Or, without touching the SDK directly (also works before it has started)
window.dispatchEvent(new CustomEvent('xunl:consent', { detail: { granted: true } }));

If your site does not need consent where it operates, set data-xunl-consent to granted in the script. Whether consent is required depends on the rules where your site and visitors are.

Set experiment goals

Each experiment has one primary goal, set before launch; the verdict uses only the primary goal and other goals are for reference.

GoalCounts whenExample
PagesThe visitor reaches a URL, such as a thank-you pagehttps://www.ssevening.com/thank-you/
ClickA click on an element matched by a CSS selector#quote-button
Custom eventReported from your code with trackXunLExperiments.track('quote_started')
Accepted inquiryYour server confirms the inquiry was saved or sent; see the next section—

Count accepted inquiries: server confirmation

On sites that are not WordPress, count accepted inquiries in three steps. Send only after the inquiry has really been saved or sent, never for clicks, validation failures or spam.

Step 1: let forms carry the experiment context

Add data-xunl-forms="all" to the script (or a CSS selector to cover only inquiry forms). Submitted forms then include a hidden xunl_context field.

HTML
<script defer src="https://www.xunl.net/js/xunl-experiments-sdk.js" data-xunl-key="SITE_PUBLIC_KEY" data-xunl-origin="https://www.xunl.net" data-xunl-consent="pending" data-xunl-forms="all"></script>

For forms sent with fetch or AJAX, add the context to the request yourself:

JavaScript
const body = new FormData(form);
const ctx = window.XunLExperiments && XunLExperiments.inquiryContext();
if (ctx) body.set('xunl_context', JSON.stringify(ctx));
await fetch('/api/inquiry', { method: 'POST', body });

Step 2: sign and send after your server saves the inquiry

The connector secret is shown once when you create the connection; keep it in the server environment variable XUNL_CONNECTOR_SECRET. Use your own inquiry number as submissionId; resending the same inquiry is not counted twice.

Node.js
// Node 18+. Call after your server has saved or sent the inquiry.
const crypto = require('node:crypto');
const XUNL = 'https://www.xunl.net', KEY = 'SITE_PUBLIC_KEY', SECRET = process.env.XUNL_CONNECTOR_SECRET;

async function confirmInquiry(xunlContext, submissionId) {
  let ctx; try { ctx = JSON.parse(xunlContext || 'null'); } catch { return false; }
  if (!ctx || ctx.preview || !ctx.visitorId || !(ctx.assignments || []).length) return false;
  const body = JSON.stringify({ eventId: crypto.randomUUID(), submissionId, visitorId: ctx.visitorId,
    acceptedAt: new Date().toISOString(), assignments: ctx.assignments, status: 'accepted' });
  const path = '/x/ab/v1/sites/' + KEY + '/conversions';
  for (let attempt = 0; attempt < 3; attempt++) { // retries keep the body, sign again
    const ts = String(Math.floor(Date.now() / 1000)), nonce = crypto.randomUUID();
    const digest = crypto.createHash('sha256').update(body).digest('hex');
    const sig = crypto.createHmac('sha256', SECRET).update(`v1\nPOST\n${path}\n${ts}\n${nonce}\n${digest}`).digest('hex');
    const res = await fetch(XUNL + path, { method: 'POST', body, redirect: 'error', headers: { 'Content-Type': 'application/json',
      'X-XunL-Timestamp': ts, 'X-XunL-Nonce': nonce, 'X-XunL-Signature': 'v1=' + sig } }).catch(() => null);
    if (res && res.ok) return true;
    if (res && res.status !== 409 && res.status < 500) return false;
    await new Promise(r => setTimeout(r, 2000 * (attempt + 1)));
  }
  return false;
}
module.exports = { confirmInquiry };
Python
# Python 3.8+. Call after your server has saved or sent the inquiry.
import hashlib, hmac, json, os, time, uuid, urllib.error, urllib.request
from datetime import datetime, timezone
XUNL, KEY, SECRET = 'https://www.xunl.net', 'SITE_PUBLIC_KEY', os.environ['XUNL_CONNECTOR_SECRET']

def confirm_inquiry(xunl_context, submission_id):
    try:
        ctx = json.loads(xunl_context or 'null')
    except ValueError:
        return False
    if not ctx or ctx.get('preview') or not ctx.get('visitorId') or not ctx.get('assignments'):
        return False
    body = json.dumps({'eventId': str(uuid.uuid4()), 'submissionId': submission_id, 'visitorId': ctx['visitorId'],
        'acceptedAt': datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
        'assignments': ctx['assignments'], 'status': 'accepted'}).encode()
    path = f'/x/ab/v1/sites/{KEY}/conversions'
    for attempt in range(3):  # retries keep the body, sign again
        ts, nonce = str(int(time.time())), str(uuid.uuid4())
        canonical = f'v1\nPOST\n{path}\n{ts}\n{nonce}\n{hashlib.sha256(body).hexdigest()}'
        sig = hmac.new(SECRET.encode(), canonical.encode(), hashlib.sha256).hexdigest()
        req = urllib.request.Request(XUNL + path, data=body, method='POST', headers={'Content-Type': 'application/json',
            'X-XunL-Timestamp': ts, 'X-XunL-Nonce': nonce, 'X-XunL-Signature': 'v1=' + sig})
        try:
            with urllib.request.urlopen(req, timeout=10):
                return True
        except urllib.error.HTTPError as e:
            if e.code != 409 and e.code < 500:
                return False
        except OSError:
            pass
        time.sleep(2 * (attempt + 1))
    return False
PHP
<?php // PHP 7.4+ with cURL. Call after your server has saved or sent the inquiry.
function xunl_uuid(): string { $b = random_bytes(16); $b[6] = chr(ord($b[6]) & 0x0f | 0x40); $b[8] = chr(ord($b[8]) & 0x3f | 0x80);
    return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4)); }

function xunl_confirm_inquiry(string $xunlContext, string $submissionId): bool {
    $xunl = 'https://www.xunl.net'; $key = 'SITE_PUBLIC_KEY'; $secret = getenv('XUNL_CONNECTOR_SECRET');
    $ctx = json_decode($xunlContext, true);
    if (!is_array($ctx) || !empty($ctx['preview']) || empty($ctx['visitorId']) || empty($ctx['assignments'])) return false;
    $body = json_encode(['eventId' => xunl_uuid(), 'submissionId' => $submissionId, 'visitorId' => $ctx['visitorId'],
        'acceptedAt' => (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s.v\Z'),
        'assignments' => $ctx['assignments'], 'status' => 'accepted'], JSON_UNESCAPED_SLASHES);
    $path = '/x/ab/v1/sites/' . $key . '/conversions';
    for ($attempt = 0; $attempt < 3; $attempt++) { // retries keep the body, sign again
        $ts = (string) time(); $nonce = xunl_uuid();
        $sig = hash_hmac('sha256', "v1\nPOST\n$path\n$ts\n$nonce\n" . hash('sha256', $body), $secret);
        $ch = curl_init($xunl . $path);
        curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10,
            CURLOPT_HTTPHEADER => ['Content-Type: application/json', "X-XunL-Timestamp: $ts", "X-XunL-Nonce: $nonce", "X-XunL-Signature: v1=$sig"]]);
        curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
        if ($code >= 200 && $code < 300) return true;
        if ($code > 0 && $code !== 409 && $code < 500) return false;
        sleep(2 * ($attempt + 1));
    }
    return false;
}

Step 3: the API contract

Address
POST https://www.xunl.net/x/ab/v1/sites/{SITE_PUBLIC_KEY}/conversions
Headers
Content-Type: application/json · X-XunL-Timestamp · X-XunL-Nonce · X-XunL-Signature: v1=<hex>
String to sign
v1\nPOST\n/x/ab/v1/sites/{key}/conversions\n{timestamp}\n{nonce}\n{sha256(body)}
Algorithm
HMAC-SHA256(connector secret, string to sign) → lowercase hex
Body
{eventId, submissionId, visitorId, acceptedAt, assignments, status: "accepted"}
Time and nonce
Unix seconds within 300 s of server time; nonce is a lowercase UUID, new for every request.
Responses
200 accepted or duplicate; 401 bad signature or time; 409 the browser assignment has not arrived yet, so re-sign the same body and retry later; do not retry other 4xx.

WordPress plugin

  1. Download the plugin ZIP under A/B experiments → Sites & installation, then in WordPress go to Plugins → Add New → Upload Plugin and activate it.
  2. In WordPress → Settings → XunL Experiments, enter the XunL address, site public key and connector secret.
  3. Map the inquiry forms to count as type:form ID, for example cf7:123. Forms not listed are never collected.
  4. Connect visitor consent, then check the connection in the console. The plugin already includes the SDK, so do not add the script again.

Verify the connection

  1. Accept cookies on your site, open the browser developer console and run the command below; consentStatus should be granted.
  2. Back in the console, select Check connection and confirm events and assignments are increasing.
  3. Open the experiment page several times in private windows and confirm both versions display and submit correctly.
JavaScript
XunLExperiments.diagnostics()
// { version, consentStatus, visitorId, assignments, pendingEvents, lastError, ... }

Frequently asked questions

Can I use it on a website that is not WordPress?

Yes. Add the JS SDK script to your shared head to run visual and split-URL experiments and to measure page, click and custom event goals. Accepted inquiries are signed and sent by your own server; the WordPress plugin simply does that step for you.

Why must inquiries be confirmed by the server?

The browser only sees that Submit was clicked, which also includes validation failures, spam and duplicates. When your server signs the inquiry after it has really been saved or sent, the report matches what sales receive.

Will it slow the page down or cause flicker?

The script loads asynchronously and keeps the original page if the configuration fails or times out. Visual changes apply once the configuration arrives, so placing the script early and keeping changes small reduces flicker; for a full redesign use split URL to send visitors to the new page.

Is anything recorded before visitors consent?

No. By default the SDK waits: until XunLExperiments.consent(true) is called or an xunl:consent event fires, it does not touch browser storage, create a visitor ID or send any experiment event. Withdrawing consent clears the local records.

What about single-page apps (React, Vue)?

Back and forward navigation is matched again automatically. After changing routes with history.pushState, call XunLExperiments.refresh() once so the SDK re-evaluates which experiments apply to the new URL.

YOUR NEXT STEP

Turn a website check into a monitoring routine

Create a company account to manage sites and notifications. Pro supports 5 sites and 5-minute availability monitoring, with deep diagnostics available according to your plan and permissions.

New companies receive a 14-day Pro trial, with no card required. Trials exclude the paid monthly credit grant.

Already have an account? Open your workspace →

A/B testing setup guide practical guide and common questions

What does an A/B testing setup guide do?

Compares the two ways to connect, shows where each site builder takes the script, covers cookie consent, the four goal types and single-page apps, and gives complete Node.js, Python and PHP examples with the signed API contract for confirming inquiries.

Who is this for, and when is it useful?

Site owners and developers who want A/B tests on Shopify, Webflow, Wix, custom sites or WordPress, with accepted inquiries counted in the results.

How do I use this A/B testing setup guide?

Pick a method from the contents: add the JS SDK to the head of any site or install the WordPress plugin, connect consent, set goals, and confirm inquiries from your server with the examples.

How should I interpret the results?

Connecting needs a XunL account and A/B experiments are a paid feature; adapt the examples to your forms and server, and keep the connector secret on the server only.

Where is the work performed, and which services receive data?

This is a public guide; reading it and copying the examples needs no sign-in, and the page collects nothing you type.