Forward Amplitude Events to Optimizely Feature Experimentation

Loading...·12 min read

Most product teams settle the question of "what counts as a conversion" inside Amplitude long before they run their first feature flag experiment. The taxonomy is already agreed, the event names are already governed, and the dashboards the business reads are already built on them. When Optimizely Feature Experimentation arrives, the temptation is to re-instrument all of it a second time against the Optimizely SDK. That is duplicated work, and worse, it produces two definitions of the same metric that drift apart within a quarter.

Forwarding solves it in the other direction: keep Amplitude as the place events are defined and tracked, and mirror the subset that matters into Optimizely as conversion events so flag rules can measure them. This guide covers the two ways to do that — forwarding from your own application code alongside the Amplitude call, and forwarding from Amplitude itself with a webhook streaming destination that posts to the Optimizely Event API. It is the inbound counterpart to sending Optimizely decisions into Amplitude, and the Web Experimentation version of the same job is covered in forwarding Amplitude events to Web Experimentation.

How Inbound Event Forwarding Works

An Optimizely conversion is a much smaller object than an Amplitude event. It is an event key, a visitor ID, a timestamp, and an optional bag of tags. Everything that makes the event meaningful for analysis — the session, the device, the user properties, the funnel position — stays in Amplitude. Forwarding is therefore a projection, not a copy: you decide which Amplitude event types map to which Optimizely event keys, and you drop the rest.

flowchart LR
    A[User action in your product] --> B[Amplitude track call]
    B --> C{Forwarding point}
    C -->|Option A: in your code| D[Optimizely SDK track]
    C -->|Option B: Amplitude streaming| E[Your webhook endpoint]
    E --> F[Optimizely Event API]
    D --> G[Optimizely event ingestion]
    F --> G
    G --> H[Flag rule metrics on the Results page]

What Optimizely Needs for a Conversion to Count

Four things have to line up, and every failure mode later in this guide is one of them going wrong.

The event must already exist in the Optimizely project, created in the app or through the REST API, and the key you send has to match its API name exactly. The visitor ID must be the same string you passed to createUserContext when the flag was evaluated — Optimizely joins conversions to decisions on that identifier alone. The decision must have happened: the Results page only shows tracked events for users who were already exposed to a flag rule, so a conversion that arrives for a visitor who never called a decide method is stored but never attributed. And the tags have to use the reserved keys — revenue as an integer in cents, value as a float, and $opt_event_properties for anything else.

Choosing Between the Two Options

The two options are genuine alternatives, not steps in one workflow. Pick one per event.

Consideration

Option A: forward from application code

Option B: forward with Amplitude streaming

Where the forward happens

In the process that already calls Amplitude

In Amplitude, after ingestion

Latency to Optimizely

Immediate, same request

Seconds, subject to sync delivery

Covers events from other sources

No, only where your code runs

Yes, including mobile, batch and warehouse imports

Identity handling

You already hold the Optimizely visitor ID

You must carry it in an event property

Failure mode

Fails with your deploy, visible in your logs

Retries for four hours, visible in Amplitude sync monitoring

Effort to add one more event

A code change and a release

A mapping entry, no release

Option A suits teams whose conversions all originate in one application they control, and who want the forward to be reviewable in the same pull request as the tracking call. Option B suits teams whose Amplitude project already aggregates events from mobile SDKs, server jobs and warehouse imports, because those events never pass through the process where the Optimizely SDK lives.

Prerequisites

  • An Optimizely Feature Experimentation project with the conversion events already created, and each event's key noted. For Option B you also need each event's numeric entity ID, which appears in the project datafile.

  • The Optimizely SDK initialised in the application that evaluates flags, for any option — the decision has to exist before a conversion can be attributed to it.

  • An Amplitude project with the events you intend to forward already instrumented and named.

  • A stable shared identifier. Whatever string you pass to createUserContext must be recoverable at the point you forward the event. If Amplitude keys users by device_id and Optimizely keys them by an internal account ID, decide now which one is the join key and make both systems carry it.

  • For Option B only, an HTTPS endpoint you control that Amplitude can post to, and the Optimizely account ID for the project.

