Server-Side Experimentation

Server-side bucketing and conversion patterns for any server-capable SDK

Server-side experimentation moves the bucketing decision out of the browser and into your application server, edge worker, or backend job. The same SDK methods do the work in every Convert SDK — JavaScript, PHP, and Ruby today, and the same pattern will apply to future SDKs (iOS, Android, etc.) when they ship. Only the entry point and the way the decision reaches the visitor change.

Code samples below are shown in JavaScript, PHP, and Ruby. The shapes (cookie-based visitor ID, request-derived audience/location properties, server-side conversion trigger) translate directly to any other server-capable SDK.

This guide covers the cases that motivate going server-side, the patterns for stable visitor identity, how to bridge a server-side decision to a client that needs to render the variant, and the options for triggering conversions from the server. Nothing here is prescriptive — pick the pattern that fits your stack. For a UI-concept-to-SDK translation reference (audience rule keys, goal trigger recipes, experience types), see From the Tracking Script to the SDK.

When Server-Side Makes Sense

Some situations where moving the decision to the server pays off:

  • SSR / static rendering: the HTML that ships to the browser already needs the variant baked in (no flicker, no client-side swap, search engines see the right content).
  • Decisions that depend on data the browser doesn't have: account-level entitlements, plan tiers, ML model output, anything you wouldn't want to expose to the client.
  • Headless commerce / Hydrogen / custom storefronts: the client surface is several distinct contexts (storefront, checkout, account area, mobile app) and the cleanest place to make one consistent decision is upstream.
  • Mobile / API backends: a native app calls your API; the decision belongs at the API boundary, not in the app shell.
  • Edge experimentation: a Cloudflare Worker / Lambda@Edge / Vercel Edge function buckets at the CDN tier and rewrites the response before it reaches the browser. See Cloudflare Workers for an edge-specific walkthrough.

If your experiment lives entirely on a single page rendered in the browser with no SSR concerns, server-side bucketing adds complexity without much payoff — Client-Side Experimentation covers that case.

Stable Visitor Identity

Server-side bucketing depends on a stable visitor ID. The SDK doesn't generate one for you — createContext(visitorId, …) accepts the ID you provide, and your bucketing decision is only as deterministic as that ID. A few approaches:

  • Read a cookie if present, set one if not. Common for browser-facing requests. The same cookie can be read by the server (to bucket) and the client (to attribute later conversions to the same visitor). Pick a stable cookie name and document it for the rest of your team.
  • Use your own session / user ID when the visitor is authenticated. Reuse whatever ID identifies the user across sessions in your system.
  • Synthesize an ID from a request fingerprint as a last resort for environments without cookies (e.g. API clients). Be aware that fingerprint-based IDs aren't stable across IP or User-Agent changes.

Whichever you pick, persist the ID before you call createContext, and make sure the same ID is used everywhere downstream — server-side rendering, client-side hydration, conversion tracking, and any other Convert SDK call for that visitor.

// Express-style example. Adapt the cookie API to your framework.
import { randomUUID } from 'node:crypto';
import ConvertSDK from '@convertcom/js-sdk';

const sdk = new ConvertSDK({ sdkKey: process.env.CONVERT_SDK_KEY });

app.use(async (req, res, next) => {
  await sdk.onReady();

  let visitorId = req.cookies['cv_vid'];
  if (!visitorId) {
    visitorId = randomUUID();
    res.cookie('cv_vid', visitorId, {
      httpOnly: false, // the browser-side SDK needs to read it too
      secure: true,
      sameSite: 'lax',
      maxAge: 365 * 24 * 60 * 60 * 1000
    });
  }
  req.convertContext = sdk.createContext(visitorId);
  next();
});
use ConvertSdk\ConvertSDK;

$sdk = ConvertSDK::create(['sdkKey' => getenv('CONVERT_SDK_KEY')]);

