Skip to main content

Endpoint

POST /api/sdk/users/update
Update or create end user records in bulk, keyed on your user_id. Use this to backfill or sync data you already have for an existing user base, for example setting a missing role trait across every user, or importing your historical users when you first set up Halo.
This endpoint never changes a user’s activity timestamps. Unlike identify, which treats every call as “this user is here now” and bumps last_seen, update leaves first_seen and last_seen untouched on existing users. That means you can run it over your whole user base without marking everyone “active today” and corrupting the Users list, activity segments, or health scores.

When to use this vs identify

identifyupdate (this endpoint)
IntentA real user is present right nowSync / backfill data you already have
last_seenBumped to now()Never touched (existing users)
Automations & seriesEnrolls (user_created, segment_match)Does not trigger automations
Lead → user conversionYes (with verified JWT)No
Identity (JWT)Per-user token (optional unless enforced)Required: org-signed token in user_token
ShapeOne user per callOne user or up to 1000 per call
Rule of thumb: call identify() from your app when a user loads it. Call update from your backend to backfill or import data.

Authentication

This endpoint needs two credentials: 1. Your publishable widget key in the Authorization header:
Authorization: Bearer ab_live_xxxxxxxxxxxxxxxx
2. A short-lived JWT in the user_token body field, signed (HS256) with your workspace’s Identity Secret. A bulk overwrite of up to 1000 users is a backend-only operation, and the publishable key is visible in browsers, so you must prove the request comes from your server. You sign the token server-side; the Identity Secret itself is never sent to Halo.
import jwt from "jsonwebtoken";

const userToken = jwt.sign(
  { scope: "users.update" }, // required: binds the token to this endpoint
  process.env.HALO_IDENTITY_SECRET,
  { algorithm: "HS256", expiresIn: "5m" } // exp required, max 1 hour
);
The token must:
  • be signed HS256 with your Identity Secret,
  • include { "scope": "users.update" },
  • include an exp claim that is unexpired and no more than 1 hour out.
It does not need a user_id claim (this is an org-scoped batch). The dedicated scope matters: identify’s per-user tokens are sent from the browser, so requiring a separate scope here prevents a captured identify token from being replayed to overwrite your whole user base. See Identity Verification.
This endpoint requires identity verification. If your workspace has not configured an Identity Secret, requests return 403. Set one up under Settings → Security, or use CSV import in the dashboard (which needs no secret).

Request Body

Send a single user:
user_id
string
required
Your system’s unique identifier for this user.
traits
object
User attributes. Same split as identify: recognized fields (name, email, signed_up_at, renewal_date, renewal_status, contract_term, payment_terms, on_contract) go to dedicated columns; everything else (e.g. role, plan) goes to custom_fields. See User Traits.mrr and arr are not writable here. Set them via identify or the Stripe integration.Do not send id, external_id, org_id, company_id, created_at, updated_at, first_seen, last_seen, or last_contacted_at. The endpoint returns HTTP 400 if any of them appear.
context
object
Structured context entries for AI consumption. Merged into the context jsonb column. See Context Entries.
user_token
string
required
Short-lived JWT (HS256) signed with your Identity Secret. See Authentication. Sent at the top level for both the single and batch shapes.
update_only
boolean
default:"false"
When true, only users that already exist in Halo are updated. Unknown user_ids are skipped instead of created and counted in the skipped field of the response. Use this to backfill traits for your existing Halo contacts without importing people who never opened the widget. Sent at the top level.
Or send a batch with the users array (up to 1000 per request):
users
array
Array of { user_id, traits?, context? } objects. Each entry follows the same rules as the single-user shape above. Combined traits + context per user must not exceed 20 KB, and the whole request must not exceed 5 MB. user_token stays at the top level, not inside each entry.

Behavior

  • Matched strictly by user_id (your external_id). There is no email/lead merging; that stays the job of identify.
  • Existing user: traits are merged (matching custom-field keys overwrite, others are preserved). Dedicated columns use COALESCE semantics (omitting a key preserves the current value). first_seen, last_seen, and source are never modified.
  • New user: a user (not a lead) is created unless update_only: true (then it’s skipped and counted in skipped). On create, first_seen and last_seen are set from signed_up_at when you provide it, otherwise to the time of the request, so importing historical users does not show everyone active today. Pass signed_up_at on imports for accurate activity history. Note created_at (“Added to Halo”) always reflects the import time and is distinct from signed_up_at.
  • Does not trigger automations, series enrollment, health recalculation, or lead conversion.
  • Backfilled custom fields (e.g. role) are registered in the attribute catalog so they appear in the segment and audience filter pickers as custom:role.

Example

# USER_TOKEN is a JWT you minted server-side, signed with your Identity Secret.
curl -X POST https://api.haloagents.ai/api/sdk/users/update \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer ab_live_xxxxxxxxxxxxxxxx" \
  -d '{
    "user_token": "'"$USER_TOKEN"'",
    "users": [
      { "user_id": "user_1", "traits": { "role": "owner" } },
      { "user_id": "user_2", "traits": { "role": "admin", "plan": "enterprise" } }
    ]
  }'
Response:
{
  "created": 3,
  "updated": 497,
  "skipped": 0,
  "total": 500
}
  • created — users that did not exist and were inserted (always 0 when update_only: true).
  • updated — existing users whose traits were merged (activity untouched).
  • skipped — unknown user_ids not created because update_only: true (always 0 otherwise).
  • total — entries processed in the request (created + updated + skipped).
To backfill a trait for your existing Halo users only (no new contacts), send update_only: true:
{
  "user_token": "...",
  "update_only": true,
  "users": [{ "user_id": "user_1", "traits": { "role": "owner" } }]
}