ShipEasy

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.

Production readyOn this page · 7 min readUpdated · May 3, 2026Works with · Server SDK · Browser SDK · Targeting · Experiments

A user attribute is any key/value pair you attach to a user when calling the SDK. ShipEasy uses attributes for three things:

  1. Targeting rules on gates — "only enable this for plan = pro".
  2. Variant assignment in experiments — "assign by account_id so all teammates see the same thing".
  3. Segmentation in analysis — "how did the lift differ for country = US vs EU?".

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:

Field
Type
Description
user_idrequired
string
Stable identifier for a logged-in user. Used as the default bucketing key. Pass it as soon as the user authenticates.
anonymous_id
string ?
Stable identifier before login. The browser SDK auto-generates and persists this in a cookie; on the server, you provide it.

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.

`user_id` is required for deterministic bucketing

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:

Field
Type
Description
stringrequired
primitive
Equality, regex, contains. Most attributes are strings.
numberrequired
primitive
Numeric comparisons (gt, lte, …) and equality.
booleanrequired
primitive
Equality only.
string[]required
array
Use with 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.

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:

  1. The dashboard rule builder can autocomplete attribute names and validate types.
  2. 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:

Field
Type
Description
eq / neqrequired
any
Strict equality.
in / not_inrequired
scalar vs array
"is this value in the list". Use the inverse to negate.
gt / gte / lt / lterequired
number
Numeric comparison.
containsrequired
string ⊂ string · value ⊂ array
Substring or membership.
regexrequired
string
JS regex match. Anchor your patterns.

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.

Hard rules
  • 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:

Field
Type
Description
email
string ?
Auto-hashed before write. Only the hash is stored. Useful only for cohort lookup, never for targeting on substrings.
ip
string ?
Coarsened to country + region. Full IP is dropped before write.
user_idrequired
string
Stored as-is. Use a UUID, not an email.

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

Targeting rules don't match

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.

Experiment assignments flip between page loads

You're evaluating without a stable user_id. Either pass user_id (preferred) or pass a stable anonymous_id for the pre-login window.

NEXT

Ship your first flag.

The shortest path from install to a flag in production. Five minutes, no card.

Create a gate
$shipeasy gate create new-checkout
Roll out to 5%
$shipeasy gate rollout new-checkout --percent 5
Was this page helpful?✎ Edit on GitHub

On this page