if (!isset($_COOKIE['cv_vid'])) {
    $visitorId = bin2hex(random_bytes(16));
    setcookie('cv_vid', $visitorId, [
        'expires'  => time() + 365 * 24 * 60 * 60,
        'path'     => '/',
        'secure'   => true,
        'httponly' => false,
        'samesite' => 'Lax',
    ]);
} else {
    $visitorId = $_COOKIE['cv_vid'];
}

if ($sdk->isReady()) {
    $context = $sdk->createContext($visitorId);
}
# config/initializers/convert_sdk.rb — build one client at boot.
# `create` fetches config synchronously and fires `ready`, so there's no
# per-request readiness gate to await (unlike the JS/PHP examples above).
require "convert_sdk"
CONVERT_SDK = ConvertSdk.create(sdk_key: ENV.fetch("CONVERT_SDK_KEY"))

# app/controllers/concerns/convert_context.rb — one context per request.
module ConvertContext
  extend ActiveSupport::Concern
  included { before_action :assign_convert_context }

  private

  def assign_convert_context
    @convert_vid = cookies[:cv_vid]
    unless @convert_vid
      @convert_vid = SecureRandom.uuid
      cookies[:cv_vid] = {
        value:     @convert_vid,
        expires:   1.year.from_now,
        secure:    true,
        httponly:  false, # the browser-side SDK needs to read it too
        same_site: :lax
      }
    end
    @convert_context = CONVERT_SDK.create_context(@convert_vid)
  end
end
# Build one Core at boot. initialize() fetches config synchronously and blocks
# until ready, so there's no per-request readiness gate (unlike the JS/PHP examples).
import os
import uuid
from flask import Flask, request, g
from convert_sdk import Core, SDKConfig

app = Flask(__name__)
CONVERT = Core(SDKConfig(sdk_key=os.environ["CONVERT_SDK_KEY"])).initialize()


@app.before_request
def assign_convert_context():
    vid = request.cookies.get("cv_vid")
    g.convert_new_vid = vid is None
    g.convert_vid = vid or str(uuid.uuid4())
    g.convert_ctx = CONVERT.create_context(g.convert_vid)


@app.after_request
def set_convert_cookie(resp):
    if g.get("convert_new_vid"):
        # httponly=False so the browser-side SDK can read the same id
        resp.set_cookie("cv_vid", g.convert_vid, max_age=365 * 24 * 60 * 60,
                        secure=True, httponly=False, samesite="Lax")
    return resp

See Visitor Context & Properties for what you can attach to the visitor (visitor properties, default segments) and how they participate in audience evaluation.

Bucket at the Request Handler

Once you have a context, bucket the experiences relevant to the request. You can bucket every active experience or just the one(s) the current page cares about — whichever keeps the request handler tidy.

Important — pass the targeting context yourself. Server-side, the SDK has no window, no document, no navigator. Every URL, geo, device, browser, UTM, or cookie value your audience and location rules reference has to be derived from the incoming request and passed via locationProperties / visitorProperties. Omitting a key your rule references means the rule can't evaluate and the visitor isn't bucketed — even if every other condition matched. The full key-by-key mapping is in From the Tracking Script to the SDK.

const variation = req.convertContext.runExperience('homepage-hero', {
  locationProperties: {
    url:      req.protocol + '://' + req.get('host') + req.originalUrl,
    path:     req.path,
    hostname: req.hostname,
    referrer: req.get('referer')
  },
  visitorProperties: {
    country:     req.get('cf-ipcountry') ?? req.get('x-vercel-ip-country') ?? 'US',
    device:      detectDeviceFromUA(req.get('user-agent')),
    browser:     detectBrowserFromUA(req.get('user-agent')),
    visitorType: req.cookies['cv_vid'] ? 'returning' : 'new',
    utm_source:  req.query.utm_source,
    utm_medium:  req.query.utm_medium
  }
});

