User attributes
The shape of the eval context — pass everything you know about the user, and ShipEasy uses it for targeting, holdouts, and analysis breakdowns.
A user attribute is any key/value pair you attach to a user when calling the SDK. ShipEasy uses attributes for three things:
- Targeting rules on gates — "only enable this for
plan = pro". - Variant assignment in experiments — "assign by
account_idso all teammates see the same thing". - Segmentation in analysis — "how did the lift differ for
country = USvsEU?".
The richer your attribute payload, the more precise your targeting and analysis. Most teams under-invest here on day one and regret it.
The eval context
Every SDK call that resolves a flag, config, or experiment takes an eval context — the user object plus any attributes you want available to the rule engine.
const ctx = {
user_id: "u_4f2a", // required for deterministic bucketing
anonymous_id: "anon_xyz", // optional, auto-managed in the browser
// Custom attributes — flat top-level keys
plan: "pro",
country: "US",
beta_tester: true,
team_size: 18,
signup_at: "2026-01-12T00:00:00Z",
};
flags.gate("new-checkout", ctx);Server SDKs take the context on every call. The browser SDK is identify-once: you call shipeasy.identify(ctx) once after login and the same context is reused for every subsequent evaluation until you identify again.
Reserved attributes
Two keys are special:
ShipEasy bucketing uses user_id ?? anonymous_id — so a user gets a stable assignment before and after login. When the user authenticates, call shipeasy.alias(anonymous_id, user_id) to stitch their pre-login exposures to their post-login identity. Without this stitch, exposure events from before login show up as a separate user in analysis.
Without a stable id, every evaluation gets a fresh random bucket — rollouts oscillate, experiment
variants flip between page loads, and analysis falls apart. Pass user_id as soon as you have it;
pass anonymous_id for the pre-login window.
Custom attributes
Everything else lives at the top level of the context object. There is no custom: { … } wrapper.
shipeasy.identify({
user_id: "u_4f2a",
plan: "pro",
country: "US",
signup_at: "2026-01-12T00:00:00Z",
beta_tester: true,
team_size: 18,
roles: ["admin", "billing"],
});Supported value types:
gt, lte, …) and equality.contains for membership, or in for the inverse.Nested objects are not supported — flatten them with dot-notation if you need to (org.tier becomes a literal key "org.tier").
Standard attributes worth passing
Most projects benefit from a few standard fields. Set them up once and your future targeting + analysis is much easier.
Identity→
user_id (UUID created at signup), anonymous_id (auto). Avoid email or username — they
change.
Monetisation→
plan (free / pro / enterprise) and mrr_band (0 / 1-99 / 100+). Most experiment
lift questions cut along money.
Cohort→
signup_at, tenure_days, team_size. Powerful for segmenting by maturity.
Geography & locale→
country (ISO-2), locale (BCP-47). Lets you regionalise rollouts and stops you running an
EU-wide test on a US-only feature.
Device→
platform (web / ios / android), app_version. The browser SDK fills platform
automatically; you fill app_version.
B2B hierarchy→
account_id, org_id, role. For B2B, bucket experiments by account_id so all teammates see
the same variant.
Registering attributes in the dashboard
Go to Project → User attributes → Register and declare the attributes you plan to send. Registration is optional but unlocks two things:
- The dashboard rule builder can autocomplete attribute names and validate types.
- Analysis surfaces attributes as breakdown dimensions in experiment results.
Unregistered attributes still work — they just won't appear in pickers.
Targeting operators
Targeting rules use these operators against attribute values:
Multiple rules on a gate are ANDed. Use in with a list to express OR. Rule order doesn't matter — there is no fall-through.
Server vs browser
On the server, you pass the context on every call:
flags.gate("new-ui", { user_id, plan, country });In the browser, you call identify() once after the user logs in and the same context is reused for every subsequent gate / config / track / experiment assignment until you identify again.
await shipeasy.identify({ user_id, plan, country });
if (shipeasy.gate("new-ui")) {
// …
}Anonymous IDs
Before login, the browser SDK auto-generates an anonymous_id and persists it in a cookie. The cookie is Lax, Secure (in production), and scoped to your domain.
On the server, generate an anonymous ID yourself — a UUID stored in AsyncStorage (React Native) or a session cookie (server-rendered apps) is the right pattern. The SDK doesn't care where the value comes from, only that it's stable per browser/device.
When the user logs in:
shipeasy.alias(anonymous_id, user_id);This call writes a single row to D1 mapping the two IDs. The next analysis run merges their exposures into one user.
Bucketing by an attribute
By default ShipEasy buckets users by user_id. To bucket by something else (typical for B2B: bucket by account so teammates align):
flags.gate(
"new-ui",
{
user_id: "u_4f2a",
account_id: "acct_910",
plan: "pro",
},
{ bucketBy: "account_id" },
);Or set the experiment-level bucket_by in the dashboard. The same identity is then used for both assignment and exposure stitching.
Privacy & PII
ShipEasy stores attributes you send as part of exposure events for analysis. Don't send PII you wouldn't want in your analytics warehouse.
- Pass user IDs, not emails. Emails are PII and they change. - Pass
country, not full IP. ShipEasy already derives a coarse geo from the request. - Hash anything sensitive on your side first — names, phone numbers, account secrets. - Never put an API token, password, or session cookie into an attribute.
A handful of fields are auto-treated:
You can set per-attribute retention in Project → Privacy (default 90 days for Pro, 30 days for Free). After retention, exposure rows are aggregated and the per-user attribute payload is dropped.
Troubleshooting
Two common causes: type mismatch ("5" is not 5) and missing attribute (an undefined attribute
fails every comparison except not_in against a list that contains it). Check the
dashboard's Test rules panel — paste a sample context, see exactly which rules pass.
You're evaluating without a stable user_id. Either pass user_id (preferred) or pass a
stable anonymous_id for the pre-login window.
Ship your first flag.
The shortest path from install to a flag in production. Five minutes, no card.