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
});
});
Consent
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.
Checking consent inside a handler
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.
Reading consent anywhere else
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.resolvedfires once, as soon as the script stops waiting, whatever the answer.consent.changedfires 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: truedoesn't mean the visitor consented. Readcategories.analyticsfor that. Someone who hasn't answered your banner resolves asanonymous, and accepting later arrives asconsent.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
resolvedstaysfalse. Nothing is tracked on our side either, so send nothing.variant.decidedis 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. Checkresolvedif 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
controlrows, 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 byarm(orbucket), never bytrackingId. - On a campaign created before the original became its own arm, holdout visitors have
trackingId: nullandisControl: true.armis still'holdout', which is whyarmis 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', {...})),propertiesis your own data, unchanged. - For Optimeleon's internal events,
propertiesis reduced tocampaignId,trackingId,bucketandapplyStatus. Internal event names are not part of this contract. sent: falsemeans preview or test mode stopped the send; the event still surfaces so you can see what would have been sent.propertiesis 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.trackedis 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, novariant.*, nopage.revealed, and nothing reaches a subscription made throughoptimeleon('on', …)on that page. The tracking part still loads, so a conversion you report there is still recorded. It surfaces asevent.trackedonly to a subscription made through the tracking global, as on a conversion-only install. erroris 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.
