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.
Choose how to connect
Both use the same site connection, experiments and reports; only installation and inquiry confirmation differ.
| JS SDK | WordPress plugin | |
|---|---|---|
| Websites | Any website: Shopify, Webflow, Wix, Squarespace, tag managers, custom sites | WordPress 6.2 or later |
| Install | Add one script line to the shared head | Upload the plugin ZIP and enter the connection details |
| Page, click and custom event goals | Support | Support |
| Accepted inquiry goal | Signed 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
- Sign in to XunL, add the website under Sites, then create a connection under A/B experiments → Sites & installation.
- 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.
- Connect visitor consent (below), then select Check connection in the console.
<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.
Connect visitor consent
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.
// 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.
| Goal | Counts when | Example |
|---|---|---|
| Pages | The visitor reaches a URL, such as a thank-you page | https://www.ssevening.com/thank-you/ |
| Click | A click on an element matched by a CSS selector | #quote-button |
| Custom event | Reported from your code with track | XunLExperiments.track('quote_started') |
| Accepted inquiry | Your 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.
<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:
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 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 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 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
- Download the plugin ZIP under A/B experiments → Sites & installation, then in WordPress go to Plugins → Add New → Upload Plugin and activate it.
- In WordPress → Settings → XunL Experiments, enter the XunL address, site public key and connector secret.
- Map the inquiry forms to count as type:form ID, for example cf7:123. Forms not listed are never collected.
- 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
- Accept cookies on your site, open the browser developer console and run the command below; consentStatus should be granted.
- Back in the console, select Check connection and confirm events and assignments are increasing.
- Open the experiment page several times in private windows and confirm both versions display and submit correctly.
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.