## Summary
Add server-side search to the users collection endpoint so API consumers can find users by
profile details — name, email, phone, address fields, and custom user properties — without
downloading the full member roster.
## Current behavior
GET /v1/users
accepts these query parameters:
| Parameter | Type | Behavior |
|-----------------|---------|-------------------------------------------------------|
|
_limit
| integer | Page size, default 20, max 100 |
|
_offset
| integer | Pagination offset |
|
_since
| integer | Unix epoch seconds; users modified after this time |
|
user_list_ids
| string | Comma-separated user list IDs |
|
phone_number
| string | Exact match, normalized |
|
email
| string | Exact match, case-insensitive |
GET /v1/users/{id}
fetches a single user by ID.
So today a user is reachable by
ID, exact email, or exact phone number
— nothing else.
The user object itself carries far more addressable data:
  • first_name
    ,
    last_name
    ,
    alternate_name
  • other_emails[]
    ,
    other_phone_numbers[]
  • address
    (
    address1
    ,
    address2
    ,
    city
    ,
    state
    ,
    zip_code
    ,
    country
    )
  • chapter_id
    ,
    chapter_ids[]
    ,
    branch_id
  • preferred_language
    ,
    second_language
  • custom_user_properties
    (organization- and chapter-scoped, enumerable via
GET /v1/custom_user_properties
, each with an
id
,
key
,
name
, and
field_type
)
None of these can be used to locate a user.
## Problem
Our Discord bot needs to resolve a person to a Solidarity Tech profile when all we have is a
partial detail — a name a member typed or a Discord handle stored as a custom
user property. There is no server-side way to do this.
The only workaround is to paginate the entire user collection and filter client-side. That is
expensive in a way that scales badly:
  • Page size caps at 100, so a roster of
    N
    members costs
    ceil(N / 100)
    requests.
  • The rate limit is 60 requests per 30 seconds (~2/sec), so 10,000 members is ~100 requests
and ~50 seconds of wall-clock time for a
single lookup
.
  • The practical response is to mirror the whole roster into a local cache, which means we are
maintaining a stale copy of member PII on our own infrastructure purely to work around a
missing query parameter.
  • _since
    helps keep such a cache warm but does not help a cold lookup, and it does not
surface deletions.
Filtering on
custom_user_properties
is the sharpest pain: those fields are where
organization-specific identity lives (Discord username, membership number, steward role), and
they are precisely the fields with no lookup path at all.
## Proposed API Changes
Two additions, either of which is independently useful.
### 1. Free-text search parameter
GET /v1/users?q=jordan%20rivera
  • q
    (string, optional): case-insensitive substring match across a documented set of fields —
suggested default:
first_name
,
last_name
,
alternate_name
,
email
,
other_emails
,
phone_number
,
other_phone_numbers
.
  • Multi-token input should match tokens independently (
    "jordan rivera"
    matches
first_name="Jordan"
,
last_name="Rivera"
).
  • Results paginate with the existing
    _limit
    /
    _offset
    and return the usual
meta.total_count
, so callers can detect over-broad queries.
  • Composes with existing filters (
    q
    +
    user_list_ids
    +
    _since
    are ANDed).
Optionally,
q_fields=first_name,last_name
to narrow which fields participate.
### 2. Per-field filters
GET /v1/users?last_name=rivera&city=greensboro
GET /v1/users?custom_user_properties[discord_username]=jrivera
  • Scalar filters for
    first_name
    ,
    last_name
    ,
    alternate_name
    ,
    city
    ,
    state
    ,
    zip_code
    ,
chapter_id
,
branch_id
,
preferred_language
.
  • custom_user_properties[<key>]=<value>
    keyed by the
    key
    already returned from
GET /v1/custom_user_properties
. For
Multiple Checkboxes
fields, match if the value is
present in the array.
  • Multiple filters AND together. Repeating a parameter (or comma-separating) ORs within that
field.
  • Match semantics should be documented per field; exact match is acceptable for all of these
and is simpler than prefix or fuzzy matching. Consistency with the existing
email
parameter's case-insensitivity would be ideal.
### Errors
Reuse the existing
422 invalid user filter
response for unknown fields, unknown custom
property keys, and queries below a minimum length (if one is enforced).
## Acceptance criteria
  • A user with a known first and last name can be located in
    one
    request, with no prior
knowledge of their ID, email, or phone number.
  • A user can be located by the value of a custom user property in one request.
  • New parameters are optional; omitting them preserves today's response exactly.
  • Results respect the caller's existing permission scope — search must not widen visibility
beyond what paginating
GET /v1/users
already returns for that API key.
  • Pagination metadata (
    total_count
    ,
    limit
    ,
    offset
    ) reflects the filtered set.
## Alternatives considered
  • Client-side cache of the full roster.
    What we do now. Duplicates member PII onto
third-party infrastructure, goes stale, and still costs a full crawl to seed.
  • Saved filters / user lists.
    user_list_ids
    filtering works well for pre-defined
segments, but lists are authored in the UI and cannot express an ad-hoc lookup for one
person by name.
  • Webhook-driven local index.
    Requires building and operating a search index for what is
fundamentally a database query on Solidarity Tech's side, and inherits every consistency
problem of the cache approach.
## Impact
This unblocks any integration that starts from a human-supplied identifier rather than a
Solidarity Tech ID: Discord and Slack bots, volunteer check-in tooling, canvassing apps, and
help-desk lookups. It also reduces load on the API — one filtered query replaces a full-roster
crawl that today runs right up against the rate limit.