Skip to main content

Endpoint

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

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:
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.
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:
string
required
Your system’s unique identifier for this user.
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.
object
Structured context entries for AI consumption. Merged into the context jsonb column. See Context Entries.
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.
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):
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 outreach campaigns, 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

Response:
  • 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: