Assign a customer list to an experiment
Use this when you know who will see an experiment before they arrive (an email campaign, a
CRM export, a list from your warehouse) and need each person's variant now.
POST /api/v1/tracking/assign/batch (beta) assigns up to 1,000 users in one request. Each
user gets what POST /api/v1/tracking/assign would give them, with the bandit weights read at
the start of the request: the global holdout, the experiment's mutual exclusion group and its
targeting rules apply to each new user, and a user who is already assigned keeps their variant.
The full contract and every error are in the
API reference.
The examples run against the Quick Start stack and its demo data.
What you need
Sign in as the demo administrator:
TOKEN=$(curl -s -X POST localhost:8000/api/v1/auth/login \
-H 'content-type: application/json' \
-d '{"email":"admin@demo.com","password":"Demo1234!"}' | jq -r .access_token)
curl -s localhost:8000/api/v1/auth/me -H "Authorization: Bearer $TOKEN" | jq .role
It prints "ADMIN".
An ACTIVE experiment. This creates one for an email campaign, with two subject lines and a targeting rule that admits customers in the US only, and starts it:
EXP_ID=$(curl -s -X POST localhost:8000/api/v1/experiments/ \
-H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{
"name": "Spring email",
"key": "spring-email",
"variants": [
{"name": "control", "is_control": true, "traffic_allocation": 50, "configuration": {"subject": "Spring sale"}},
{"name": "urgent", "traffic_allocation": 50, "configuration": {"subject": "48 hours left"}}
],
"metrics": [{"name": "Purchase", "event_name": "purchase", "is_primary": true}],
"targeting_rules": {
"logical_operator": "AND",
"groups": [
{"logical_operator": "AND", "conditions": [{"attribute": "country", "operator": "equals", "value": "US"}]}
]
}
}' | jq -r .id)
curl -s -X POST localhost:8000/api/v1/experiments/$EXP_ID/start \
-H "Authorization: Bearer $TOKEN" | jq -r .status
It prints active.
An API key with the sdk:ruleset scope, created by an ADMIN or a DEVELOPER. The same scope lets the key download every feature flag's targeting
rules, so keep it on a server and never ship it to a browser or a mobile app. This creates one
and saves it in $KEY:
KEY=$(curl -s -X POST localhost:8000/api/v1/api-keys \
-H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"name": "Campaign batch job", "scopes": ["sdk:ruleset"]}' | jq -r .key)
printf '%s\n' "${KEY:0:5}"
It prints eptk_.
Send the list
Each user has a user_id and, optionally, a context: the attributes the experiment's
targeting rules read, as on POST /api/v1/tracking/assign. This sends three customers, two
in the US and one in Germany, and saves the response in $RESULT:
RESULT=$(curl -s -X POST localhost:8000/api/v1/tracking/assign/batch \
-H "X-API-Key: $KEY" \
-H 'content-type: application/json' \
-d '{
"experiment_key": "spring-email",
"users": [
{"user_id": "cust-001", "context": {"country": "US"}},
{"user_id": "cust-002", "context": {"country": "US"}},
{"user_id": "cust-003", "context": {"country": "DE"}}
]
}')
jq -c .counts <<<"$RESULT"
It prints {"assigned":2,"holdout":0,"mutual_exclusion":0,"targeting":1}. The response also
has assignments, one entry per user in the order sent, and variants, each variant's name
and configuration by id.
Read who was not enrolled
It prints cust-003 and targeting. A user who was not enrolled gets the control variant, the
experience to show them, and nothing is recorded for them:
reason |
What it means | What to do |
|---|---|---|
holdout |
The user is in the global holdout, which sees no experiment | Nothing: show the control |
mutual_exclusion |
The user is enrolled in, or hashed to, another experiment of the same mutual exclusion group | Nothing: show the control |
targeting |
The user's context does not match the experiment's targeting rules |
Send the attributes your rules use in each user's context |
Join variants to your list
jq -r '.variants as $v | .assignments[] | [.user_id, $v[.variant_id].name, .assigned] | @csv' <<<"$RESULT"
It prints one line per customer: the id, the variant's name and whether they were enrolled.
cust-003 is "cust-003","control",false; the other two are enrolled in control or
urgent.
Retries
Send the same request again. Users already assigned keep their variant and no second assignment is recorded:
curl -s -X POST localhost:8000/api/v1/tracking/assign/batch \
-H "X-API-Key: $KEY" \
-H 'content-type: application/json' \
-d '{
"experiment_key": "spring-email",
"users": [
{"user_id": "cust-001", "context": {"country": "US"}},
{"user_id": "cust-002", "context": {"country": "US"}},
{"user_id": "cust-003", "context": {"country": "DE"}}
]
}' | jq --argjson before "$RESULT" '.assignments == $before.assignments'
It prints true: the same answers as the first time. A 409 (the experiment was paused or
completed during the request) or a 500 may come after some users were assigned; sending the
same request again is safe for the same reason. Any other 4xx assigns nobody.
When they arrive
When a customer reaches your site or app, call POST /api/v1/tracking/assign (an SDK's
get_assignment / getAssignment) as usual. The user gets the same variant, and that call
records that they saw the experiment; the batch route records only the assignment:
curl -s -X POST localhost:8000/api/v1/tracking/assign \
-H "X-API-Key: $KEY" \
-H 'content-type: application/json' \
-d '{"experiment_key": "spring-email", "user_id": "cust-001", "context": {"country": "US"}}' \
| jq --argjson before "$RESULT" '.variant_id == $before.assignments[0].variant_id'
It prints true.
More than 1,000 users
Send the list in chunks of 1,000, one request at a time. The route allows 60 requests a minute
from one address; over that it answers 429 with a Retry-After header, in seconds, and the
loop below waits that long and sends the same chunk again. It stops at any other error; run it
again from the start, which is safe because assignments are sticky.
This writes 2,500 customer ids to a file in a scratch directory, sends them in three chunks,
and appends each answer to assignments.csv:
cd "$(mktemp -d)"
seq -f 'cust-%05g' 1 2500 > customers.txt
split -l 1000 customers.txt chunk-
for chunk in chunk-*; do
BODY=$(jq -R '{user_id: ., context: {country: "US"}}' "$chunk" | jq -s '{experiment_key: "spring-email", users: .}')
while true; do
STATUS=$(curl -s -o response.json -D headers.txt -w '%{http_code}' \
-X POST localhost:8000/api/v1/tracking/assign/batch \
-H "X-API-Key: $KEY" \
-H 'content-type: application/json' \
-d "$BODY")
if [ "$STATUS" != 429 ]; then
break
fi
sleep "$(tr -d '\r' < headers.txt | awk 'tolower($1) == "retry-after:" {print $2}')"
done
if [ "$STATUS" != 200 ]; then
printf '%s\n' "stopped at $chunk: HTTP $STATUS $(jq -c .detail response.json)"
break
fi
jq -r '.assignments[] | [.user_id, .variant_id, .assigned, .reason] | @csv' response.json >> assignments.csv
done
wc -l < assignments.csv
It prints 2500, one line per customer.
- One chunk at a time. Chunks sent in parallel share the 60-a-minute limit and the API's database connections with every other request.
What counts in results
Every enrolled user counts in the experiment's results from the moment they are assigned, whether or not they open the email. If only some of them will ever see the experience, analyse it as an intent-to-treat experiment, or assign only the users you will contact. For a bandit experiment, each enrolled user also counts as a pull of their variant.