Option A: Forward From Your Application Code

The forward is one extra call next to the Amplitude call you already make. Do it in a single helper rather than at every call site, so there is exactly one place where the mapping between the two systems is decided.

Forwarding From JavaScript and Node

The SDK client exposes track(eventKey, userId, attributes, eventTags), which is the convenient shape here: the forwarder usually holds a user ID rather than a live user context object.

import { createInstance } from '@optimizely/optimizely-sdk';
import * as amplitude from '@amplitude/analytics-browser';

const optimizelyClient = createInstance({ sdkKey: '<YOUR_SDK_KEY>' });

// Only these Amplitude event types become Optimizely conversions.
const FORWARDED_EVENTS = {
  'Checkout Completed': 'purchase_completed',
  'Trial Started': 'trial_started',
  'Plan Upgraded': 'plan_upgraded',
};

/**
 * Track once in Amplitude, and mirror the event into Optimizely when the
 * taxonomy says it is also a conversion metric.
 */
export function trackConversion(userId, eventType, properties = {}) {
  amplitude.track(eventType, properties);

  const eventKey = FORWARDED_EVENTS[eventType];
  if (!eventKey) return;

  const tags = { $opt_event_properties: properties };
  if (typeof properties.revenue_cents === 'number') {
    tags.revenue = Math.round(properties.revenue_cents);
  }
  if (typeof properties.value === 'number') {
    tags.value = properties.value;
  }

  optimizelyClient.track(eventKey, userId, {}, tags);
}

Three details matter here. The allowlist is explicit, so a new Amplitude event never silently becomes an Optimizely metric. Revenue is rounded to an integer number of cents before it is sent, because Optimizely discards non-integer revenue. And the original Amplitude properties are passed through under $opt_event_properties, which is what makes them available when you build a metric filtered by property on the Results page.

On the server the code is identical apart from the import: use the Node build of the Amplitude SDK and await the SDK client's readiness before the first forward, so early requests are not dropped while the datafile is still being fetched.

Forwarding From Python

The Python SDK exposes the same method on the client, with event tags as the fourth argument. This example takes the event as a dictionary, which is the shape a queue consumer or a request handler usually has.

from optimizely import optimizely

optimizely_client = optimizely.Optimizely(sdk_key="YOUR_SDK_KEY")

# Amplitude event type -> Optimizely event key
FORWARDED_EVENTS = {
    "Checkout Completed": "purchase_completed",
    "Trial Started": "trial_started",
    "Plan Upgraded": "plan_upgraded",
}


def forward_amplitude_event(amplitude_event):
    """Mirror one Amplitude event into Optimizely as a conversion."""
    event_type = amplitude_event.get("event_type", "")
    event_key = FORWARDED_EVENTS.get(event_type)
    if not event_key:
        return

    user_id = amplitude_event.get("user_id") or amplitude_event.get("device_id")
    if not user_id:
        return

    properties = amplitude_event.get("event_properties") or {}
    tags = {"$opt_event_properties": properties}

    revenue_cents = properties.get("revenue_cents")
    if isinstance(revenue_cents, (int, float)):
        tags["revenue"] = int(round(revenue_cents))

    optimizely_client.track(event_key, user_id, {}, tags)

The user_id or device_id fallback is deliberate. Amplitude sets user_id only once a user is identified; anonymous traffic carries only device_id. Whichever of the two you chose as the join key has to be the value you passed to createUserContext, and mixing them produces conversions that are never attributed to a decision.

Keeping the Identity Consistent

The single most common cause of a forwarded event that never appears in results is an identifier mismatch. Amplitude and Optimizely both accept arbitrary strings and neither will complain about a value the other has never seen.

Write the Optimizely visitor ID into Amplitude as a user property at identification time, so that every Amplitude event carries the value the forward needs. This costs one identify call and removes the guesswork later — the same property is what makes Option B possible at all, since a webhook payload has no access to your application's session state.

Option B: Forward With Amplitude Event Streaming

