for Shopify

For developers

API documentation

For stores whose landing pages live outside Shopify: the headless API assigns versions, records visits and attributes orders with the same engine the on-Shopify pages use. This is the same reference the app shows inside the product; your API keys live in the app's Headless page once installed.

What this is for

Any page you host yourself that sells through your Shopify store. A Hydrogen or custom storefront, or a marketing landing page on your own CMS. There is no Liquid theme, so the app embed that runs every other delivery mode is not there. You ask us which version a visitor gets, you render it, and you pass the attribution token to the cart so the order comes back to the right version.

It is also the only mode with no flicker. Every client-side variant is swapped in after the page paints, so version B pays a delay the control does not and a structural test partly measures render speed. Asking before you render removes that.

This mode expects a developer. Everything it needs is documented here, but wiring it into your own site is your side of the line. If you would rather not, ask about implementation support.

Which of these is you

You haveYou useFlicker
Your own split already (a CMS, Optimizely, VWO, homegrown)/api/v1/visit to record what you chosenone
A server or edge worker that renders the page/api/v1/assign before you rendernone
A static site, no server, no split logicThe drop-in script belowmanaged, see below

The first two are strictly better and the first one is not a downgrade - keep the testing tool you have, and let us supply the Shopify revenue truth it cannot see by itself.

Drop-in script

<script src="http://cbsplit.io/lander.js"
        data-key="cbs_pk_your_key_here"
        data-test="18"
        data-shop="your-store.myshopify.com"></script>

Mark the parts that differ, and leave everything both versions share alone:

<div data-cbs-version="a">
  <h1>Your control headline</h1>
  <a href="https://your-store.myshopify.com/cart/123:1">Buy now</a>
</div>

<div data-cbs-version="b">
  <h1>Your variant headline</h1>
  <a href="https://your-store.myshopify.com/cart/123:1">Get instant access</a>
</div>

<!-- Links are stamped for you. Anything outside a marked block is shared by both versions. -->

Add data-version="a" if you split your own traffic and only want the visit recorded. Add data-debug="1" while wiring up.

What the script costs you

If we decide the version in the browser, the variant area cannot render until the answer arrives. Showing version A immediately and swapping to B on arrival would make B slower on every single load, so the test would partly measure our network latency rather than your change. On a landing page, where speed often IS the conversion lever, that bias can exceed the effect you are hunting.

So the marked areas are hidden for every version until the decision lands. Everyone is equally slower and nobody is slower than the other arm. If the request fails or is blocked, the page reveals itself after 2.5 seconds showing the control, because a page that sells beats a page that measures.

Record a visit

Tells us a visitor saw a version you already chose. This is what gives a test its denominator - without it you get sales per version and no conversion rate.

Use it when: Your site, CMS or existing testing tool splits its own traffic and you want us to score it.

GET or POST  http://cbsplit.io/api/v1/visit
ParameterMeaning
keyrequiredYour publishable key. May also be sent as an x-cbsplit-key header.
testrequiredThe test id. Naming the test beats matching a URL, because your page's URL is on another domain.
versionrequiredWhich version you served: a, b, c… Must be a version that exists on this test.
visitorrequiredA stable per-person id. Any format, 6-200 characters; anything that is not a UUID is hashed into one, stably.
pathoptionalThe full landing URL. UTM tags and click ids are read out of its query string, so sending this alone gives you campaign tracking with no extra params.
countryoptionalTwo-letter country code.
deviceoptionaldesktop, mobile or tablet. Anything else falls back to desktop rather than being stored as junk.
affiliateoptionalYour own affiliate identifier. Deliberately not inferred from utm_source - a source is a channel, an affiliate is someone you pay.
utm_*optionalAny utm_ param, plus gclid, fbclid, ttclid, msclkid, twclid, li_fat_id, irclickid. Explicit params override anything found in path.

Returns

{
  "ok": true,
  "testId": 18,
  "version": "b",
  "counted": true,        // false on a repeat: the visitor was already recorded
  "reassigned": false,    // true if they were already on a DIFFERENT version, which we keep
  "cbs": "MTg6Yjo…"       // per-visitor attribution token, if you can put it on your links
}

Errors, returned with a 400 so your webhook engine can alarm on them

missing_testNo test param.
missing_versionNo version param.
empty_versionThe param arrived empty - a template variable did not resolve.
missing_visitorNo usable visitor id.
empty_visitorThe param arrived empty - a template variable did not resolve.
unknown_testNo running test with that id on this account.
unknown_versionThat version is not on this test.
unauthorizedMissing, unknown or revoked key. 401.

Idempotent on (test, visitor). Retrying is what webhook engines are built to do, and a double-counted visit does not error - it silently deflates your conversion rate.

Ask which version to serve

We choose the version and return it, along with the attribution token. Assignment is sticky on the visitor id, and the visit is recorded by this same call.