// Or, for all active experiences:
const allVariations = req.convertContext.runExperiences({
  locationProperties: { /* same shape */ },
  visitorProperties:  { /* same shape */ }
});
use ConvertSdk\DTO\BucketedVariation;
use OpenAPI\Client\BucketingAttributes;

/** @var BucketedVariation|null $variation */
$variation = $context->runExperience('homepage-hero', new BucketingAttributes([
    'locationProperties' => [
        'url'      => $request->fullUrl(),
        'path'     => $request->path(),
        'hostname' => $request->getHost(),
        'referrer' => $request->header('referer'),
    ],
    'visitorProperties' => [
        'country'     => $request->header('cf-ipcountry') ?? 'US',
        'device'      => detectDeviceFromUA($request->header('user-agent')),
        'browser'     => detectBrowserFromUA($request->header('user-agent')),
        'visitorType' => isset($_COOKIE['cv_vid']) ? 'returning' : 'new',
        'utm_source'  => $request->query('utm_source'),
        'utm_medium'  => $request->query('utm_medium'),
    ],
]));

$variations = $context->runExperiences(new BucketingAttributes([/* same shape */]));
# Visitor properties are flat top-level keys; `location_properties` is the
# reserved nested key (alongside `environment` and `enable_tracking`). Keys are
# matched verbatim against your rule keys, so pass them exactly as configured.
variation = @convert_context.run_experience("homepage-hero", {
  country:     request.headers["CF-IPCountry"] || "US",
  device:      detect_device(request.user_agent),
  browser:     detect_browser(request.user_agent),
  visitorType: cookies[:cv_vid] ? "returning" : "new",
  utm_source:  params[:utm_source],
  utm_medium:  params[:utm_medium],
  location_properties: {
    url:      request.original_url,
    path:     request.path,
    hostname: request.host,
    referrer: request.referer
  }
})

# Or, for all active experiences:
variations = @convert_context.run_experiences({
  country: request.headers["CF-IPCountry"] || "US",
  location_properties: { url: request.original_url }
})
# Server-side there is no window/navigator — derive every rule input from the
# request and pass audiences via attributes=, locations via location_attributes=:
variation = g.convert_ctx.run_experience(
    "homepage-hero",
    attributes={
        "country": request.headers.get("CF-IPCountry", "US"),
        "device": detect_device(request.user_agent.string),
        "browser": detect_browser(request.user_agent.string),
        "visitorType": "returning" if request.cookies.get("cv_vid") else "new",
        "utm_source": request.args.get("utm_source"),
        "utm_medium": request.args.get("utm_medium"),
    },
    location_attributes={
        "url": request.url,
        "path": request.path,
        "hostname": request.host,
        "referrer": request.referrer,
    },
)

# Or, for all active experiences:
variations = g.convert_ctx.run_experiences(
    attributes={"country": request.headers.get("CF-IPCountry", "US")},
    location_attributes={"url": request.url},
)

If most experiences share the same visitorProperties, set them once at context creation and they'll apply to every subsequent call: sdk.createContext(visitorId, { country, device, browser, ... }). Inline visitorProperties on a runExperience call override the context-level defaults for that call.

The bucketing call enqueues an exposure event automatically. In short-lived environments (serverless functions, edge workers, single PHP requests) the SDK flushes the queue at the end of the request via a built-in shutdown hook on PHP and via timers / explicit calls on Node. If your runtime is unusually short-lived (e.g. a Cloudflare Worker), call releaseQueues() before the response is sent — see Cloudflare Workers for the edge-specific flush pattern.

See Running Experiences for parameters and return-value details.

Consent

The SDK doesn't gate calls on consent. If your traffic is subject to consent rules (GDPR, ePrivacy, etc.) and the consent decision is observable on the server (e.g. via a consent cookie set client-side), gate runExperience and trackConversion behind it the same way you'd gate any other tracking:

