Integrate Zapier with Optimizely Feature Experimentation

Loading...·8 min read

Zapier connects applications that have no direct integration with each other. Optimizely Feature Experimentation decides which variation of a flag each user receives. Sending those decisions to a Zapier webhook gives you a bridge to every destination Zapier supports — a Slack channel, a Google Sheet, an Airtable base, a CRM record, a ticketing system — without writing a client for any of them.

This is the integration to reach for when the destination is not an analytics tool. Forwarding every decision to a warehouse belongs in a pipeline; forwarding the decisions that matter to a human belongs in a Zap. A sales team that needs to know which pricing variation an enterprise lead saw before the demo call, an operations rota that changes when a fulfilment flag flips, a channel that should be told when a specific account is bucketed into a risky rollout: none of those justify building an integration, and all of them are twenty minutes of Zap.

The cost model is the thing to understand before you start. Zapier bills by task, so an integration that forwards every decision on a high-traffic flag will exhaust a plan quota in hours. This article is as much about deciding which decisions to forward as it is about how.

How the Integration Works

Feature Experimentation has no Custom Analytics Integration UI, so the bridge is a decision notification listener: a callback that fires on every decide(). The listener filters aggressively, then POSTs a small JSON object to a Zapier Catch Hook URL, and the Zap does the rest.

flowchart LR
    A["user.decide('enterprise_pricing')"] --> B[DECISION notification fires]
    B --> C{Does this decision matter to a human?}
    C -->|No| D[Dropped, no task consumed]
    C -->|Yes| E[POST to Catch Hook]
    E --> F[Zap trigger]
    F --> G[Filter step]
    G --> H[Slack, Sheets, CRM, Airtable]

Decision Notification Data

The listener's decision info object carries these fields for a flag-type decision. The browser and Node SDKs expose camelCase keys; the Python SDK exposes snake_case keys.

Field (camelCase / snake_case)

Type

Description

flagKey / flag_key

string

The flag key that was evaluated

enabled / enabled

boolean

Whether the flag is on for this user

variationKey / variation_key

string

The delivered variation key

ruleKey / rule_key

string

The experiment or delivery rule that matched

decisionEventDispatched / decision_event_dispatched

boolean

Whether Optimizely sent an impression for this decision

What a Zap Actually Costs

A decide() call is cheap and happens on every page view, every request, sometimes several times per render. A Zap task is neither cheap nor idempotent.

Filtering approach

Where it runs

Task cost

Forward everything

Nowhere

One task per decision — unusable on any real flag

Filter step inside the Zap

Zapier

Still one task per decision; the filter runs after the trigger

Filter in the listener

Your code

Zero for anything you drop

Deduplicate per user and flag

Your code

One task per user, not per page view

The second row is the trap. A Zap's Filter step stops the subsequent steps, not the trigger, so filtering inside Zapier does not save you the task. Every filter that can run in your own code should run there.

Three filters are worth applying by default: forward only the flag keys somebody has asked for, forward only decisions where decisionEventDispatched is true so rollouts do not flood the Zap, and remember which users you have already sent so a returning visitor does not fire the hook on every page.

Prerequisites

  • Optimizely Feature Experimentation SDK for your platform — the Node.js SDK or the Python SDK on the server, or the JavaScript SDK v6 or later if decisions are made in the browser.

  • A Zap with a Webhooks by Zapier trigger set to Catch Hook, which gives you a URL of the form https://hooks.zapier.com/hooks/catch/<account>/<hook>/.

  • A place to keep that URL as a secret. It is an unauthenticated endpoint: anyone holding the URL can write into your Zap. Treat it exactly as you would an API key.

  • A short-lived cache — Redis, or an in-process map with a TTL — if you intend to deduplicate per user and flag, which you should.

Where to Call the Hook

Server-side

Browser

Hook URL visible to users

No

Yes, in the page source and the network tab

Payload can be forged

No

Yes, trivially

Task quota controllable

Yes

No — anyone can spend it

Recommended

Yes

Only through a proxy you own

Calling a Catch Hook from browser JavaScript publishes the URL to everyone who opens developer tools, and a published unauthenticated write endpoint is a task-quota bill waiting to happen. When decisions are made client-side, post them to your own endpoint and let the server call Zapier. The proxy is shown below.

Server-Side Node.js Implementation

const optimizelySdk = require('@optimizely/optimizely-sdk');
const { enums } = require('@optimizely/optimizely-sdk');

