Skip to main content
When you set up Halo, you usually want to bring in the users you already have, and later you may need to backfill a trait you forgot to send (a common one is 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.
Do not loop identify() over your existing users to set a missing trait. It stamps every user “active today”, which corrupts the Last Active column, the active / recent / inactive / at-risk activity segments, automation audiences, and health scores. Use the Update / Backfill API or CSV import instead, both of which leave activity untouched.

Which method to use

MethodBest forAffects activity?
Update / Backfill APIBackfilling or syncing data from your backend, at scaleNo
CSV import (dashboard)One-off uploads, no engineering neededNo
identify()A live user loading your appYes (by design)
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.
import jwt from "jsonwebtoken";

// Backfill `role` for every existing user, in batches of 500.
const BATCH = 500;
for (let i = 0; i < allUsers.length; i += BATCH) {
  const users = allUsers.slice(i, i + BATCH).map((u) => ({
    user_id: u.id,
    traits: { role: u.role },
  }));

  // Prove the request is from your backend with a short-lived JWT signed
  // by your Identity Secret. The secret itself is never sent to Halo.
  const userToken = jwt.sign(
    { scope: "users.update" },
    process.env.HALO_IDENTITY_SECRET,
    { algorithm: "HS256", expiresIn: "5m" }
  );

  await fetch("https://api.haloagents.ai/api/sdk/users/update", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: "Bearer ab_live_xxxxxxxxxxxxxxxx",
    },
    body: JSON.stringify({ user_token: userToken, users }),
  });
}
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.
Backfilling only your existing Halo users (no new contacts created for people who never opened the widget)? Add update_only: true to the body. Unknown user_ids are skipped and reported in the response’s skipped count:
body: JSON.stringify({ user_token: userToken, update_only: true, users }),
When importing brand-new users (first-time setup), include signed_up_at so their activity history is accurate instead of defaulting to the import time:
{
  user_id: "user_1",
  traits: {
    name: "Jane Doe",
    email: "[email protected]",
    role: "owner",
    signed_up_at: "2023-04-12T00:00:00Z"
  }
}
See the full Update / Backfill API reference for auth, batch limits, and the recognized vs custom trait split.

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 by external_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.
ha.identify(user.id, {
  name: user.name,
  email: user.email,
  role: user.role,        // persists to custom_fields.role
  signed_up_at: user.createdAt,
});

After a backfill: making fields filterable

Backfilled custom traits (like role) 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.