if (req.cookies['consent'] === 'granted') {
  const variation = req.convertContext.runExperience('homepage-hero', { /* ... */ });
}
if (($_COOKIE['consent'] ?? null) === 'granted') {
    $variation = $context->runExperience('homepage-hero', new BucketingAttributes([/* ... */]));
}
if cookies[:consent] == "granted"
  variation = @convert_context.run_experience("homepage-hero", { country: "US" })
end

# Or evaluate but stay silent on the wire: pass `enable_tracking: false` per call
# (bucketing and sticky persistence still run; only the outbound event is suppressed),
# or build the client with `ConvertSdk.create(..., tracking: false)`.
variation = @convert_context.run_experience("homepage-hero", { enable_tracking: false })
# run_experience enqueues a bucketing/activation event when enable_tracking is True
# (the default); track_conversion enqueues the conversion. To evaluate but stay
# silent on the wire, pass enable_tracking=False per call — bucketing and sticky
# persistence still run, only the outbound event is suppressed. Gate the wire
# traffic (bucketing event and conversion) on the consent signal:
consented = request.cookies.get("consent") == "granted"
variation = g.convert_ctx.run_experience(
    "homepage-hero", attributes={"country": "US"}, enable_tracking=consented
)
if consented:
    g.convert_ctx.track_conversion("signup_completed")

If consent is purely a client-side concern, your server can bucket freely (decisions are deterministic and local) but hold back the wire traffic until consent is observed: either gate the runExperience / trackConversion calls on the consent signal (as in the cookie check above), or initialize with network: { tracking: false } and call releaseQueues() once consent lands. Tracking mode is set at init — there's no runtime toggle to flip.

Bridge the Decision to the Client (When You Need To)

If the variant only affects the server-rendered HTML, you're done — render the right thing and return the response. Many setups also need the client to know which variation the visitor is in, either to keep the experience consistent across SPA navigations or to fire client-side conversions. A few options:

Render the assignment into the HTML

Inline the variation key as a data- attribute on the root, a window.__CV__ JSON blob, or a meta tag. The client reads it on load and uses it without re-running the SDK in the browser:

<html data-cv-variations='{"homepage-hero":"version-b"}'>
  <head>
    <!-- or, equivalently, a script tag -->
    <script>window.__CV__ = {"homepage-hero":"version-b"};</script>

Escape what you inline. Serialize the payload with JSON.stringify and HTML-escape it before embedding (<, >, &, and </ inside a <script>); never write a raw cookie or visitor-supplied value into the markup unescaped.

Cheap and works for any framework. The client doesn't need the SDK at all if it isn't going to bucket new experiences or trigger conversions itself.

Use the cookie you already set for the visitor ID