const HOOK_URL = process.env.ZAPIER_HOOK_URL;

// Only these flags are interesting to a human. Everything else is dropped
// before a task is spent.
const FORWARDED_FLAGS = new Set(['enterprise_pricing', 'fulfilment_routing']);

// Per user and flag, once. A TTL map here; Redis in a multi-process service.
const alreadySent = new Map();
const DEDUPE_TTL_MS = 24 * 60 * 60 * 1000;

function seenRecently(key) {
  const at = alreadySent.get(key);
  if (at && Date.now() - at < DEDUPE_TTL_MS) return true;
  alreadySent.set(key, Date.now());
  return false;
}

async function notifyZapier(userId, attributes, decisionInfo) {
  const { flagKey, enabled, variationKey, ruleKey, decisionEventDispatched } = decisionInfo;

  if (!FORWARDED_FLAGS.has(flagKey)) return;
  if (!decisionEventDispatched) return;
  if (seenRecently(`${userId}:${flagKey}`)) return;

  const payload = {
    user_id: userId,
    account_id: attributes.account_id,
    plan: attributes.plan,
    flag_key: flagKey,
    enabled,
    variation_key: enabled ? variationKey : 'off',
    rule_key: ruleKey,
    decided_at: new Date().toISOString(),
  };

  try {
    const response = await fetch(HOOK_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });
    if (!response.ok) console.error('zapier hook rejected', response.status);
  } catch (error) {
    // A Zap outage must never affect a flag decision
    console.error('zapier hook failed', error);
  }
}

const optimizely = optimizelySdk.createInstance({ sdkKey: process.env.OPTIMIZELY_SDK_KEY });

optimizely.onReady().then(() => {
  optimizely.notificationCenter.addNotificationListener(
    enums.NOTIFICATION_TYPES.DECISION,
    ({ type, userId, attributes, decisionInfo }) => {
      if (type !== 'flag') return;
      notifyZapier(userId, attributes || {}, decisionInfo);
    },
  );
});

The three guards at the top of notifyZapier are the whole design. Without the flag allow-list, adding a new flag anywhere in the codebase silently starts billing tasks. Without the decisionEventDispatched check, a targeted rollout at 100% fires on every request. Without deduplication, a single user refreshing a page ten times is ten tasks.

The Payload Zapier Sees

Zapier builds its field mapping from the first payload it receives, so send a flat object with stable keys. Nested objects become dotted paths and arrays become numbered fields, both of which break the moment the shape varies.

{
  "user_id": "user_123",
  "account_id": "acct_9f2",
  "plan": "enterprise",
  "flag_key": "enterprise_pricing",
  "enabled": true,
  "variation_key": "annual_discount",
  "rule_key": "enterprise_pricing_test",
  "decided_at": "2026-08-09T10:14:22.000Z"
}

Send every field on every request, including the ones that are empty. Zapier maps a field it has never seen as missing, so a payload that omits account_id when there is no account will leave that step of the Zap unmapped for every subsequent event rather than blank for one.

Python Implementation

The Python SDK's DECISION callback takes four positional arguments and exposes snake_case keys in decision_info — reading decision_info.get('flagKey') returns None silently, which here means posting a task to Zapier with a null flag key.

import os
import time

import requests
from optimizely import optimizely
from optimizely.helpers import enums

HOOK_URL = os.environ['ZAPIER_HOOK_URL']
FORWARDED_FLAGS = {'enterprise_pricing', 'fulfilment_routing'}
DEDUPE_TTL_SECONDS = 24 * 60 * 60

_already_sent = {}


def seen_recently(key):
    sent_at = _already_sent.get(key)
    now = time.time()
    if sent_at and now - sent_at < DEDUPE_TTL_SECONDS:
        return True
    _already_sent[key] = now
    return False


def on_decision(decision_type, user_id, attributes, decision_info):
    if decision_type != 'flag':
        return

    flag_key = decision_info.get('flag_key')
    enabled = decision_info.get('enabled')
    variation_key = decision_info.get('variation_key')
    rule_key = decision_info.get('rule_key')
    dispatched = decision_info.get('decision_event_dispatched')

    if flag_key not in FORWARDED_FLAGS:
        return
    if not dispatched:
        return
    if seen_recently(f'{user_id}:{flag_key}'):
        return

    attributes = attributes or {}
    payload = {
        'user_id': user_id,
        'account_id': attributes.get('account_id', ''),
        'plan': attributes.get('plan', ''),
        'flag_key': flag_key,
        'enabled': enabled,
        'variation_key': variation_key if enabled else 'off',
        'rule_key': rule_key,
        'decided_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
    }

    try:
        response = requests.post(HOOK_URL, json=payload, timeout=2)
        if response.status_code >= 400:
            print(f'zapier hook rejected: {response.status_code}')
    except requests.RequestException as error:
        print(f'zapier hook failed: {error}')


optimizely_client = optimizely.Optimizely(sdk_key=os.environ['OPTIMIZELY_SDK_KEY'])
optimizely_client.notification_center.add_notification_listener(
    enums.NotificationTypes.DECISION, on_decision
)

user = optimizely_client.create_user_context(
    'user_123', {'plan': 'enterprise', 'account_id': 'acct_9f2'}
)
decision = user.decide('enterprise_pricing')
print('Variation:', decision.variation_key)

The in-process dictionary is fine for a single worker and wrong for a fleet: five processes each keep their own memory of what they have sent, so a user can fire five tasks instead of one. Move the deduplication key into Redis with an expiry as soon as you run more than one instance.

Browser Decisions Without Exposing the Hook

When the decision happens client-side, post it to your own endpoint and forward from there.

import { createInstance, enums } from '@optimizely/optimizely-sdk';

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

optimizely.onReady().then(() => {
  optimizely.notificationCenter.addNotificationListener(
    enums.NOTIFICATION_TYPES.DECISION,
    ({ type, decisionInfo }) => {
      if (type !== 'flag') return;
      if (!decisionInfo.decisionEventDispatched) return;

      // Your endpoint, your session, your rate limiting. The hook URL never
      // reaches the browser, and the user id comes from the session server-side.
      fetch('/internal/experiment-decision', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'same-origin',
        body: JSON.stringify({
          flagKey: decisionInfo.flagKey,
          variationKey: decisionInfo.variationKey,
          enabled: decisionInfo.enabled,
          ruleKey: decisionInfo.ruleKey,
        }),
      }).catch(() => {});
    },
  );
});

Do not accept a user ID from that request. The browser can claim to be anyone; the session cannot.

Designing the Zap

  1. Create a Zap with the Webhooks by Zapier trigger and choose Catch Hook. Copy the URL into your server's environment.

  2. Send one real decision so Zapier can sample the payload and build its field list.

  3. Add a Filter step only for conditions your code cannot evaluate — the account tier from your CRM, for instance, rather than the flag key you already filtered.

  4. Add the action: post to Slack, add a row to a Google Sheet, update a CRM record.

  5. Turn the Zap on, then send a second decision and confirm it appears in the Zap history.

Keep one Zap per purpose rather than one Zap with a long branch. Zapier's task accounting and error reporting are both per-Zap, and a single failing branch in a shared Zap is far harder to see.

Verifying the Integration

  1. With the Zap on, trigger a decision for a flag on the allow-list.

  2. Open Zap History and confirm a task ran with the payload you expect.

  3. Trigger the same decision again for the same user and confirm no second task appears — that proves deduplication works.

  4. Trigger a decision for a flag that is not on the allow-list and confirm nothing arrives.

  5. Check the destination itself: the Slack message, the sheet row, the CRM field.

  6. Watch the task counter over a normal hour of traffic before leaving it running. If the number surprises you, one of the three filters is not doing what you think.

Troubleshooting

Symptom

Likely cause

Fix

No task in Zap history

Zap is off, or the hook URL is stale

Turn the Zap on; a rebuilt trigger issues a new URL

Fields missing in the action step

The sampled payload lacked them

Send every key on every request, empty rather than absent

Task quota consumed in hours

Filtering only inside the Zap

Filter in the listener; the Zap filter runs after the trigger

Duplicate tasks for one user

No deduplication, or per-process deduplication in a fleet

Key on user and flag in shared storage with a TTL

Tasks firing for rollouts, not experiments

decisionEventDispatched not checked

Skip decisions where it is false

Unexpected payloads in Zap history

The hook URL leaked to the browser

Rotate the hook and forward through your own endpoint

Application latency rose

The hook is called synchronously on the request path

Fire and forget, or queue it

Zap succeeds but the destination is empty

Field mapping points at a path that changed shape

Keep the payload flat and stable

Related Reading