uservoice-user-search
v1.5.0
Published
Reliable multi-strategy user search (by email or name) for the UserVoice API
Readme
uservoice-user-search
Reliable user and account search for the UserVoice API — by email address, display name, or company name — plus full suggestion supporter resolution with Salesforce-synced account custom fields.
UserVoice exposes several different endpoints for finding users, and each one behaves differently depending on the API token scope, plan tier, and UserVoice version. This module solves that problem by running a prioritised sequence of search strategies and returning as soon as one yields results. If an endpoint returns nothing or errors, the next strategy is tried automatically.
Features
- Single stable interface —
findByEmail(),findByName(),find() - Account/company search —
findAccounts()andfindAccountsByName()search UserVoice account collections, includingexternal_accountswhere available - Unified customer search —
findCustomers()merges user and account matches into display-readyuser,account, andaccount_userrows - Multi-strategy fallback — four email strategies, three name strategies, tried in reliability order
- Single-call suggestion metrics —
getSuggestion()fetches a suggestion with all Salesforce-synced aggregated data (cv_enterprise,cv_majors, MRR, open opportunity values, etc.) in one API call — no supporter pagination needed - Suggestion supporter pipeline —
getSuggestionSupporterDetails()fetches all supporters for an idea and enriches each one with the full account record (including all Salesforce-synced custom fields) - Normalised response — consistent
NormalizedUser,NormalizedSupporter,NormalizedAccount, and customer-search result shapes regardless of which API responded - Auto-pagination — supporter fetching walks all pages automatically
- Concurrency-limited account fetching — batch account lookups run in parallel without hammering the API
- Six-tier log levels —
silent→error→warn→info→debug→verbose; verbose adds full decoded query params and complete response bodies with configurable truncation - Rate-limit handling — automatic back-off and retry on
429responses - Zero runtime dependencies — uses the native
fetchAPI (Node ≥ 18) - Dual ESM + CJS build — works in modern ESM projects and legacy
require()contexts
Requirements
- Node.js 18.0.0 or later (uses native
fetch) - A UserVoice OAuth bearer token with at minimum
adminscope
Installation
npm install uservoice-user-searchQuick Start
import { UserVoiceSearch } from 'uservoice-user-search';
const search = new UserVoiceSearch({
subdomain: 'mycompany', // → mycompany.uservoice.com
token: process.env.UV_TOKEN,
});
// Search by exact email address
const user = await search.findByEmail('[email protected]');
if (user) {
console.log(`Found: ${user.name} (ID ${user.id})`);
} else {
console.log('User not found.');
}
// Search by display name (returns array)
const users = await search.findByName('Alice Smith');
console.log(`${users.length} match(es) found.`);
// Auto-detect: email-like string → findByEmail, anything else → findByName
const results = await search.find('[email protected]');
const results2 = await search.find('Alice Smith');
// Search companies/accounts by account name
const accounts = await search.findAccounts('Acme Corp');
for (const account of accounts) {
console.log(account.name, account.id, account.mrr, account.arr);
}
// Search users and companies together for customer pickers
const customers = await search.findCustomers('Acme Corp');
for (const row of customers) {
console.log(row.matchType, row.accountName, row.userName, row.userEmail, row.mrr, row.arr);
}
// Fetch suggestion with all Salesforce-synced aggregate data (single API call)
const s = await search.getSuggestion(suggestionId);
console.log(s.title, s.supporterMrr, s.cvEnterprise.revenue);
// Suggestion supporters — with full account + custom fields
// (Only on standard UserVoice instances — see notes on enterprise deployments)
const rows = await search.getSuggestionSupporterDetails(suggestionId, { forumId: 1 });
for (const row of rows) {
console.log(
row.name,
row.email,
row.votes,
row.account?.name,
row.account?.customFields?.ARR,
row.account?.customFields?.Plan,
);
}CommonJS
const { UserVoiceSearch } = require('uservoice-user-search');API Reference
new UserVoiceSearch(config)
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
| subdomain | string | ✅ * | — | Your UserVoice subdomain (e.g. "mycompany" → mycompany.uservoice.com). Not required when baseUrl is provided. |
| baseUrl | string | ✅ * | — | Full base URL for instances on a custom domain (e.g. "https://ideas.mycompany.com"). Takes precedence over subdomain. |
| token | string | ✅ | — | OAuth bearer token |
| logLevel | string | | 'silent' | Log verbosity level (see table below). Takes precedence over debug. |
| debug | boolean | | false | Backward-compat alias for logLevel: 'debug'. Ignored when logLevel is set. |
| logBodyLimit | number | | 4096 | Max characters per response body printed at verbose level. Longer bodies are truncated. |
| timeoutMs | number | | 15000 | Per-request timeout in milliseconds |
| strategies.email | Strategy[] | | built-in | Override the email strategy list |
| strategies.name | Strategy[] | | built-in | Override the name strategy list |
| strategies.accounts | Strategy[] | | built-in | Override the account strategy list |
| accounts.concurrency | number | | 5 | Max parallel account requests in getSuggestionSupporterDetails |
Throws UserVoiceConfigError if subdomain or token are missing.
search.findByEmail(email)
Search for a user by their exact email address.
const user = await search.findByEmail('[email protected]');
// → NormalizedUser | nullRuns email strategies in order; returns the first match found, or null if no user is found across all strategies.
search.findByName(name, [options])
Search for users by display name.
const users = await search.findByName('Alice Smith');
// → NormalizedUser[]
// Run all strategies in parallel and merge (useful for comprehensive searches)
const users = await search.findByName('Alice Smith', { all: true });| Option | Type | Default | Description |
|---|---|---|---|
| all | boolean | false | Run all strategies and merge deduplicated results |
search.find(query, [options])
Auto-routing convenience method. If query matches *@*.*, delegates to findByEmail (result wrapped in array). Otherwise delegates to findByName.
const results = await search.find('[email protected]'); // → [NormalizedUser] or []
const results2 = await search.find('Alice Smith'); // → NormalizedUser[]search.findAccounts(query, [options])
Search for UserVoice accounts/companies by account name.
const accounts = await search.findAccounts('Acme Corp');
// → NormalizedAccount[]
for (const account of accounts) {
console.log(account.name, account.id, account.mrr, account.arr);
}This uses admin account endpoints where they are available, including CRM-synced external_accounts on UserVoice instances that expose that resource. If a tenant does not support an account search endpoint, that endpoint is treated as an empty strategy result and the search continues.
| Option | Type | Default | Description |
|---|---|---|---|
| all | boolean | false | Run all account strategies and merge deduplicated results |
| perPage | number | 25 | Results per account search request |
findAccountsByName(name, options) is the explicit-name alias for the same account/company search.
Account search finds companies. Casting a proxy vote or creating feedback in UserVoice may still require a UserVoice user/supporter unless your UserVoice instance supports account-level feedback links.
search.findCustomers(query, [options])
Search users and accounts together. This is useful for customer pickers where the caller may type either a person or a company name.
const customers = await search.findCustomers('Acme Corp');
// → NormalizedCustomerSearchResult[]
for (const row of customers) {
console.log({
type: row.matchType, // "user", "account", or "account_user"
company: row.accountName,
accountId: row.accountId,
user: row.userName,
email: row.userEmail,
mrr: row.mrr,
arr: row.arr,
});
}findCustomers() runs the existing user search (find()) and account search (findAccounts()) in parallel. If a user match and an account match refer to the same account, they are merged into one account_user row instead of returning duplicate account rows.
| Option | Type | Default | Description |
|---|---|---|---|
| all | boolean | — | Convenience flag forwarded to both user and account searches |
| users | object | {} | Options forwarded to find() / findByName() |
| accounts | object | {} | Options forwarded to findAccounts() |
search.getSuggestionSupporters(suggestionId, [options])
Fetch all supporters for a suggestion. Returns normalised supporter records with a lightweight account stub (id + name only). Custom fields on the account are not included — use getSuggestionSupporterDetails() for that.
const supporters = await search.getSuggestionSupporters(12345, { forumId: 1 });
// → NormalizedSupporter[]| Option | Type | Default | Description |
|---|---|---|---|
| forumId | number\|string | — | The UserVoice forum (project) ID the suggestion belongs to. Strongly recommended — most UserVoice instances return 404 without it. Automatically falls back to the unscoped URL on 404. |
| perPage | number | 100 | Records per API page (max 100) |
| limit | number\|null | null | Cap total supporters returned. null = fetch all pages. |
search.getAccountDetails(accountId)
Fetch the full account record for a single account ID, including all Salesforce-synced and UserVoice-native custom fields.
const account = await search.getAccountDetails(78900);
// → NormalizedAccount
console.log(account.customFields);
// { ARR: 50000, Plan: 'Enterprise', Industry: 'Technology', ... }search.getSuggestion(suggestionId)
Fetch a single suggestion with all pre-computed aggregated data in one API call.
This is the recommended method for syncing suggestion metrics from UserVoice to an external system. It returns everything UserVoice has already computed: supporter counts, MRR, Salesforce-synced segment data, and open opportunity values. The forum ID is extracted automatically from the JSON API sideloaded links and is available as suggestion.forumId.
Enterprise instances (e.g.
ideas.ringcentral.com): On these deployments, individual supporter enumeration via the API is not possible.getSuggestion()provides a reliable single-call alternative.
const s = await search.getSuggestion(51128290);
console.log(s.title); // "Dark mode support"
console.log(s.forumId); // 958493 — use this as forumId for supporter calls
console.log(s.supportersCount); // 142
console.log(s.supporterMrr); // 84250 (MRR of supporting accounts)
console.log(s.cvEnterprise.revenue); // 250000 (Enterprise ARR)
console.log(s.cvPotentialRevenue); // 310000 (open opportunity value)Returns a NormalizedSuggestion — see the Normalised Object Shapes section below.
search.getSuggestionSupporterDetails(suggestionId, [options])
The primary method for building a supporter table with account data.
Fetches all supporters for a suggestion (auto-paginated) and enriches each one with the full account record — including all Salesforce-synced custom fields. Accounts are fetched in parallel with a configurable concurrency limit.
const rows = await search.getSuggestionSupporterDetails(12345, { forumId: 1 });
// → NormalizedSupporter[] (each row has a full account.customFields map)
// Render a table
for (const row of rows) {
console.log({
name: row.name,
email: row.email,
votes: row.votes,
company: row.account?.name,
arr: row.account?.customFields?.ARR,
plan: row.account?.customFields?.Plan,
sfId: row.account?.externalId,
});
}| Option | Type | Default | Description |
|---|---|---|---|
| forumId | number\|string | — | The UserVoice forum (project) ID the suggestion belongs to. Strongly recommended — most UserVoice instances return 404 without it. Automatically falls back to the unscoped URL on 404. |
| perPage | number | 100 | Supporter records per API page |
| limit | number\|null | null | Cap total supporters (null = all) |
| concurrency | number | 5 | Max parallel account requests (overrides constructor default) |
Partial failure behaviour: if an individual account fetch fails (e.g. 403 / 404), that supporter's account field retains the lightweight stub (with an empty customFields) rather than causing the whole call to throw. A warning is logged in debug mode.
Normalised Object Shapes
NormalizedUser
{
id: number | string
name: string | null
email: string | null
createdAt: string | null // ISO-8601
avatarUrl: string | null
state: string | null // e.g. "active", "blocked"
roles: string | null // comma-separated
account: NormalizedAccount | null // embedded account/customer data when available
_raw: object // original API object
}NormalizedAccount
{
id: number | string
name: string | null
externalId: string | null // Salesforce record ID
createdAt: string | null // ISO-8601
memberCount: number | null // users in this account
mrr: number | null // monthly recurring revenue
arr: number | null // annual recurring revenue
customFields: Record<string, unknown> // all custom / Salesforce-synced fields
_raw: object
}Custom fields arrive from the API as either a plain hash ({ ARR: 50000 }) or an array of { name, value } pairs — both are normalised to a flat { key: value } map. The raw customFields map is preserved, while mrr and arr are derived as numeric first-class fields from common aliases such as mrr, MRR, account_monthly_rate, monthly_recurring_revenue, ARR, arr, and annual_recurring_revenue.
NormalizedCustomerSearchResult
{
id: string
matchType: 'user' | 'account' | 'account_user'
user: NormalizedUser | null
account: NormalizedAccount | null
userId: number | string | null
userName: string | null
userEmail: string | null
accountId: number | string | null
accountName: string | null
mrr: number | null
arr: number | null
_raw: { user: object | null, account: object | null }
}NormalizedSuggestion
{
id: number | string
title: string | null
status: string | null
forumId: number | string | null // extracted from JSON API links
createdAt: string | null // ISO-8601
updatedAt: string | null // ISO-8601
// Supporter aggregates (pre-computed by UserVoice)
supportersCount: number | null
supportingAccountsCount: number | null
supporterMrr: number | null
supporterRevenue: number | null
firstSupportAt: string | null // ISO-8601
lastSupportAt: string | null // ISO-8601
// Salesforce-synced segment breakdowns
cvEnterprise: { accountsCount: number|null, revenue: number|null, usersCount: number|null }
cvMajors: { accountsCount: number|null, revenue: number|null, usersCount: number|null }
cvMidmarket: { accountsCount: number|null, revenue: number|null, usersCount: number|null }
cvSm: { accountsCount: number|null, revenue: number|null, usersCount: number|null }
// Opportunity metrics
cvOpenOpportunities: number | null
cvOpenOpportunitiesBlockers: number | null
cvPotentialRevenue: number | null
cvPotentialRevenueBlockers: number | null
cvLostOpportunities: number | null
cvLostRevenue: number | null
cvPercentOpportunitiesWon: number | null
cvPercentRevenueWon: number | null
_raw: object
}NormalizedSupporter
{
id: number | string // supporter record ID
userId: number | string | null // UserVoice user ID
name: string | null
email: string | null
createdAt: string | null // ISO-8601 — user created at
avatarUrl: string | null
state: string | null
roles: string | null
votes: number | null // votes applied to this suggestion
supportedAt: string | null // ISO-8601 — when support was recorded
account: NormalizedAccount | null // stub from getSuggestionSupporters();
// full record from getSuggestionSupporterDetails()
_raw: object
}Error types
All error classes are exported from the package root.
import {
UserVoiceApiError,
UserVoiceRateLimitError,
UserVoiceConfigError,
} from 'uservoice-user-search';| Class | Extends | When thrown |
|---|---|---|
| UserVoiceConfigError | Error | Invalid constructor arguments |
| UserVoiceApiError | Error | HTTP error from the API (4xx/5xx) or non-JSON response |
| UserVoiceRateLimitError | UserVoiceApiError | All retry attempts exhausted after a 429 |
try {
const rows = await search.getSuggestionSupporterDetails(12345);
} catch (err) {
if (err instanceof UserVoiceRateLimitError) {
console.error(`Rate limited. Try again in ${err.retryAfter}s.`);
} else if (err instanceof UserVoiceApiError) {
console.error(`API error ${err.status}: ${err.message}`);
}
}Log Levels
The module uses a six-tier log system controlled by the logLevel constructor option.
| Level | Value | What it prints |
|---|---|---|
| silent | 0 | Nothing (default) |
| error | 1 | Hard API errors, config problems |
| warn | 2 | + Non-fatal warnings (unexpected response shapes, partial failures, 429 retry notices) |
| info | 3 | + Public method entry/exit with result counts and per-call timing |
| debug | 4 | + Strategy events, request URLs, redacted headers, response status and timing |
| verbose | 5 | + Full decoded query-param listings and complete response bodies (truncated at logBodyLimit) |
// info — timing summaries and result counts only
const search = new UserVoiceSearch({
subdomain: 'mycompany',
token: process.env.UV_TOKEN,
logLevel: 'info',
});
// debug — full request/response metadata (no bodies)
const search = new UserVoiceSearch({
subdomain: 'mycompany',
token: process.env.UV_TOKEN,
logLevel: 'debug',
});
// verbose — everything, including full response bodies
const search = new UserVoiceSearch({
subdomain: 'mycompany',
token: process.env.UV_TOKEN,
logLevel: 'verbose',
logBodyLimit: 8192, // optional: default is 4096 chars
});
// backward compat — debug:true still works (maps to logLevel:'debug')
const search = new UserVoiceSearch({
subdomain: 'mycompany',
token: process.env.UV_TOKEN,
debug: true,
});You can also reference level names programmatically:
import { LOG_LEVELS } from 'uservoice-user-search';
// LOG_LEVELS = { silent: 0, error: 1, warn: 2, info: 3, debug: 4, verbose: 5 }Example output at each level
info — just the summary line per public method call:
[uservoice-user-search] [INFO] getSuggestionSupporterDetails: suggestion #12345
[uservoice-user-search] [INFO] getSuggestionSupporterDetails: 47 supporter(s), 23 unique account(s) to enrich
[uservoice-user-search] [INFO] getSuggestionSupporterDetails → 47 row(s), 23/23 accounts enriched in 1842msdebug — adds per-request metadata and strategy events:
[uservoice-user-search] [INFO] getSuggestionSupporterDetails: suggestion #12345
[uservoice-user-search] [DEBUG] → GET https://mycompany.uservoice.com/api/v2/admin/suggestions/12345/supporters?page=1&per_page=100
[uservoice-user-search] [DEBUG] headers: {"Authorization":"Bearer [REDACTED]","Accept":"application/json"}
[uservoice-user-search] [DEBUG] ← 200 https://... — 47 result(s) in 318ms
[uservoice-user-search] [DEBUG] → GET https://mycompany.uservoice.com/api/v2/admin/accounts/100
[uservoice-user-search] [DEBUG] ← 200 https://... — 1 result(s) in 94ms
[uservoice-user-search] [INFO] getSuggestionSupporterDetails → 47 row(s), 23/23 accounts enriched in 1842msverbose — additionally expands query params and response bodies:
[uservoice-user-search] [DEBUG] → GET https://mycompany.uservoice.com/api/v2/admin/accounts/100
[uservoice-user-search] [VERBOSE] query params:
[uservoice-user-search] [VERBOSE] (none for this path)
[uservoice-user-search] [DEBUG] ← 200 https://... — 1 result(s) in 94ms
[uservoice-user-search] [VERBOSE] response body (843 chars):
{
"account": {
"id": 100,
"name": "Acme Corp",
"custom_fields": { "ARR": 50000, "Plan": "Enterprise" },
...
}
}Bearer tokens are always redacted regardless of log level.
How Strategies Work
Email search strategies (in order)
v2AdminQueryEmail—GET /api/v2/admin/users?q=<email>— free-text search, post-filtered to exact email match. Primary strategy. Uses the proper search index (~150 ms onideas.ringcentral.com).v2AdminFilterEmail—GET /api/v2/admin/users?filter[email]=<email>— exact-match filter; works natively on some instances. Post-filtered even here: on instances that silently ignore the filter and return unrelated users, no false positive is returned.v2AdminFilterEmailOrId—GET /api/v2/admin/users?filter[email_or_external_id]=<email>— alternative filter key used by some UserVoice versions. Same post-filter defence.v1SearchEmail—GET /api/v1/users/search.json?query=<email>— legacy endpoint. Requires HMAC-SHA1 OAuth on some instances (includingideas.ringcentral.com), where it will 401 and fall through.
Note on
ideas.ringcentral.com:filter[email]andfilter[email_or_external_id]are silently ignored by this instance (they scan all 21 M+ users and each takes ~10 s). Strategy 1 (q=email) is the only fast path and resolves the correct user in ~150 ms.
Name search strategies (in order)
v2AdminQueryName—GET /api/v2/admin/users?q=<name>— full-text admin search; richest field set. The only working strategy onideas.ringcentral.com(autocomplete returns 404; v1 requires HMAC-SHA1).v2AdminAutocomplete—GET /api/v2/admin/autocomplete?type=user&q=<name>— prefix-optimised fallback for other instances.v1SearchName—GET /api/v1/users/search.json?query=<name>— legacy endpoint fallback.
Each strategy stops the chain as soon as it returns results (unless all: true is passed to findByName). API errors in a single strategy cause a fall-through to the next — only a 429 Too Many Requests propagates immediately.
Account search strategies (in order)
v2AdminQueryExternalAccounts—GET /api/v2/admin/external_accounts?q=<name>— CRM-synced customer account search where available.v2AdminQueryAccounts—GET /api/v2/admin/accounts?q=<name>— standard admin account collection search.v2AdminAutocompleteAccounts—GET /api/v2/admin/autocomplete?type=account&q=<name>— account autocomplete fallback.v2AdminFilterExternalAccountName—GET /api/v2/admin/external_accounts?filter[name]=<name>— exact-name filter fallback.v2AdminFilterAccountName—GET /api/v2/admin/accounts?filter[name]=<name>— exact-name filter fallback.
Unsupported account endpoints are treated as empty results so account search can degrade gracefully on tenants that only expose user search. A 429 Too Many Requests still propagates immediately.
Custom strategies
import { UserVoiceSearch } from 'uservoice-user-search';
import { v2AdminFilterEmail } from 'uservoice-user-search/src/strategies/email.js';
const search = new UserVoiceSearch({
subdomain: 'mycompany',
token: process.env.UV_TOKEN,
strategies: {
email: [{ name: 'v2AdminFilterEmail', fn: v2AdminFilterEmail }],
},
});Contributing
git clone https://github.com/ringcentral/uservoice-user-search.git
cd uservoice-user-search
npm install
npm test
npm run buildPull requests are welcome. Please add tests for any new strategies or behaviour changes.
License
MIT © RingCentral
