← all packages

@playlive/fundraiser-data

v0.5.4

npm install @playlive/fundraiser-data

Requires @playlive:registry=https://packages.playlive.experience.stjude.org in your .npmrc. packument · tarball · API docs


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.

Coverage

Install

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.

Quick start

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/environments to 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 exports

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.

Per-env presets

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

API reference

Full generated API documentation: https://packages.playlive.experience.stjude.org/p/@playlive/fundraiser-data/docs/

Configuration (./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.

Fetchers

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 (./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 | stringDonorLevel. 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.

Demo + package metadata

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

Demo mode

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.

Upstream spec

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().

Twitch Extension URL disclosure

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.

Migration from 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:

Examples

Boot an overlay data layer end-to-end

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);

Walk the donation cursor manually

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 }).

Pick this season's fundraising event

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", … }

Drive a synthetic donation through the live pipeline

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

License

MIT © St. Jude Children's Research Hospital