OptimeleonOptimeleon
Request Access
Setup · Advanced

Event hooks

Subscribe to Optimeleon's decisions from your own code and forward them to GTM, your warehouse or your logging. The full event catalog, and the additive-only promise the contract keeps.

For the developer or tracking owner who wants what the Optimeleon script does on each page (which variant a visitor was assigned, whether it rendered, consent changes) inside their own pipeline: Google Tag Manager, a dataLayer, a warehouse loader, or their own logging. You subscribe to events; there is nothing to poll.

We ship events, not integrations: nothing in the script pushes to a dataLayer or any vendor by itself. The wiring is a few lines of your own code, like the examples below. The same examples are in the product under Settings → Integrations.

Quick start

Google Tag Manager

Paste into a Custom HTML tag that fires on every page:

<script>
  window.optimeleon = window.optimeleon || function () {
    var a = arguments;
    (optimeleon.q = optimeleon.q || []).push(a);
    if (a[0] === 'on' || a[0] === 'once') {
      return function () { a.u ? a.u() : (a.x = 1); };
    }
  };
  window.optimeleon('on', 'variant.decided', function (v) {
    if (!v) return; // this page is not being optimized
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push({
      event: 'optimeleon_variant',
      optimeleon_page_url: v.pageUrl,
      optimeleon_campaign_id: v.campaignId,
      optimeleon_variant: v.trackingId,
      optimeleon_arm: v.arm // 'holdout' is your baseline
    });
  });
</script>

The first seven lines are a queue stub that works together with the Optimeleon install snippet. They make the tag safe to fire before the Optimeleon script has loaded: the call waits in a queue and is registered the moment the script starts. If your tag always fires after the install snippet, they are harmless.

Plain JavaScript

window.optimeleon('on', 'variant.decided', function (v, meta) {
  if (!v) return; // this page is not being optimized
  yourWarehouse.send({
    pageUrl: v.pageUrl,
    campaignId: v.campaignId,
    trackingId: v.trackingId, // null on the control arm
    arm: v.arm, // 'holdout' is your baseline
    replayed: meta.replayed
  });
});

Events fire whether or not the visitor has consented. They describe what the script did on the page, and they never leave the browser on their own: the script sends nothing anywhere on your behalf.

Forwarding them is tracking, and tracking needs the visitor's permission. Gate what you send on the visitor's consent, the same way you gate your other analytics tags: in GTM, fire the tag only under your analytics consent trigger; in code, check optimeleon('consent') inside your handler, as below. How Optimeleon itself handles consent is described in consent-and-cookies.md.

This works, and is the shortest thing that does:

window.optimeleon('on', 'variant.decided', function (v) {
  if (!v) return; // this page is not being optimized
  if (optimeleon('consent').categories.analytics) yourWarehouse.send(v);
});

For a returning visitor the script does not assume their earlier consent still stands: it waits for your consent tool to confirm it on this page, or for 3 seconds if the tool says nothing, after which the earlier consent stands. variant.decided waits with it, so by the time your handler runs the answer is settled and the line above reads the real one. In the normal case your consent tool answers in well under a second and you will not notice the wait.

Outside a handler (on a timer, on DOMContentLoaded, in a tag that fires on its own schedule) you can land inside that wait, and then optimeleon('consent') reports resolved: false with mode: 'anonymous' even for a visitor who consented before. The snapshot always tells you which it is, and the script logs one console warning if you read it too early.

Two events tell you where you stand, and both are sticky, so subscribing late still works:

  • consent.resolved fires once, as soon as the script stops waiting, whatever the answer.
  • consent.changed fires on every change after that, including a visitor accepting later.
function onConsent(c) {
  if (c.categories.analytics) startYourTracking();
}
window.optimeleon('on', 'consent.resolved', onConsent);
window.optimeleon('on', 'consent.changed', onConsent);
  • resolved: true doesn't mean the visitor consented. Read categories.analytics for that. Someone who hasn't answered your banner resolves as anonymous, and accepting later arrives as consent.changed.
  • For a returning visitor, the confirmation arrives on every page load as transition: 'upgrade'. Don't read an upgrade as "the visitor just accepted".
  • If the Optimeleon tracking script is blocked, nothing resolves and resolved stays false. Nothing is tracked on our side either, so send nothing. variant.decided is not held back waiting for an answer that will never arrive. It fires as soon as the decision is made.
  • Reading optimeleon('consent') is fine for a check made later, for example on a click. Check resolved if that can happen within the first seconds of the page.

API