If the client-side SDK is also running (e.g. for conversions or for new bucketing decisions the server didn't make), give it the same visitor ID via the cookie you set above. createContext(cookieValue, …) produces the same bucketing decision the server made, deterministically. No bridge data needed — just keep the visitor ID consistent.

Pass the decision through your framework's data-loading layer

Frameworks like Next.js, Remix, Hydrogen, Nuxt, Astro, Laravel, and Symfony have a server-to-client data channel built in (getServerSideProps, loader, view variables, template context, etc.). Pass the bucketed variation through it like any other server-derived prop. This is usually the most natural option in those frameworks because it lines up with how you'd pass any other server-decided state.

// Remix / Hydrogen loader
export async function loader({ context, request }) {
  const cv = context.convertContext; // from your visitor-id middleware
  const variation = cv.runExperience('homepage-hero');
  return json({ variation });
}

export default function Home() {
  const { variation } = useLoaderData();
  return variation?.key === 'version-b' ? <HeroB /> : <HeroA />;
}
// Laravel controller — pass the variation to the Blade view
public function index(Request $request)
{
    $context  = $request->attributes->get('convertContext'); // from your visitor-id middleware
    $variation = $context->runExperience('homepage-hero');

    return view('home', [
        'variation' => $variation, // a BucketedVariation or null
    ]);
}
@if ($variation?->variationKey === 'version-b')
    <x-hero-b />
@else
    <x-hero-a />
@endif
# Rails controller — expose the variation to the view.
class HomeController < ApplicationController
  include ConvertContext # the before_action that sets @convert_context

  def index
    @variation = @convert_context.run_experience("homepage-hero")
    # @variation&.key is "version-b", another key, or nil (a Sentinel miss)
  end
end
<% if @variation&.key == "version-b" %>
  <%= render "hero_b" %>
<% else %>
  <%= render "hero_a" %>
<% end %>
# Flask view — bucket, then hand the variation (and visitor id) to the template
@app.route("/")
def home():
    variation = g.convert_ctx.run_experience("homepage-hero")
    # variation.variation_key is "version-b", another key, or None (a miss)
    return render_template(
        "home.html",
        variation=variation,
        vid=g.convert_vid,   # expose so the client SDK reuses the same id
    )

# In templates/home.html (Jinja):
#   {% if variation and variation.variation_key == "version-b" %}
#     {% include "hero_b.html" %}
#   {% else %}
#     {% include "hero_a.html" %}
#   {% endif %}

Set a separate signal cookie

When you don't have a framework data channel and don't want to render the decision into HTML, a small additional cookie or response header that the client can read works. Use this sparingly — adding cookies has cost, and the visitor-ID cookie plus client-side runExperience already covers most cases.

Trigger Conversions

You have two broad options for conversions, and most apps use both depending on where the goal completes:

Server-side conversion

When the goal completion happens on the server (an order webhook fires, an API endpoint is hit, a backend job processes a renewal), call trackConversion from the same server context — using the same visitor ID that was used to bucket the experience earlier. This is the cleanest path when the conversion event is something your server already handles.

Verify the webhook before trusting it. An order/event webhook is an unauthenticated public endpoint — verify the platform's signature (e.g. Shopify's X-Shopify-Hmac-SHA256, or your own shared-secret HMAC) and reject mismatches before calling trackConversion, or anyone can forge conversions and revenue. The handlers below omit the check for brevity.

// Example: order webhook handler
app.post('/webhooks/order-paid', async (req, res) => {
  await sdk.onReady();
  const visitorId = req.body.customer_attributes?.cv_vid; // however you carry it
  if (!visitorId) return res.sendStatus(204);

  const ctx = sdk.createContext(visitorId);
  ctx.trackConversion('purchase-completed', {
    conversionData: [
      { key: 'amount', value: req.body.total },
      { key: 'transactionId', value: req.body.order_id }
    ]
  });
  await ctx.releaseQueues();
  res.sendStatus(204);
});
use ConvertSdk\DTO\ConversionAttributes;
use ConvertSdk\DTO\GoalData;
use ConvertSdk\Enums\GoalDataKey;

// Example: order webhook handler (Laravel controller)
public function orderPaid(Request $request)
{
    $payload   = $request->json()->all();
    $visitorId = $payload['customer_attributes']['cv_vid'] ?? null; // however you carry it
    if (!$visitorId) {
        return response()->noContent();
    }

    $context = $this->sdk->createContext($visitorId);
    $context->trackConversion('purchase-completed', new ConversionAttributes(
        conversionData: [
            new GoalData(GoalDataKey::Amount,        $payload['total']),
            new GoalData(GoalDataKey::TransactionId, $payload['order_id']),
        ],
    ));
    // PHP flushes the queue automatically at request end via register_shutdown_function.

    return response()->noContent();
}
# Example: order webhook handler (Rails controller)
class OrderWebhooksController < ActionController::API
  def paid
    visitor_id = params.dig(:customer_attributes, :cv_vid) # however you carry it
    return head(:no_content) unless visitor_id

    context = CONVERT_SDK.create_context(visitor_id)
    context.track_conversion(
      "purchase-completed",
      goal_data: { amount: params[:total], transaction_id: params[:order_id] }
    )
    # No explicit flush: a long-running Rails server drains via the background
    # flush timer. In a Sidekiq job, flush on :shutdown instead (see Fork Safety).
    head :no_content
  end