Use it when: You have no split logic of your own. Call it from your server BEFORE rendering, and there is no flicker at all.

GET or POST  http://cbsplit.io/api/v1/assign
ParameterMeaning
keyrequiredYour publishable key. May also be sent as an x-cbsplit-key header.
visitorrequiredA stable per-person id, as a UUID. It must outlive the page view - a new id per request puts the same person in a different version each time.
testoptionalThe test id. Without it we match the page against your tests' targets using path and handle.
pathoptionalThe page URL, used for target matching and recorded as the landing URL.
handleoptionalThe product handle, if you know it. Otherwise it is read out of path.
countryoptionalTwo-letter country code.
deviceoptionaldesktop, mobile or tablet.
affiliateoptionalYour own affiliate identifier.

Returns

{
  "test": true,              // false when this page has no running test - render normally
  "testId": 42,
  "version": "b",            // sticky for this visitor from here on
  "deliveryMode": "headless",
  "ops": [ … ],              // element changes, if the test was authored that way
  "view": null,              // alternate-template suffix, when the test uses one
  "cbs": "eyJ0…"             // attribution token - must reach the order
}

Every "no" returns the same { test: false }, on purpose: an anonymous browser caller must not be able to learn which tests exist by probing. The named errors above are the visit endpoint only, which is a server-to-server call from an authenticated integrator.

Attribution params on your buy links

ParameterMeaning
cbsrequiredThe signed token. From the cbs field of either endpoint's response, or minted once per version if your page cannot consume a response.
attributes[cbs]requiredThe same value again. A cart permalink redirects, and whether Shopify records the requested URL or the redirect target is not something to depend on - this form survives as a cart attribute either way.
cbsvoptionalThe raw visitor id, unsigned. Joins the sale to its visit row so device, country and UTM carry onto the order. Unsigned is safe: it is a join key, and forging it cannot move a sale onto another version.
attributes[cbsv]optionalThe same value again, for the same redirect reason as above.

1 · Ask for an assignment

// Next.js middleware.ts - assign BEFORE the page renders, so version B has no flicker.
import { NextResponse } from "next/server";

export async function middleware(request) {
  const cookies = request.cookies;
  // One stable id per visitor. Assignment is sticky on this, so it must outlive the page view.
  let visitor = cookies.get("cb_vid")?.value;
  const fresh = !visitor;
  if (fresh) visitor = crypto.randomUUID();

  const res = await fetch("http://cbsplit.io/api/v1/assign", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      key: "cbs_pk_your_key_here",
      visitor,
      path: request.nextUrl.pathname,
    }),
  });
  const a = await res.json();

  const response = NextResponse.next();
  if (fresh) response.cookies.set("cb_vid", visitor, { maxAge: 31536000, sameSite: "lax" });

  if (a.test) {
    // Your app reads these and renders accordingly.
    response.headers.set("x-cb-version", a.version);
    // cbs is the attribution token. It must reach the order - see the cart step below.
    response.cookies.set("cb_cbs", a.cbs, { maxAge: 86400, sameSite: "lax" });
  }
  return response;
}

The visitor id must be stable and outlive the page view - assignment is sticky on it, so a new id every request would put the same person in a different version each time and make the split meaningless.

2 · What comes back

{
  "test": true,              // false when this page has no running test - render normally
  "testId": 42,
  "version": "b",            // which version this visitor is in, sticky from here on
  "deliveryMode": "headless",
  "ops": [ ... ],            // element changes, if the test was authored that way
  "view": null,              // alternate-template suffix, when the test uses one
  "prices": {                // PRESENT ONLY when this arm changes a price (a price test):
    "discount": { "value": 10, "valueType": "percentage" },
    "productId": "9911",     // render these SKUs at the discounted figure - your storefront
    "variants": [            // shows it, Shopify's checkout charges it, from this one value
      { "id": "8801", "was": 40, "now": 36 }   // base-currency advisory figures
    ]
  },
  "cbs": "eyJ0..."           // attribution token - must reach the order
}

3 · Carry attribution to the order

# Hydrogen / Storefront API - carry attribution to the order.
#
# The token has to survive checkout, and the only things that do are cart attributes and line item
# properties. Set it as a cart attribute named "cbs" and it arrives on the order as a note
# attribute, which is exactly where cbsplit looks for it.

mutation AttachCbsplit($cartId: ID!, $attributes: [AttributeInput!]!) {
  cartAttributesUpdate(cartId: $cartId, attributes: $attributes) {
    cart { id }
    userErrors { field message }
  }
}

# variables:
# { "cartId": "gid://shopify/Cart/...",
#   "attributes": [{ "key": "cbs", "value": "<the cbs value from /api/v1/assign>" }] }

Without this step a test records visits and never records a sale, so every version reports a 0% conversion rate - which looks like a real result rather than a missing integration.