npm install @playlive/fundraiser-data
Pure native-fetch REST surface for Tiltify + Twitch charity data — no
React, no TanStack Query, no Zustand. Ported from
playlive-overlay-data-layer/src/api/* with the React-aware glue
stripped and the global getConfig() swapped for a self-contained
configure() singleton.
bun add @playlive/fundraiser-data
Two of the three peers are required and are installed automatically by npm 7+ / Bun, so the one-liner above already pulls them in:
| Peer | Required? | Why |
|---|---|---|
@playlive/tiltify-core |
yes | Every Tiltify fetcher drives the tiltify singleton and re-exports its types. |
@playlive/twitch-charity |
yes | Owns the Twitch charity wire shapes, fetchers, and Tiltify converters. |
@playlive/realtime-pipeline |
optional | Demo fixtures only (peerDependenciesMeta.optional). See Demo mode. |
To pin them explicitly (recommended for apps that also import those packages directly, so a single version is hoisted):
bun add @playlive/fundraiser-data @playlive/tiltify-core @playlive/twitch-charity
bun add @playlive/realtime-pipeline # optional — demo fixtures only
Peers are declared jose-style so wire types stay in lockstep across
packages. No runtime dependencies beyond the peers. Native fetch only.
import {
configure,
createDonationsFetcher,
fetchCampaign,
fetchMilestones,
} from "@playlive/fundraiser-data";
// Call this once at app boot.
configure({
tiltifyProxyUrl: import.meta.env.VITE_TILTIFY_PROXY_URL, // deployed separately from the services below
twitchServiceUrl: "https://main.playlive.core.api.experience.stjude.org",
// Optional — only needed when the app touches these surfaces:
scheduleApiUrl: "https://main.playlive.schedule.api.experience.stjude.org",
lifetimeApiUrl: "https://main.playlive.lifetime-raised.api.experience.stjude.org",
leaderboardApiUrl: "https://main.playlive.leaderboard.api.experience.stjude.org",
});
Skip the URL boilerplate: use
@playlive/fundraiser-data/environmentsto pull the four Play Live service URLs (twitchServiceUrl,lifetimeApiUrl,leaderboardApiUrl,scheduleApiUrl) from a versioned preset instead of hand-wiring them.
// (imports from the block above)
// Unified — works for both `tiltify` and `twitch`.
const campaign = await fetchCampaign({
charityType: "tiltify",
teamUserSlug: "@some-user",
slug: "their-campaign",
});
const milestones = await fetchMilestones({
charityType: "tiltify",
campaignId: campaign?.id,
});
// Cursor-aware donations fetcher (shape suits TanStack Query's
// useInfiniteQuery, but works standalone).
const donations = createDonationsFetcher({
charityType: "tiltify",
campaignId: campaign?.id ?? "",
});
const page1 = await donations({ pageParam: null });
const page2 = await donations({ pageParam: page1.metadata.after });
configure() must run before any fetch*() call — getConfig() throws
"@playlive/fundraiser-data not configured" otherwise, so a misordered
boot sequence surfaces immediately instead of silently 404ing.
| Subpath | Description |
|---|---|
@playlive/fundraiser-data |
Default barrel — re-exports every subpath below, plus PACKAGE_NAME + KNOWN_URLS. |
@playlive/fundraiser-data/config |
configure, getConfig, isConfigured, resetConfig, setDemoProvider, getDemoProvider, resetDemoProvider, DEFAULT_CAUSE_ID, DEFAULT_CONFIG + the FundraiserDataConfig / DemoProvider types. |
@playlive/fundraiser-data/tiltify |
fetchTiltifyCampaign, createTiltifyDonationsFetcher, fetchTiltifyFlattenedDonations, fetchTiltifyMilestones, fetchTiltifyRewards, fetchTiltifyPolls, fetchTiltifyTargets, fetchTiltifySchedule, fetchTiltifyUser, fetchTiltifyTeam, fetchTiltifyFundraisingEvent, fetchTiltifyFundraisingEventMilestones, fetchTiltifyCause, fetchTiltifyEventCampaigns, fetchTiltifyCurrentEvents, fetchTiltifyUserCampaigns, fetchTiltifyUserAndTeamCampaigns, createTiltifyLeaderboardFetcher |
@playlive/fundraiser-data/twitch |
fetchTwitchCampaign, fetchTwitchCampaignDonations, createTwitchDonationsFetcher, convertTwitchToTiltifyCampaign, convertTwitchToTiltifyDonation, TwitchApiError |
@playlive/fundraiser-data/playlive |
fetchScheduleBlockRaised, fetchLifetimeRaised, fetchPreviousYearTotals, fetchGiftsThatGiveMilestones, bucketGiftsThatGiveGoal, GIFTS_THAT_GIVE_MILESTONE_GOALS, fetchDonorSpotlight, fetchLeaderboardExclusions, insertLeaderboardExclusion, deleteLeaderboardExclusion, fetchLeaderboardWithExclusions, postTiltifyTestDonations |
@playlive/fundraiser-data/donation-trains |
fetchDonationTrains, fetchDonationTrainHighRateDonors, fetchDonationTrainCommonTrains, fetchUpdatedTrainStatus, updateTrainVisibility, processDonationsForTrains, fetchCampaignRulesets, createCampaignRuleset, updateRuleset, deleteRuleset, requireDonationTrainApiUrl |
@playlive/fundraiser-data/projections |
extractCampaignAmounts, extractCampaignFundraisingEventAmounts, flattenDonationPages, getDonorLevel, DONOR_LEVEL_THRESHOLDS, selectCurrentFundraisingEvents — pure React-free projections over the Tiltify domain types. |
@playlive/fundraiser-data/environments |
Per-env FundraiserDataConfig presets — DEV_CONFIG, QA_CONFIG, PROD_CONFIG, getConfigForEnv(env, overrides?), buildPreset, ENV_URLS, GENERATED_AT. |
@playlive/fundraiser-data/unified |
CharityType-dispatched fetchCampaign / createDonationsFetcher / fetchMilestones / fetchRewards / fetchPolls / fetchTargets / fetchSchedule / fetchUser / fetchTeam / fetchFundraisingEvent / fetchFundraisingEventMilestones / fetchCause / fetchEventCampaigns. |
@playlive/fundraiser-data/demo |
isDemoMode, isDemoCampaignId, isDemoFundraisingEventId, stripSigil + the DEMO_* slug / ID constants (no fixtures — see Demo mode). |
@playlive/fundraiser-data/types |
CharityType, DonationFetchConfig, PaginatedResponse, TiltifyPaginationMetadata, TwitchPaginationMetadata. |
@playlive/fundraiser-data/environments ships versioned
FundraiserDataConfig presets for the three Play Live service
environments. The four URL fields (twitchServiceUrl,
lifetimeApiUrl, leaderboardApiUrl, scheduleApiUrl) are generated
from the deployed API domains, so the presets track the live
environments rather than a hand-typed copy.
import { configure } from "@playlive/fundraiser-data/config";
import { getConfigForEnv } from "@playlive/fundraiser-data/environments";
configure(
getConfigForEnv("prod", {
// The Tiltify proxy is deployed separately — supply your own.
tiltifyProxyUrl: import.meta.env.VITE_TILTIFY_PROXY_URL,
}),
);
Exports:
| Export | Description |
|---|---|
DEV_CONFIG / QA_CONFIG / PROD_CONFIG |
Frozen FundraiserEnvPreset objects — four URLs + causeId. tiltifyProxyUrl deliberately absent. |
getConfigForEnv(env, overrides?) |
Merges a preset with overrides and returns a FundraiserDataConfig ready for configure(). Throws on an unknown env. |
buildPreset(env) |
Pure preset builder behind the three frozen constants. Exported for tests. |
ENV_URLS |
Raw URL table keyed by FundraiserEnv — useful for consumers that only want one field. |
GENERATED_AT |
ISO-8601 timestamp of the last URL-table refresh. |
Overrides always win over the preset — handy for pointing a QA
build at a locally-run schedule API. Any field of
FundraiserDataConfig is fair game.
Each published version pins the URL table it shipped with;
GENERATED_AT tells you when that table was last refreshed.
Full generated API documentation: https://packages.playlive.experience.stjude.org/p/@playlive/fundraiser-data/docs/
./config)| Export | Kind | Notes |
|---|---|---|
configure |
function | configure(config: FundraiserDataConfig): void. Idempotent — a later call replaces the previous config. |
getConfig |
function | Returns Required<FundraiserDataConfig>. Throws when called before configure(). |
isConfigured |
function | Non-throwing boolean probe. |
resetConfig |
function | Wipes config. Tests should call this in afterEach. |
setDemoProvider |
function | Inject demo fixtures (typically the whole @playlive/realtime-pipeline/demo namespace). Pass null to detach. |
getDemoProvider / resetDemoProvider |
function | Read back / clear the registered provider. |
FundraiserDataConfig |
interface | tiltifyProxyUrl (required) + twitchServiceUrl, causeId, scheduleApiUrl, lifetimeApiUrl, leaderboardApiUrl, donorSpotlightApiUrl, donationTrainApiUrl. |
DemoProvider |
interface | All-optional getDemo* methods; unimplemented ones fall back to null / []. |
DEFAULT_CAUSE_ID |
const | St. Jude cause UUID — the default for config.causeId. |
DEFAULT_CONFIG |
const | Defaults merged under the consumer config (every optional URL defaults to ""). |
State is anchored on a Symbol.for() slot on globalThis, so duplicate
module copies produced by a bundler's optimizeDeps pre-bundling still
resolve the same singleton.
| Export | Source | Notes |
|---|---|---|
fetchCampaign |
./unified |
CharityType-dispatched campaign fetcher. Twitch payloads are projected into the Tiltify shape. |
createDonationsFetcher |
./unified |
Cursor-aware donations fetcher factory. Overloaded: a "tiltify" literal yields a string-cursor closure (metadata.after), "twitch" a numeric-page one (metadata.nextPage). |
fetchMilestones / fetchRewards / fetchPolls / fetchTargets / fetchSchedule |
./unified |
Twitch returns [] for all five (unsupported). |
fetchUser / fetchTeam / fetchFundraisingEvent / fetchCause |
./unified |
Twitch returns null (unsupported). |
fetchEventCampaigns / fetchFundraisingEventMilestones |
./unified |
Twitch returns [] (unsupported). |
fetchTiltify* |
./tiltify |
Per-entity Tiltify-only fetchers. fetchTiltifyCampaign throws "Campaign not found" when neither id nor (teamUserSlug, slug) resolves; the collection fetchers swallow errors → []. |
fetchTiltifyUserCampaigns |
./tiltify |
Personal campaigns owned by a Tiltify user (by user UUID). Nullish / "null" string guard. |
fetchTiltifyUserAndTeamCampaigns |
./tiltify |
Union of personal + team campaigns for a Tiltify user (by user UUID). Backs the landing "pick a campaign" flow. |
fetchTiltifyFlattenedDonations |
./tiltify |
Walks the cursor; capped at maxPages (default 100 pages × 100 rows). |
fetchTiltifyCurrentEvents |
./tiltify |
Cause-level fundraising-event list (GET public/causes/{id}/fundraising_events, limit 100). Every year, published or not. Swallows errors → []. |
createTiltifyLeaderboardFetcher |
./tiltify |
Cursor-aware Tiltify donor-leaderboard fetcher factory (shape-compatible with useInfiniteQuery). |
fetchTwitch* / convertTwitchTo* |
./twitch |
Twitch-only fetchers + pure shape adapters (re-exported from @playlive/twitch-charity, with causeId injected from the singleton config). |
TwitchApiError |
./twitch |
Thrown on non-2xx from the Twitch proxy. Deprecated alias of TwitchCharityApiError — same class, so instanceof matches either name. |
fetchScheduleBlockRaised |
./playlive |
Play Live schedule-block REST baseline (GET /schedules/campaigns/{id}/raised). Requires scheduleApiUrl. |
fetchLifetimeRaised |
./playlive |
Lifetime raised total for a user / team (GET /getLifetimeRaised). Returns null on NODATA. Requires lifetimeApiUrl. |
fetchPreviousYearTotals |
./playlive |
Historical yearly totals (GET /getPreviousYearTotals). Requires lifetimeApiUrl. |
fetchGiftsThatGiveMilestones |
./playlive |
Ordered gifts-that-give rows for a goal (GiftsThatGiveMilestoneGoal literal or raw number). Requires lifetimeApiUrl. |
bucketGiftsThatGiveGoal |
./playlive |
Snap an arbitrary goal amount down to the highest GIFTS_THAT_GIVE_MILESTONE_GOALS tier it clears (1200 → "1000"). Returns null below the $100 floor. |
fetchLeaderboardExclusions |
./playlive |
Donor-name exclusion list read (GET /leaderboard-exclusions/{id}). Public. Requires leaderboardApiUrl. |
insertLeaderboardExclusion / deleteLeaderboardExclusion |
./playlive |
Exclusion mutations. Accept adminApiKey (x-api-key) or tiltifyOAuthToken (Authorization: OAuth <token>). |
fetchLeaderboardWithExclusions |
./playlive |
Server-filtered leaderboard (GET /leaderboard-with-exclusions/{id}). Supports fixed calendar buckets (timeType) or ad-hoc windows (startDate / endDate). Returns MonetaryLeaderboardEntry[]. |
fetchDonorSpotlight |
./playlive |
Donor spotlight overview (GET /spotlight/overview) for a campaign — donor-of-the-hour, biggest-donation-of-the-day, community hero. Returns null on non-2xx. Requires donorSpotlightApiUrl. |
postTiltifyTestDonations |
./playlive |
Fire a synthetic donation / batch through the core REST API (POST /donations/tiltify/test) so alerts, trains, timers and every WS subscriber react as if Tiltify delivered it. Accepts adminApiKey or tiltifyOAuthToken; demo campaigns need neither. Requires twitchServiceUrl. |
fetchDonationTrains / fetchDonationTrainHighRateDonors / fetchDonationTrainCommonTrains / fetchUpdatedTrainStatus |
./donation-trains |
Donation-train reads (GET /get-trains-for-campaign/{id}, /get-stats/*, /get-updated-train-status/{id}). Requires donationTrainApiUrl. |
updateTrainVisibility / processDonationsForTrains |
./donation-trains |
Train mutations (PATCH /trains/{id}, POST /process-donations/). Requires donationTrainApiUrl. |
fetchCampaignRulesets / createCampaignRuleset / updateRuleset / deleteRuleset |
./donation-trains |
Full CRUD on donation-train rulesets. Requires donationTrainApiUrl. |
./projections)Pure, network-free functions over the Tiltify domain types — safe to call from a Node warmer, a Lambda, or a React render.
| Export | Kind | Notes |
|---|---|---|
extractCampaignAmounts |
function | CampaignLike | null → { totalAmount, currentAmount, goalAmount, originalGoalAmount, supportingAmount }, all defensively parsed to numbers (0 on missing / NaN). |
extractCampaignFundraisingEventAmounts |
function | Same idea across a campaign + its parent fundraising event. { forceCampaignGoal: true } pins overallGoalAmount to the campaign's own goal instead of the umbrella event goal. |
flattenDonationPages |
function | { pages: [{ data }] } → one sorted TiltifyDonation[]. Newest first; { flipSorting: true } for oldest first. Non-donation rows are dropped. |
getDonorLevel |
function | number | string → DonorLevel. Truncates before comparison, so 24.99 → "grey". |
DONOR_LEVEL_THRESHOLDS |
const | Frozen ascending ladder: bronze 25 / silver 50 / gold 75 / platinum 100. |
selectCurrentFundraisingEvents |
function | Narrows a raw fundraising-event list to the in-flight Play Live season, newest first. Accepts { now } for deterministic tests. |
| Export | Kind | Notes |
|---|---|---|
isDemoMode(userOrTeamSlug, campaignSlug) |
function | Predicate over an already-sigil-stripped slug pair. |
isDemoCampaignId / isDemoFundraisingEventId |
function | UUID-sentinel predicates. |
stripSigil |
function | Drops a leading @ (user) or + (team) from a slug; type-preserving overloads. |
DEMO_USER_SLUG, DEMO_TEAM_SLUG, DEMO_CAMPAIGN_SLUG, DEMO_TEAM_CAMPAIGN_SLUG, DEMO_CAMPAIGN_ID, DEMO_TEAM_CAMPAIGN_ID, DEMO_FUNDRAISING_EVENT_ID |
const | Identifier constants (zero fixtures inlined). |
PACKAGE_NAME |
const | Identifier for runtime version-pinning. |
KNOWN_URLS |
const | Twitch Extension URL disclosure list (frozen, empty). |
The fetchers transparently short-circuit to demo fixtures when the
incoming slugs or IDs match the demo identifiers — no consumer-side
branching required. Fixtures themselves live in
@playlive/realtime-pipeline/demo (≈25 KB of canned data) and are
injected at app boot:
import { fetchCampaign, setDemoProvider } from "@playlive/fundraiser-data";
import * as demo from "@playlive/realtime-pipeline/demo";
setDemoProvider(demo);
// Now any fetch call with a demo slug returns the canned fixture
// without touching the network.
await fetchCampaign({
charityType: "tiltify",
teamUserSlug: "@playliveDemoUser",
slug: "playliveDemoCampaign",
});
The @ / + sigil is optional — fetchTiltifyCampaign runs
stripSigil() before matching, so "@playliveDemoUser" and
"playliveDemoUser" behave identically.
If no provider is registered, demo slugs resolve to null / []
rather than hitting Tiltify — safer than leaking real network traffic
from a demo overlay misconfiguration.
Tiltify data is read through the Tiltify v5 REST API — see Tiltify's
public developer documentation at https://developers.tiltify.com for
the upstream resource shapes. The typed client and domain types are
re-exported transitively via
@playlive/tiltify-core.
The Twitch Charity proxy is operated by Play Live and has no public
spec; the canonical wire shapes live in
@playlive/twitch-charity and are version-pinned
by tests.
The Play Live first-party services wrapped by the /playlive and
/donation-trains subpaths (schedule, lifetime-raised, leaderboard,
donor spotlight, donation trains, and the core REST API) each publish
their own Swagger UI at <serviceUrl>/docs — point it at whichever URL
you passed to configure().
This package itself does not hard-code any production hosts — every
endpoint flows through the consumer-supplied URLs passed to
configure(). The KNOWN_URLS export is therefore empty:
import { KNOWN_URLS } from "@playlive/fundraiser-data";
console.log(KNOWN_URLS);
// []
Your overlay app must add every URL it passes to configure()
(tiltifyProxyUrl, twitchServiceUrl, scheduleApiUrl,
lifetimeApiUrl, leaderboardApiUrl, donorSpotlightApiUrl,
donationTrainApiUrl) — or the corresponding ENV_URLS row when using
a preset — to its own Extension URL disclosure.
playlive-overlay-data-layer@playlive/fundraiser-data is a drop-in replacement for the
playlive-overlay-data-layer/src/api/* layer. Function names,
parameter shapes, and return shapes are preserved verbatim — only:
getConfig() from playlive-overlay-data-layer/types/config → call
configure({ tiltifyProxyUrl, twitchServiceUrl, causeId }) once at
boot instead.setDemoProvider(demo) once. If you don't, demo slugs
return null / [] rather than the canned fixtures.AbortSignal for React
unmount cancellation.Config → campaign → children in parallel → pure projections. This is
the shape every Play Live overlay uses; the React flavour of the same
flow lives in @playlive/react-data.
import {
configure,
DEMO_CAMPAIGN_SLUG,
DEMO_USER_SLUG,
extractCampaignAmounts,
fetchMilestones,
fetchTiltifyCampaign,
fetchTiltifyFlattenedDonations,
getConfigForEnv,
getDonorLevel,
setDemoProvider,
} from "@playlive/fundraiser-data";
import type { TiltifyDonation, TiltifyMilestone } from "@playlive/tiltify-core";
import * as demo from "@playlive/realtime-pipeline/demo";
configure(
getConfigForEnv("prod", {
tiltifyProxyUrl: "https://tiltify-proxy.prod.experience.stjude.org",
}),
);
// Optional: makes the demo slugs below resolve without any network.
setDemoProvider(demo);
export interface OverlaySnapshot {
campaignName: string;
raised: number;
goal: number;
percent: number;
nextMilestone: TiltifyMilestone | undefined;
topDonors: Array<{ name: string; amount: string; level: string }>;
}
export async function loadOverlay(
teamUserSlug: string,
campaignSlug: string,
): Promise<OverlaySnapshot | null> {
let campaign: Awaited<ReturnType<typeof fetchTiltifyCampaign>>;
try {
// Throws "Campaign not found" when the slug pair resolves nothing;
// resolves `null` when the campaign belongs to another cause.
campaign = await fetchTiltifyCampaign({ teamUserSlug, slug: campaignSlug });
} catch (error) {
console.error("campaign lookup failed", error);
return null;
}
if (!campaign) return null;
// Milestones swallow their own errors → `[]`; donations are capped to
// one page so a long-running campaign doesn't walk 10k rows per tick.
const [milestones, donations] = await Promise.all([
fetchMilestones({ charityType: "tiltify", campaignId: campaign.id }),
fetchTiltifyFlattenedDonations({
campaignId: campaign.id,
count: 50,
maxPages: 1,
}),
]);
const { currentAmount, goalAmount } = extractCampaignAmounts(campaign);
const nextMilestone = milestones
.filter((m) => m.active)
.sort(
(a, b) => Number.parseFloat(a.amount.value) - Number.parseFloat(b.amount.value),
)
.find((m) => Number.parseFloat(m.amount.value) > currentAmount);
const topDonors = [...donations]
.sort(
(a: TiltifyDonation, b: TiltifyDonation) =>
Number.parseFloat(b.amount.value) - Number.parseFloat(a.amount.value),
)
.slice(0, 5)
.map((d) => ({
name: d.donor_name,
amount: d.amount.value,
level: getDonorLevel(d.amount.value), // "platinum" | … | "grey"
}));
return {
campaignName: campaign.name,
raised: currentAmount,
goal: goalAmount,
percent: goalAmount > 0 ? (currentAmount / goalAmount) * 100 : 0,
nextMilestone,
topDonors,
};
}
// Demo slugs resolve entirely from the injected fixtures.
await loadOverlay(DEMO_USER_SLUG, DEMO_CAMPAIGN_SLUG);
createDonationsFetcher is overloaded on the charityType string
literal, so the cursor type is narrowed for you: Tiltify hands back an
opaque metadata.after string, Twitch a numeric metadata.nextPage.
import { createDonationsFetcher } from "@playlive/fundraiser-data";
import type { TiltifyDonation } from "@playlive/tiltify-core";
const nextPage = createDonationsFetcher({
charityType: "tiltify",
campaignId: "6f4a1e2c-8b3d-4a11-9f77-2b0c5d9e1a44",
count: 100,
config: { completedAfter: "2026-02-01T00:00:00Z" },
});
const rows: TiltifyDonation[] = [];
let cursor: string | null | undefined = null;
do {
const page = await nextPage({ pageParam: cursor });
rows.push(...page.data);
cursor = page.metadata.after;
} while (cursor);
For a one-shot walk with a built-in page cap, prefer
fetchTiltifyFlattenedDonations({ campaignId, maxPages }).
import {
fetchTiltifyCurrentEvents,
selectCurrentFundraisingEvents,
} from "@playlive/fundraiser-data";
// Tiltify's cause endpoint returns years of history — filter to the
// in-flight Play Live season, newest first.
const events = selectCurrentFundraisingEvents(await fetchTiltifyCurrentEvents());
const active = events[0]; // e.g. { name: "PLAY LIVE 2026", … }
Useful for overlay QA: the core API replays the payload down the same webhook path a real donation takes, so alerts, donation trains, the subathon timer, and every WebSocket subscriber react.
import { postTiltifyTestDonations } from "@playlive/fundraiser-data";
await postTiltifyTestDonations({
donations: {
id: "00000000-0000-0000-0000-0000000000ff",
campaign_id: "00000000-0000-0000-0000-000000000000", // DEMO_CAMPAIGN_ID
cause_id: "400f5687-6017-4d1a-a4d9-7c9166b984c2", // DEFAULT_CAUSE_ID
amount: { value: "125.00", currency: "USD" },
donor_name: "QA Bot",
donor_comment: "smoke test",
completed_at: new Date().toISOString(),
donation_matches: null,
fundraising_event_id: null,
poll_id: null,
poll_option_id: null,
reward_claims: null,
reward_id: null,
sustained: null,
target_id: null,
team_event_id: null,
},
// Demo campaigns need no credential; real ones take one of:
// adminApiKey: process.env.PLAYLIVE_ADMIN_API_KEY,
// tiltifyOAuthToken: session.accessToken,
});
For the React flavour of these flows — the same fetchers behind hooks —
see @playlive/react-data (dependency-free) or
@playlive/react-query (TanStack Query).
MIT © St. Jude Children's Research Hospital