end
# Example: order webhook handler (Flask). Verify the platform signature before
# trusting the payload (omitted here for brevity).
@app.post("/webhooks/order-paid")
def order_paid():
    payload = request.get_json(silent=True) or {}
    visitor_id = (payload.get("customer_attributes") or {}).get("cv_vid")  # however you carry it
    if not visitor_id:
        return "", 204
    ctx = CONVERT.create_context(visitor_id)
    ctx.track_conversion(
        "purchase-completed",
        revenue=payload.get("total"),
        conversion_data={"transaction_id": payload.get("order_id")},
    )
    CONVERT.flush()   # short-lived request path: deliver before returning
    return "", 204

The hard part is usually carrying the visitor ID from the original bucketing context to the goal-completion context — webhooks don't have your cookies, so you typically pass the ID through whatever channel does survive: cart attributes, order metadata, a hidden form field, a custom header. Whatever you pick, write it down so future code reads from the same place.

Client-side conversion

When the goal completion happens in the browser (CTA click, form submit, in-page event), call trackConversion from the client-side SDK using the same visitor ID. The server doesn't need to be involved.

This is mechanically the same as the conversion path in Client-Side Experimentation — the only difference is that the bucketing decision was made server-side. Both decisions go to the same visitor and the same project, so the conversion attaches correctly as long as the visitor ID matches.

Hybrid (server buckets, client converts)

Common for SSR setups: the server makes the decision (so the SSR markup is consistent and pre-rendered) and the client triggers conversions (because the goal happens in the browser, post-render). Both call sites use the same visitor ID; the client uses createContext(visitorIdFromCookie) and calls trackConversion. No additional bridge data needed.

Persistence Across Sessions

Server-side, persistence usually falls out naturally — your application probably already has a database or session store you can wrap as a dataStore for the SDK to read/write bucketing decisions. Provide it at SDK init and the SDK will use it for dedup and for re-using previous bucketing decisions when a visitor returns. See Persistent DataStore for the interface and examples.

If you don't pass a dataStore, the SDK keeps bucketing state in memory for the lifetime of the current process/request — fine for stateless servers where bucketing is deterministic on every call (same visitor ID → same variation), but goal-dedup state won't carry across requests without persistence.

Putting It Together

A minimal end-to-end SSR flow with a hybrid conversion pattern.

import express from 'express';
import cookieParser from 'cookie-parser';
import { randomUUID } from 'node:crypto';
import ConvertSDK from '@convertcom/js-sdk';

const app = express();
app.use(cookieParser());

const sdk = new ConvertSDK({
  sdkKey: process.env.CONVERT_SDK_KEY,
  dataStore: yourPersistentDataStore() // see Persistent DataStore
});

// 1. Stable visitor identity
app.use(async (req, res, next) => {
  await sdk.onReady();
  let vid = req.cookies['cv_vid'];
  if (!vid) {
    vid = randomUUID();
    res.cookie('cv_vid', vid, { secure: true, sameSite: 'lax', maxAge: 31536000000 });
  }
  req.convert = { vid, ctx: sdk.createContext(vid) };
  next();
});

// 2. Server-side bucketing — variation baked into the SSR'd HTML
app.get('/', (req, res) => {
  const variation = req.convert.ctx.runExperience('homepage-hero');
  res.send(renderHTML({
    variation,
    vid: req.convert.vid // expose to the client so it can use the same ID
  }));
});

