Search API
Substring search across active fundraisers, user profiles, verified organizations and donors, plus a fundraiser-title autocomplete.
What this search is, and is not
Search is case-insensitive substring matching. It is not a full-text engine. There is no relevance score, no stemming (donations will not match donation), no fuzzy or typo tolerance, no phrase quoting, and no boolean operators. Ordering is a fixed tiered rule, not a computed score. Earlier revisions of this page documented /search/fundraisers, /search/organizations and /search/autocomplete with category, location, goal-range, tag and sort filters — none of those routes or parameters exist. The API has exactly two search endpoints, documented below.
Search
GET /api/v1/search
One endpoint covering every resource type. Authentication is optional and does not change the results — a signed-in caller and an anonymous caller see the same rows.
Query Parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
q | string | — | The search term. Fewer than 2 characters after trimming returns an empty result set with 200, not an error. |
scope | string | all | One of all, campaigns, users, orgs, donors. Any other value matches no branch and returns an empty result set with 200. |
limit | integer | 20 | Clamped to a maximum of 100. Missing, non-numeric, zero and negative values fall back to 20. |
offset | integer | 0 | Negative and non-numeric values fall back to 0. Applies to campaign results only — see the paging caveat below. |
lang | string | — | en, ru, uk or es: the reader's language. Without it, the language cookie and then Accept-Language are used. It picks which translation of a campaign's title and summary is returned, and the language of anonymous donors' aliases. |
% and _ in q are matched as the literal characters you typed; they are not wildcards.
const API_BASE = 'https://api.fundlyhub.org/api/v1';
const params = new URLSearchParams({
q: 'medical',
scope: 'campaigns',
limit: '20',
offset: '0'
});
const response = await fetch(`${API_BASE}/search?${params}`);
const data = await response.json();Response
{
"results": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"type": "campaign",
"title": "Emergency Medical Fund",
"subtitle": "Help cover medical expenses",
"snippet": "By Jane Doe",
"link": "/f/emergency-medical-fund",
"image": "https://<cdn-host>/uploads/....jpg",
"highlights": { "category": "Medical", "location": "Sacramento, CA" }
}
],
"total": 42,
"executionTimeMs": 18,
"cached": false
}Every result carries id, type (campaign, user, organization or donor) and link. link is a site-relative path on fundlyhub.org, not an API URL:
type | link | Type-specific fields |
|---|---|---|
campaign | /f/{slug} | title, subtitle (the summary), snippet (By {owner name}), image, highlights.category, highlights.location |
user | /@{handle}, or the id when the profile has no handle | title (the name), subtitle (the bio — empty for a private profile), image, handle, isPrivate |
organization | /organizations/{id} | title (default DBA, else legal name), subtitle (description), image (logo), highlights.website |
donor | /d/guest/{key} or /d/anon/{key} | donorKind (guest or anon), given (integer cents), gifts, currency, anonymousKey |
There is no per-result relevance score, and snippet is not a highlighted excerpt of the match.
A donor result is a person who gave without an account (guest) or gave anonymously (anon), as listed on the donor leaderboard — not a user account. An anonymous donor has title: null: their display name is an alias derived from anonymousKey in the reader's language, the same way the donor wall derives it.
executionTimeMs is measured for the request you made. cached is true when the payload came from the short-lived result cache.
What each scope matches
| Type | Matched against | Rows considered |
|---|---|---|
| Campaigns | title, summary, story_html, category, location, and the translated title and summary in every language | status = 'active' and visibility = 'public' only |
| Users | name; the exact handle (profile_slug, with or without a leading @); bio for profiles that are not private | All profiles. A private profile can be found by name or handle, but its bio is neither matched nor returned. |
| Organizations | legal_name, dba_name, description | Verification status approved or verified, and not soft-deleted |
| Donors | A guest's donor_name; an anonymous donor's alias in the reader's language | Paid gifts. Email addresses are never matched or returned. |
story_html is matched as stored, so a campaign can match on markup or an attribute value rather than on visible body text. Translated stories are not matched — only the story in the language it was written in.
Ordering
- Campaigns — title matches (in any language) first, then summary matches, then the rest; newest first within each tier.
- Users — an exact handle match first, then name matches, then bio matches; alphabetical by name within each tier.
- Organizations — name matches ahead of description matches.
- Donors — by amount given, largest first.
That is the whole ranking rule; nothing is scored.
Paging caveat
offset and total describe campaigns only
offset is passed to the campaign query alone. The user and organization queries always run at offset 0 and take at most floor(limit / 2) rows each (at least one user), and donors are returned only on the first page (offset = 0), at most floor(limit / 2) guests plus floor(limit / 2) anonymous donors. So with scope=all, page two repeats the same users and organizations and has no donors.
total is likewise the count of matching campaigns. It is not the number of rows in results, and with scope=users, orgs or donors it is 0 even when results are returned. Do not compute a page count from it unless you are searching campaigns.
For paging that behaves predictably, request scope=campaigns.
If the donor branch fails, the rest of the response is still returned without donor rows.
Errors
A failure returns 500 with the same envelope shape plus an error string:
{
"results": [],
"total": 0,
"executionTimeMs": 12,
"cached": false,
"error": "..."
}Because results is always present, check the HTTP status — an error response is not distinguishable from an empty one by shape alone.
Autocomplete Suggestions
GET /api/v1/suggest
Typeahead for the search box. Unauthenticated.
Query Parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
q | string | — | Fewer than 2 characters after trimming returns { "suggestions": [], "executionTimeMs": n }. |
limit | integer | 10 | Clamped to a maximum of 20. |
const API_BASE = 'https://api.fundlyhub.org/api/v1';
const response = await fetch(
`${API_BASE}/suggest?q=med&limit=10`
);
const { suggestions } = await response.json();Response
{
"suggestions": [
"Medical Relief Fund",
"Medicine for Marta"
],
"cached": false,
"executionTimeMs": 6
}suggestions is a flat array of strings, not objects — there is no type, id or count on an entry, so a suggestion cannot be linked to directly. Feed the chosen string back into GET /search as q.
Suggestions are distinct fundraiser titles only — users, organizations, categories and past search terms are never suggested. Matching is a prefix match (term%) against the original title or any translated title, so it will not find a term that appears mid-title. The suggestion is offered in the reader's language (?lang=, then the language cookie, then Accept-Language): the translated title where there is one, otherwise the original. Results are sorted alphabetically, not by popularity. Only status = 'active' and visibility = 'public' fundraisers are considered.
A failure returns 500 with { "suggestions": [], "executionTimeMs": n, "cached": false }.
Caching
Both endpoints cache on the normalised query parameters (including the language), so two callers issuing the same search share a result. Search results are held for roughly 30 seconds and suggestions for roughly a minute, which means a newly published fundraiser may not appear immediately.
Response Codes
200- Success, including "query too short" and "no matches"429- Rate limit exceeded (see Rate Limits)500- Server error (the envelope is still returned)