Amplitude can stream events to a URL you control as it ingests them, through its webhook streaming destination. Your endpoint translates the payload and posts it to the Optimizely Event API. Nothing runs in your product, which is why this option also captures events that were never emitted by the application the Optimizely SDK lives in — mobile clients, backend jobs, and warehouse or batch imports, all of which Amplitude streams alongside real-time traffic.

Step 1: Create the Webhook Sync in Amplitude

  1. In Amplitude Data, open Catalog and select the Destinations tab.

  2. Search for Webhook and select the events and user properties destination.

  3. Name the sync and click Create Sync.

  4. Enter the HTTPS URL of your endpoint. Amplitude does not post from a fixed IP address, so the endpoint has to accept traffic from any Amplitude host.

  5. Add an authorization header. The sync supports up to five extra headers on top of the two it always sends, which is enough for a shared secret your endpoint verifies.

  6. Under Send Events, enable event forwarding and select only the event types you intend to turn into conversions.

Leave the payload on the default Amplitude event format unless you have a reason not to. The default carries event_type, user_id, device_id, event_properties and user_properties, which is everything the translation needs.

Step 2: Map Amplitude Event Types to Optimizely Event Keys

The Event API is addressed by numeric entity ID, not by event key alone, so the mapping table needs both. Both values are in the project datafile.

{
  "Checkout Completed": { "key": "purchase_completed", "entityId": "27891234567" },
  "Trial Started": { "key": "trial_started", "entityId": "27891234568" },
  "Plan Upgraded": { "key": "plan_upgraded", "entityId": "27891234569" }
}

Keep this file next to the endpoint rather than inline in the handler. Adding an event then becomes a configuration change, which is the whole reason to prefer this option.

Step 3: Post to the Optimizely Event API

The endpoint accepts a batch of events for one or more visitors. The important field is enrich_decisions: with it set to true, Optimizely attributes each conversion to whatever decisions that visitor already has, which is exactly the behaviour the SDK produces.

const OPTIMIZELY_EVENT_ENDPOINT = 'https://logx.optimizely.com/v1/events';
const ACCOUNT_ID = 'YOUR_ACCOUNT_ID';

export async function handleAmplitudeWebhook(request, response) {
  const events = Array.isArray(request.body) ? request.body : [request.body];

  const visitors = events
    .map((event) => {
      const mapping = EVENT_MAP[event.event_type];
      const visitorId = event.user_properties?.optimizely_visitor_id;
      if (!mapping || !visitorId) return null;

      return {
        visitor_id: visitorId,
        attributes: [],
        snapshots: [{
          decisions: [],
          events: [{
            entity_id: mapping.entityId,
            key: mapping.key,
            timestamp: Date.parse(event.event_time) || Date.now(),
            uuid: event.insert_id || crypto.randomUUID(),
            properties: event.event_properties || {},
          }],
        }],
      };
    })
    .filter(Boolean);

  if (visitors.length === 0) return response.status(204).end();

  const result = await fetch(OPTIMIZELY_EVENT_ENDPOINT, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      account_id: ACCOUNT_ID,
      visitors,
      anonymize_ip: true,
      client_name: 'amplitude-event-forwarder',
      client_version: '1.0.0',
      enrich_decisions: true,
    }),
  });

  return response.status(result.ok ? 204 : 502).end();
}

Reusing Amplitude's insert_id as the event uuid is worth the two lines it costs. Amplitude retries a failed delivery, so the same event can reach your endpoint more than once; a stable UUID lets Optimizely recognise the repeat instead of counting it twice.

You can exercise the endpoint before wiring up the sync:

curl -sS -X POST https://logx.optimizely.com/v1/events \
  -H 'Content-Type: application/json' \
  -d '{
    "account_id": "YOUR_ACCOUNT_ID",
    "visitors": [{
      "visitor_id": "smoke-test-visitor",
      "attributes": [],
      "snapshots": [{
        "decisions": [],
        "events": [{
          "entity_id": "27891234567",
          "key": "purchase_completed",
          "timestamp": 1785974400000,
          "uuid": "0f6f7d1e-2b3c-4d5e-8f90-a1b2c3d4e5f6"
        }]
      }]
    }],
    "anonymize_ip": true,
    "client_name": "amplitude-event-forwarder",
    "client_version": "1.0.0",
    "enrich_decisions": true
  }' -w '%{http_code}\n'

