Integrate Zapier with Optimizely Feature Experimentation
TL;DR
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 |
|---|---|---|
| string | The flag key that was evaluated |
| boolean | Whether the flag is on for this user |
| string | The delivered variation key |
| string | The experiment or delivery rule that matched |
| 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
Create a Zap with the Webhooks by Zapier trigger and choose Catch Hook. Copy the URL into your server's environment.
Send one real decision so Zapier can sample the payload and build its field list.
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.
Add the action: post to Slack, add a row to a Google Sheet, update a CRM record.
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
With the Zap on, trigger a decision for a flag on the allow-list.
Open Zap History and confirm a task ran with the payload you expect.
Trigger the same decision again for the same user and confirm no second task appears — that proves deduplication works.
Trigger a decision for a flag that is not on the allow-list and confirm nothing arrives.
Check the destination itself: the Slack message, the sheet row, the CRM field.
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 |
| 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
Integrate Google Tag Manager with Optimizely Feature Experimentation — the other no-code route, for destinations that live in a tag manager.
Integrate Segment with Optimizely Feature Experimentation — when the fan-out is analytics rather than operations, a CDP is cheaper per event than a task.
Integrate Amazon Redshift with Optimizely Feature Experimentation — for the decisions that belong in a warehouse rather than a Slack channel.