const off = optimeleon('on',   'variant.decided', (payload, meta) => {}); // → unsubscribe function
const off = optimeleon('once', 'ready',           (payload, meta) => {}); // → unsubscribe function
            optimeleon('off',  'variant.decided', callback);              // → nothing
            optimeleon('on',   '*',               (payload, meta) => {}); // every event

on and once return a function that removes the subscription. Before the Optimeleon script has loaded, that is only true if your install snippet was copied from the product after event hooks became public, or if your own code planted the queue stub shown above; an older install snippet returns a placeholder object, or nothing at all, there instead. To be safe with any snippet, remove a subscription with optimeleon('off', name, callback), which works in every case. The returned function removes only its own subscription; off removes every subscription of that callback to that event.

Every callback receives (payload, meta):

meta field Meaning
name The event name, e.g. variant.decided. Useful with *.
at When the event happened, in milliseconds since the Unix epoch.
replayed true when the event had already happened before you subscribed (see below).
apiVersion The version of this contract. Currently 1. See Stability.

A callback that throws is isolated: it cannot break your page, the script, or the other subscribers. That delivery is not retried; the callback stays subscribed and receives the next event as usual.

Subscribing late: meta.replayed

You never have to race the script. Some events are sticky: the script remembers the latest one, and when you subscribe after it happened, your callback is called straight away with it.

  • replayed: false: the event happened after you subscribed; you are seeing it live.
  • replayed: true: it had already happened before you subscribed, so you are being handed the most recent one. Each subscription gets this catch-up at most once.

Both are real. For variant.decided, a replayed event is the visitor's actual assignment for this page, so record it. Only the timing differs. If your code runs late (for example after waiting for other tags), most of your deliveries will be replays.

Only the latest value is replayed, never a history. Events that are not sticky reach only subscribers registered before they happen, including calls made before the script loaded, which keep their order: a subscription made after an early optimeleon('track', …) does not receive that tracked event.

Events

Event Sticky Payload
ready yes { bootOutcome, paused, preview }. bootOutcome is 'decided' (a campaign runs on this page) or 'off_campaign' (none does, or the project is paused; see paused)
variant.decided yes { campaignId, pageUrl, trackingId, bucket, arm, isHoldout, isControl }, or null when the page is not being optimized
variant.applied yes { campaignId, pageUrl, trackingId, applyStatus, appliedCount, skippedCount, erroredCount }. applyStatus is 'full', 'partial' or 'none'
variant.applyFailed no { campaignId, pageUrl, trackingId, reason }
variant.changeNotShown no { campaignId, pageUrl, trackingId, changeId, reason }. The script stopped trying to show one change. reason is 'element_not_found' (the element never appeared) or 'invalid_edit' (the page cannot take the change)
variant.reassigned no { campaignId, pageUrl, previousTrackingId, trackingId }. The visitor's earlier variant was retired, so they were assigned a new one
page.revealed no { revealOwner, whiteScreenMs, revealPath }. revealOwner is 'shim', 'snippet' or 'unowned'
consent.resolved yes { mode, categories: { analytics, marketing }, resolved }. Fires once, when the script stops waiting for your consent tool. See Reading consent anywhere else
consent.changed yes { mode, categories: { analytics, marketing }, resolved, transition }. mode is 'anonymous' or 'analytics'; transition is 'upgrade' or 'downgrade'
status.changed yes { paused, preview }
event.tracked no { name, properties, sent }
decision.timedOut no { trigger, elapsedMs }. The decision did not arrive in time, so the original page was shown. trigger is 'initial_load' or 'spa_nav' (a route change in a single-page app)
error no { scope, message }. The script caught a fault and kept your page working
* never replays Every event above, as it happens. Read meta.name. Optimeleon's own internal events do not appear under their own names; they surface only inside event.tracked (see below), and those internal names are not part of this contract

variant.decided in detail

Field Meaning
campaignId The campaign's id. It stays the same for the campaign's whole life, while variants are added, removed and re-weighted. The product shows it under the page's settings → Traffic serving → Campaign ID. This is the key to join on.
pageUrl The page the campaign runs on, normalized: always https://, lower-case host without www., no query string, no #fragment and no trailing slash, e.g. https://example.com/offers. A page can host several campaigns over time, so store it as a label next to campaignId.
trackingId The variant's tracking id, {slug}_{shortId}, e.g. hero_banner_a1b2c3, and the same value our own analytics carry. null on the control arm. The short id (after the last _: 6 characters, lower-case letters and digits) is assigned once when the variant is created and never changes. The slug (before it) follows the variant's name until the variant first goes live and is then kept (renaming a live variant does not change it), but a team member can still edit the slug deliberately. To join reliably across such an edit, key on the short id.
bucket 'holdback' for the holdout, 'adaptive' for everyone being optimized.
arm Which group the visitor is in. The field to read. See below.
isHoldout Shortcut for arm === 'holdout'.
isControl Shortcut for trackingId === null.