// 3. Server-side conversion on an order webhook
app.post('/webhooks/order-paid', async (req, res) => {
  await sdk.onReady();
  const vid = req.body.customer_attributes?.cv_vid;
  if (!vid) return res.sendStatus(204);
  const ctx = sdk.createContext(vid);
  ctx.trackConversion('purchase-completed', {
    conversionData: [
      { key: 'amount', value: req.body.total },
      { key: 'transactionId', value: req.body.order_id }
    ]
  });
  await ctx.releaseQueues();
  res.sendStatus(204);
});
use ConvertSdk\ConvertSDK;
use ConvertSdk\DTO\ConversionAttributes;
use ConvertSdk\DTO\GoalData;
use ConvertSdk\Enums\GoalDataKey;
use Illuminate\Http\Request;

// Boot the SDK once at app startup (e.g. in a service provider).
$sdk = ConvertSDK::create([
    'sdkKey'    => env('CONVERT_SDK_KEY'),
    'dataStore' => yourPersistentDataStore(), // see Persistent DataStore
]);

// 1. Stable visitor identity (Laravel middleware)
class ConvertContextMiddleware
{
    public function __construct(private ConvertSDK $sdk) {}

    public function handle(Request $request, Closure $next)
    {
        if (!$this->sdk->isReady()) {
            return $next($request);
        }

        $vid = $request->cookie('cv_vid');
        if (!$vid) {
            $vid = bin2hex(random_bytes(16));
            cookie()->queue('cv_vid', $vid, 60 * 24 * 365, '/', null, true, false, false, 'lax');
        }

        $request->attributes->set('convertVid', $vid);
        $request->attributes->set('convertContext', $this->sdk->createContext($vid));

        return $next($request);
    }
}

// 2. Server-side bucketing — variation baked into the rendered view
class HomeController
{
    public function index(Request $request)
    {
        $context  = $request->attributes->get('convertContext');
        $variation = $context?->runExperience('homepage-hero'); // null-safe: middleware may skip when the SDK isn't ready

        return view('home', [
            'variation' => $variation,
            'vid'       => $request->attributes->get('convertVid'), // expose for client SDK
        ]);
    }
}

// 3. Server-side conversion on an order webhook
class OrderWebhookController
{
    public function __construct(private ConvertSDK $sdk) {}

    public function paid(Request $request)
    {
        $payload = $request->json()->all();
        $vid     = $payload['customer_attributes']['cv_vid'] ?? null;
        if (!$vid) {
            return response()->noContent();
        }

        $context = $this->sdk->createContext($vid);
        $context->trackConversion('purchase-completed', new ConversionAttributes(
            conversionData: [
                new GoalData(GoalDataKey::Amount,        $payload['total']),
                new GoalData(GoalDataKey::TransactionId, $payload['order_id']),
            ],
        ));
        // No releaseQueues() — PHP flushes on shutdown.

        return response()->noContent();
    }
}
# config/initializers/convert_sdk.rb — build one client at boot.
require "convert_sdk"
CONVERT_SDK = ConvertSdk.create(
  sdk_key: ENV.fetch("CONVERT_SDK_KEY"),
  store:   your_persistent_store # see Persistent DataStore
)

# 1. Stable visitor identity (Rails concern -> before_action)
module ConvertContext
  extend ActiveSupport::Concern
  included { before_action :assign_convert_context }

  private

  def assign_convert_context
    @convert_vid = cookies[:cv_vid]
    unless @convert_vid
      @convert_vid = SecureRandom.uuid
      cookies[:cv_vid] = { value: @convert_vid, expires: 1.year.from_now,
                           secure: true, httponly: false, same_site: :lax }
    end
    @convert_context = CONVERT_SDK.create_context(@convert_vid)
  end
end

# 2. Server-side bucketing — variation baked into the rendered view
class HomeController < ApplicationController
  include ConvertContext

  def index
    @variation = @convert_context.run_experience("homepage-hero")
    @client_vid = @convert_vid # expose to the client so it reuses the same ID
  end
end

