npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

uservoice-user-search

v1.5.0

Published

Reliable multi-strategy user search (by email or name) for the UserVoice API

Readme

uservoice-user-search

npm version CI License: MIT

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 interfacefindByEmail(), findByName(), find()
  • Account/company searchfindAccounts() and findAccountsByName() search UserVoice account collections, including external_accounts where available
  • Unified customer searchfindCustomers() merges user and account matches into display-ready user, account, and account_user rows
  • Multi-strategy fallback — four email strategies, three name strategies, tried in reliability order
  • Single-call suggestion metricsgetSuggestion() 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 pipelinegetSuggestionSupporterDetails() 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 levelssilenterrorwarninfodebugverbose; verbose adds full decoded query params and complete response bodies with configurable truncation
  • Rate-limit handling — automatic back-off and retry on 429 responses
  • Zero runtime dependencies — uses the native fetch API (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 admin scope

Installation

npm install uservoice-user-search

Quick 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 | null

Runs 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 1842ms

debug — 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 1842ms

verbose — 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)

  1. v2AdminQueryEmailGET /api/v2/admin/users?q=<email> — free-text search, post-filtered to exact email match. Primary strategy. Uses the proper search index (~150 ms on ideas.ringcentral.com).
  2. v2AdminFilterEmailGET /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.
  3. v2AdminFilterEmailOrIdGET /api/v2/admin/users?filter[email_or_external_id]=<email> — alternative filter key used by some UserVoice versions. Same post-filter defence.
  4. v1SearchEmailGET /api/v1/users/search.json?query=<email> — legacy endpoint. Requires HMAC-SHA1 OAuth on some instances (including ideas.ringcentral.com), where it will 401 and fall through.

Note on ideas.ringcentral.com: filter[email] and filter[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)

  1. v2AdminQueryNameGET /api/v2/admin/users?q=<name> — full-text admin search; richest field set. The only working strategy on ideas.ringcentral.com (autocomplete returns 404; v1 requires HMAC-SHA1).
  2. v2AdminAutocompleteGET /api/v2/admin/autocomplete?type=user&q=<name> — prefix-optimised fallback for other instances.
  3. v1SearchNameGET /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)

  1. v2AdminQueryExternalAccountsGET /api/v2/admin/external_accounts?q=<name> — CRM-synced customer account search where available.
  2. v2AdminQueryAccountsGET /api/v2/admin/accounts?q=<name> — standard admin account collection search.
  3. v2AdminAutocompleteAccountsGET /api/v2/admin/autocomplete?type=account&q=<name> — account autocomplete fallback.
  4. v2AdminFilterExternalAccountNameGET /api/v2/admin/external_accounts?filter[name]=<name> — exact-name filter fallback.
  5. v2AdminFilterAccountNameGET /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 build

Pull requests are welcome. Please add tests for any new strategies or behaviour changes.


License

MIT © RingCentral