It fires once when the page's decision is made, and again on every client-side route change in a single-page app. A route that is not being optimized emits null. Consecutive routes can carry the same assignment, so de-duplicate if you count exposures.

"Decided" means assigned, not necessarily rendered. For an arm that changes the page (arm: 'variant'), variant.applied reports how many of its changes landed, and variant.changeNotShown names individual changes that could not be shown.

Do not wait for variant.applied before recording a visitor. It only fires when there are changes to apply, so it never fires for holdout, original or control visitors, and waiting for it would silently drop your baselines. Record from variant.decided. Also note that variant.applied is sticky per page load: on a single-page app, a late subscriber can be handed the report from an earlier route, so compare its trackingId and pageUrl with the latest variant.decided.

Holdout, original, variant and control

arm Who bucket trackingId
'holdout' A fixed share of visitors kept out of optimization. They see your original page. 'holdback' the original's id, e.g. original_x1y2z3
'original' Visitors in the optimized group who were served your unchanged page. 'adaptive' the original's id
'variant' Visitors in the optimized group who were served a variant. 'adaptive' the variant's id
'control' Visitors in the optimized group who were served no variant: traffic not allocated to any variant, including visitors a campaign's audience rules kept away from one. 'adaptive' null
  • Overall impact of Optimeleon: everyone in the optimized group (bucket: 'adaptive') against the holdout (arm: 'holdout').
  • Lift of one variant: that variant against arm: 'original'.
  • Keep control rows, but do not use them as a baseline.
  • The holdout and the original arm both see the unchanged page and share its trackingId. Tell them apart by arm (or bucket), never by trackingId.
  • On a campaign created before the original became its own arm, holdout visitors have trackingId: null and isControl: true. arm is still 'holdout', which is why arm is the field to read.

event.tracked

Every event the script sends, ours and yours, also surfaces here.

  • For an event you track yourself (optimeleon('track', 'purchase', {...})), properties is your own data, unchanged.
  • For Optimeleon's internal events, properties is reduced to campaignId, trackingId, bucket and applyStatus. Internal event names are not part of this contract.
  • sent: false means preview or test mode stopped the send; the event still surfaces so you can see what would have been sent.
  • properties is a copy made for you: changing it does not change what is sent. The copy is one level deep, so treat nested objects as read-only.

Stability and versioning

This is a public contract, versioned by meta.apiVersion (currently 1).

  • Within a version, we only add. New events and new payload fields can appear. Existing event names, field names, field types and meanings are never renamed, retyped or removed.
  • Write your code so an unknown event or an extra field is simply ignored, and handle a string value you do not recognise (for example a future arm) with a sensible default.
  • A breaking change is a new version. It is announced in advance, with time to migrate, and you can tell which version you are receiving from meta.apiVersion.

Conversion-only installs

A conversion-only install loads Optimeleon's tracking script without the part that serves variants. It never takes over window.optimeleon (that global stays yours), so subscribe through the global the install snippet names, which is __opti_capture unless you chose another:

__opti_capture('on', 'event.tracked', function (e) {
  if (e.name !== 'purchase') return;
  window.dataLayer.push({ event: 'optimeleon_conversion', ...e.properties });
});

Everything above applies, with three differences:

  • event.tracked is the only event. The others describe serving variants, which does not happen on these pages.
  • It fires for conversions reported through that global in the browser. A conversion your server sends to us directly never passes through the browser, so it does not appear.
  • Before the tracking script has loaded, __opti_capture('on', …) returns nothing. Remove a subscription made that early with __opti_capture('off', name, callback).

On a full install, subscribe through optimeleon('on', …); a conversion is delivered once, whichever part of the script observed it.

Good to know

  • Editor and freeze pages don't serve variants. Inside a page builder's editing canvas, or when serving is switched off with the freeze parameter, the serving part never loads: no ready, no variant.*, no page.revealed, and nothing reaches a subscription made through optimeleon('on', …) on that page. The tracking part still loads, so a conversion you report there is still recorded. It surfaces as event.tracked only to a subscription made through the tracking global, as on a conversion-only install.
  • error is deliberately narrow. It reports faults where the script could not do its job, such as a change it could not apply, not expected conditions like a browser that blocks storage.