ProveBetter

Docs

Server-side testing (plain HTTP)

Use this when the decision must live in your code — paywalls, pricing, auth, personalization — not in page content. Two endpoints, no SDK, no client: your backend asks which variant a visitor is in, serves it, and reports the outcome.

Looking for the reasoning, not the API? Read the server-side A/B testing guide — this page is the API reference layer.

Quickstart

  1. Create the test in the builder and pick In my app (server-side). Launch it. Note the test key (shown on the test card and dashboard).
  2. Assign a visitor. Call /api/assign from your backend with a stable visitor id (session id, user id, or a first-party cookie — never something that changes per request):
    curl -X POST https://a66e659b628ef51d9c6d61167f0a3809.ctonew.app/api/assign \
      -H 'Content-Type: application/json' \
      -d '{"testKey":"YOUR_TEST_KEY","visitorId":"user-1234"}'
    {
      "testId": "…",
      "testName": "Checkout paywall",
      "testStatus": "running",
      "variants": [{ "key": "A", "name": "A", "isControl": true }],
      "variantKey": "B",
      "variantName": "B",
      "inTest": true,
      "reason": "new"
    }
    inTest:false with variantKey:null means serve your default (see failure semantics below).
  3. Serve the variant in your code — branch on variantKey when inTest is true:
    const res = await fetch("https://a66e659b628ef51d9c6d61167f0a3809.ctonew.app/api/assign", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ testKey: "YOUR_TEST_KEY", visitorId }),
    });
    const a = await res.json();
    const showPaywallB = a.inTest && a.variantKey === "B";
  4. Report the outcome when the visitor converts:
    curl -X POST https://a66e659b628ef51d9c6d61167f0a3809.ctonew.app/api/events \
      -H 'Content-Type: application/json' \
      -d '{"testKey":"YOUR_TEST_KEY","visitorId":"user-1234","type":"conversion"}'
    Returns 201 with {"event":{"eventId":"…","variantKey":"B","inTest":true,"assignmentCreated":false}}. An optional eventName (max 120 chars) labels the conversion in the dashboard's event breakdown — e.g. send "form_fill" for a form-fill goal.

Stickiness — how the same visitor gets the same variant

Failure semantics — what to do when the answer isn't "B"

The reason field tells you why. Your safe default is always: serve the control / default behavior and never block the request.

reasonMeaningYour code should
newFirst assignment for this visitor.Serve the returned variant.
stickyReturning visitor; stored assignment still holds.Serve the same variant as before.
winnerA winner was declared — everyone in-test gets it.Serve the winning variant.
holdoutVisitor fell outside the test's total traffic.Serve your default.
draft / scheduled / ended / outside_scheduleTest isn't active (status or schedule window).Serve your default; re-check later if you cache.

Verify your integration

  1. Call assign twice with the same visitorId → identical variantKey both times (stickiness).
  2. Call it with a handful of fresh visitorIds → variants land roughly on your configured split.
  3. Send one conversion → open the test's dashboard; the visitor and conversion appear within a minute.
  4. End the test → assign now returns inTest:false, reason:"ended", and your code serves the default.

Client-side testing (headlines, copy, layout) doesn't need any of this — install the snippet instead.