role, which powers Role audience filters). There are three ways to get user data in. Picking the right one matters, because one of them affects activity data.
The golden rule
identify() is an activity signal: every call bumps the user’s last_seen to now. That is correct when a real user loads your app. It is wrong for a bulk backfill.
Which method to use
| Method | Best for | Affects activity? |
|---|---|---|
| Update / Backfill API | Backfilling or syncing data from your backend, at scale | No |
| CSV import (dashboard) | One-off uploads, no engineering needed | No |
identify() | A live user loading your app | Yes (by design) |
Option 1: Update / Backfill API (recommended for engineers)
POST /api/sdk/users/update is purpose-built for this. It upserts users by your user_id, merges traits, and never touches first_seen / last_seen on existing users. It also does not trigger automations, so importing historical users won’t retroactively fire your user_created series.
The Update / Backfill API requires identity verification (an Identity Secret). If you have not set one up, use CSV import below instead, which needs no secret.
update_only: true to the body. Unknown user_ids are skipped and reported in the response’s skipped count:
signed_up_at so their activity history is accurate instead of defaulting to the import time:
Option 2: CSV import (no code)
In the dashboard, go to your contacts and use the import flow to upload a CSV. Map your columns to Halo fields (or to custom fields). Like the API, CSV import does not bump activity when updating existing users, and it dedupes byexternal_id or email so you won’t create duplicates for users the SDK already knows about. Map a signed_up_at column for accurate signup dates on new rows.
Option 3: identify() (live users only)
Use identify() in your app when a real user is present. Going forward, pass every recommended trait (including role) on identify so new and returning users persist it naturally and your data stays complete without backfills.
After a backfill: making fields filterable
Backfilled custom traits (likerole) are registered automatically so they show up in the segment and audience filter pickers as custom:role. If you backfilled directly in the database instead of via the API or CSV import, the value is still queryable but may not appear in the picker until it is seen through one of the supported paths.