A 204 means the payload was accepted. It does not mean the event was attributed — that depends on the visitor having a decision, which the smoke-test visitor will not have.

Step 4: Handle Retries and Delivery Failures

Amplitude makes one delivery attempt and, on failure, nine more spread over roughly four hours. Return quickly and return honestly: acknowledge with a 2xx only once the Optimizely call has succeeded or you have queued the event durably, and return a 5xx otherwise so the retry schedule does its job. An endpoint that swallows errors and always answers 200 turns a transient Optimizely outage into permanently lost conversions.

Because the retry window is four hours, timestamps matter. Send Amplitude's original event_time rather than the time your endpoint ran, or a redelivered event will land hours after the action it represents.

Verifying the Forwarded Events

Confirming the Request Was Accepted

For Option A, watch the network calls the SDK makes and confirm a request to the Optimizely event endpoint follows the action under test. For Option B, use the sync monitoring in Amplitude to confirm deliveries are succeeding, and log the Optimizely response status in your endpoint. A 400 from the Event API is always a payload problem, and the response body names the field.

Confirming the Conversion on the Results Page

Add the event as a metric on a flag rule, then run the flow end to end as a test user: evaluate the flag first, then trigger the action. Within a few minutes the metric should show one conversion for that visitor's variation. If the number stays at zero while the request was accepted, the problem is attribution, not delivery — start with the visitor ID.

Gotchas

Do Not Run Both Options at Once

Enabling application-side forwarding and the streaming sync for the same event sends the same conversion twice, and Optimizely records the duplicate as a second event for that visitor, inflating conversion counts and biasing the experiment. Choose one path per event key. If you migrate from one to the other, remove the first before enabling the second, and treat the changeover date as a boundary in any analysis that spans it.

Revenue Must Be an Integer in Cents

Optimizely records revenue in cents as an integer. A value of 54.99 is not a rounding inconvenience — a non-integer revenue tag is discarded, and the metric silently reports less revenue than the store took. Convert in the forwarder, not at the call site, so every path through the code converts the same way.

The Decision Must Come First

A conversion for a visitor with no decision is accepted and stored, but it never appears on the Results page. This bites hardest with server-side jobs: a nightly billing process that forwards a renewal event for a user whose flag was evaluated in the web app yesterday works fine, but the same event for a user who has never hit the flagged code path contributes nothing.

Event Keys and Properties Must Exist in Optimizely

Sending an unknown event key is not an error you will notice — the SDK logs it and moves on. The same is true of event properties: they must be created in the Optimizely UI before a metric can filter on them, and unknown ones are ignored. Treat the mapping table as a contract and review it whenever the Amplitude taxonomy changes.

Troubleshooting

The Endpoint Accepts the Event but Nothing Appears

Work backwards through the four requirements. Confirm the event key matches the API name exactly, including case. Confirm the visitor ID is byte-for-byte the one passed to createUserContext. Confirm the visitor has a decision for a rule where the event is a metric. Only then look at the payload.

The Event API Rejects the Payload

A 400 almost always means entity_id is missing, is not a string, or belongs to a different project than account_id. Re-read both values from the current datafile rather than from a wiki page — entity IDs are per project and do not survive a project rebuild.

Counts Differ Between Amplitude and Optimizely

Some divergence is structural and expected. Amplitude counts every event; Optimizely counts conversions for visitors who were bucketed into a rule, which is a subset. Amplitude keys anonymous traffic by device, Optimizely by visitor ID. Ad blockers can suppress one system and not the other. A difference of five to fifteen percent is normal for the same nominal metric. A difference above twenty percent, or one that grows over the life of an experiment, points at an identity mismatch rather than at sampling.

Related Reading