Set Up Your Data Pipeline
Create the warehouse tables that Levered reads during training -- exposures for what users saw, rewards for what they did.
Levered is designed around a simple principle: your data stays in your warehouse. Levered never stores raw event data. Instead, it connects to your warehouse, reads exposure and reward events via SQL, and uses them to train bandit models.
This means you get:
- Full data ownership -- no vendor lock-in, no data leaving your infrastructure.
- Your existing pipelines -- log events through Segment, Rudderstack, direct inserts, or whatever you already use.
- No extra SDK calls for rewards -- Levered reads conversions from the tables your analytics pipeline already writes to.
For this to work, Levered needs two things in your warehouse:
- An exposures table -- records of which variant each user saw.
- A rewards table -- records of successful outcomes (conversions, purchases, etc.). This often already exists.
Exposures table
Every time a user sees a variant, you log an exposure event to your warehouse. It does not matter how you got the variant -- the JavaScript SDK, the REST API, or a server-side call. If a user saw it, log it.
The resulting table is how Levered knows which users saw which variants.
Required columns
| Column | Type | Description |
|---|---|---|
anonymous_id | STRING | A stable user or session identifier. Must match the anonymous_id in your reward/metric queries. |
timestamp | TIMESTAMP | When the user was exposed to the variant. |
optimization_id | STRING | Which optimization this exposure belongs to. |
variant | JSON / STRING | The design factor values as a JSON object, e.g., {"headline": "Ship faster", "cta_text": "Start free"}. |
You can optionally store context attributes (device type, country, etc.) if you use CMAB:
| Column | Type | Description |
|---|---|---|
context | JSON / STRING | Context attributes as a JSON object, e.g., {"device_type": "mobile", "country": "US"}. |
Create the table
CREATE TABLE IF NOT EXISTS `your_project.your_dataset.exposures` (
anonymous_id STRING NOT NULL,
timestamp TIMESTAMP NOT NULL,
optimization_id STRING NOT NULL,
variant JSON,
context JSON
);CREATE TABLE IF NOT EXISTS exposures (
anonymous_id VARCHAR NOT NULL,
timestamp TIMESTAMP_NTZ NOT NULL,
optimization_id VARCHAR NOT NULL,
variant VARIANT,
context VARIANT
);CREATE TABLE IF NOT EXISTS exposures (
anonymous_id TEXT NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
optimization_id TEXT NOT NULL,
variant JSONB,
context JSONB
);Alternative: individual columns per design factor
Instead of a single variant JSON column, you can store each design factor as its own column. This works well if you prefer flat tables or your analytics pipeline does not emit JSON.
CREATE TABLE IF NOT EXISTS `your_project.your_dataset.exposures` (
anonymous_id STRING NOT NULL,
timestamp TIMESTAMP NOT NULL,
optimization_id STRING NOT NULL,
headline STRING,
cta_text STRING
);CREATE TABLE IF NOT EXISTS exposures (
anonymous_id VARCHAR NOT NULL,
timestamp TIMESTAMP_NTZ NOT NULL,
optimization_id VARCHAR NOT NULL,
headline VARCHAR,
cta_text VARCHAR
);CREATE TABLE IF NOT EXISTS exposures (
anonymous_id TEXT NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
optimization_id TEXT NOT NULL,
headline TEXT,
cta_text TEXT
);Both approaches work. Levered handles column mapping when you configure the assignment source (see below).
Logging exposures
However you request variants -- the JavaScript SDK, the REST API, or a server-side call -- you are responsible for logging the exposure to your warehouse whenever the user actually sees the variant.
From the SDK -- the onExposure callback fires automatically when a variant is served:
onExposure={(exposure) => {
analytics.track('levered_exposure', {
anonymous_id: exposure.anonymousId,
timestamp: exposure.timestamp,
optimization_id: exposure.optimizationId,
variant: exposure.variant, // e.g. {"headline": "Ship faster", "cta_text": "Start free"}
context: exposure.context, // e.g. {"device_type": "mobile"}
});
}}From the REST API -- after calling the /serve endpoint, log the response yourself:
const res = await fetch(`https://api.levered.dev/api/v2/optimizations/${optimizationId}/serve`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ anonymous_id: userId, context: { device_type: 'mobile' } }),
});
const data = await res.json();
// Log the exposure to your warehouse
analytics.track('levered_exposure', {
anonymous_id: userId,
timestamp: new Date().toISOString(),
optimization_id: optimizationId,
variant: data.variants[0].props,
});Use whatever pipeline you already have -- Segment, Rudderstack, a direct warehouse insert, or a custom ETL.
Rewards table
The rewards table records when users do something valuable -- sign up, purchase, click a CTA. You likely already have this data in your warehouse through your existing analytics pipeline.
Levered does not require a special SDK call for rewards. It reads them from the SQL query you define as a metric.
Required columns
| Column | Type | Description |
|---|---|---|
anonymous_id | STRING | The same user identifier used in exposures. This is how Levered joins rewards back to the variant a user saw. |
timestamp | TIMESTAMP | When the reward event occurred. |
value | NUMBER | The reward value. For boolean rewards (converted/didn't), use 1. For numeric rewards (revenue), use the actual amount. |
Example tables
A signup conversion table (boolean reward):
-- Your existing signups table probably already looks like this
SELECT
user_id AS anonymous_id,
created_at AS timestamp
FROM signupsA purchase table (numeric reward):
SELECT
user_id AS anonymous_id,
purchased_at AS timestamp,
amount AS value
FROM purchasesYou do not need to create new tables for rewards. In the next step (Define Metrics), you will write a SQL query that reads from whatever tables you already have.
How Levered joins exposures and rewards
During training, Levered:
- Queries your exposures table to see what each user was shown.
- Queries your rewards table (via the metric SQL) to see who converted.
- Joins them on
anonymous_idusing last-touch attribution within a conversion window (default: 24 hours). - Trains the bandit model on the result -- learning which variants lead to more rewards.
Exposures table Rewards table
┌──────────────┬──────────┐ ┌──────────────┬───────┐
│ anonymous_id │ variant │ │ anonymous_id │ value │
├──────────────┼──────────┤ ├──────────────┼───────┤
│ user_abc │ {"h":"A"}│───>│ user_abc │ 1 │ ✓ Matched
│ user_def │ {"h":"B"}│ │ user_ghi │ 1 │ ✗ No exposure
│ user_ghi │ {"h":"A"}│ └──────────────┴───────┘
└──────────────┴──────────┘
↓
Observations (training data)
┌──────────────┬──────────┬────────┐
│ anonymous_id │ variant │ reward │
├──────────────┼──────────┼────────┤
│ user_abc │ {"h":"A"}│ 1 │
│ user_def │ {"h":"B"}│ 0 │
│ user_ghi │ {"h":"A"}│ 1 │
└──────────────┴──────────┴────────┘Next step
Your data pipeline is set up. Next, define the metrics that tell Levered what SQL to run against your rewards data.