## 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.