# 3. Server-side conversion on an order webhook
class OrderWebhooksController < ActionController::API
  def paid
    visitor_id = params.dig(:customer_attributes, :cv_vid)
    return head(:no_content) unless visitor_id

    CONVERT_SDK.create_context(visitor_id).track_conversion(
      "purchase-completed",
      goal_data: { amount: params[:total], transaction_id: params[:order_id] }
    )
    # Long-running server: the background flush timer delivers.
    # (Sidekiq job: flush on shutdown. Lambda/CLI: flush before exit.)
    head :no_content
  end
end
import os
import uuid
from flask import Flask, request, g, render_template
from convert_sdk import Core, SDKConfig

app = Flask(__name__)

# Boot the SDK once. Pass data_store=... for cross-request dedup / sticky
# bucketing (see Persistent DataStore); omit it for stateless deterministic use.
CONVERT = Core(SDKConfig(sdk_key=os.environ["CONVERT_SDK_KEY"])).initialize()


# 1. Stable visitor identity
@app.before_request
def assign_convert_context():
    if request.path.startswith("/webhooks"):
        return   # webhooks carry no visitor cookie and build their own context
    vid = request.cookies.get("cv_vid")
    g.convert_new_vid = vid is None
    g.convert_vid = vid or str(uuid.uuid4())
    g.convert_ctx = CONVERT.create_context(g.convert_vid)


@app.after_request
def set_convert_cookie(resp):
    if g.get("convert_new_vid"):
        resp.set_cookie("cv_vid", g.convert_vid, max_age=31536000,
                        secure=True, httponly=False, samesite="Lax")
    return resp


# 2. Server-side bucketing — variation baked into the rendered HTML
@app.route("/")
def home():
    variation = g.convert_ctx.run_experience("homepage-hero")
    return render_template("home.html", variation=variation, vid=g.convert_vid)


# 3. Server-side conversion on an order webhook
@app.post("/webhooks/order-paid")
def order_paid():
    payload = request.get_json(silent=True) or {}
    vid = (payload.get("customer_attributes") or {}).get("cv_vid")
    if not vid:
        return "", 204
    ctx = CONVERT.create_context(vid)
    ctx.track_conversion(
        "purchase-completed",
        revenue=payload.get("total"),
        conversion_data={"transaction_id": payload.get("order_id")},
    )
    CONVERT.flush()   # short-lived request path: deliver before returning
    return "", 204

The browser side can then either:

  • Just render the variation from the SSR'd HTML (no client SDK), or
  • Initialize a client-side SDK with the same cv_vid cookie and call trackConversion for any in-page goals — the bucketing decision the server already made will be replicated deterministically because the visitor ID matches.

Common Pitfalls

  • Different visitor IDs across calls. The server bucketed visitor abc; later the client fires a conversion as xyz. Convert can't link the two. Always keep one ID per visitor, set it once, read it everywhere.
  • Server-side SDK not awaited. createContext before onReady() (JS) / isReady() (PHP) returns true means the SDK hasn't fetched its config yet — bucketing will fail or return null. Always wait for ready, or initialize at server boot and keep the instance warm. (Ruby's ConvertSdk.create fetches config synchronously, so a client built at boot is ready as soon as create returns — just don't decide before it does.)
  • Forgetting to flush in short-lived runtimes. In Node, the SDK's batch timer might not fire before the function exits. Call releaseQueues() before returning from short-lived handlers (serverless, edge functions). PHP flushes automatically on shutdown via register_shutdown_function. Ruby long-running servers drain via the background flush timer, but short-lived runtimes (AWS Lambda, CLI tasks, Sidekiq shutdown) must call flush / release_queues explicitly.
  • Re-creating the SDK per request. Heavy. Initialize the SDK once at process boot and reuse it; create a fresh Context per request from the same SDK instance.
  • Skipping the SDK and POSTing to the tracking API directly. Hand-rolling HTTP requests bypasses the SDK's payload construction, dedup, batching, and retry handling. Use trackConversion.