npm install @playlive/twitch-charity
Twitch Charity REST proxy client + Twitch ↔ Tiltify converters. Zero framework
dependencies. Native fetch only. Browser / Bun / Node / Lambda safe.
This package wraps the Play Live REST proxy that fronts Twitch's Helix
/helix/charity/* endpoints (/campaigns/twitch/{id}, /donations/twitch/{id}),
and ships pure converters that translate Twitch's charity wire shapes into the
canonical Tiltify v5 shapes the rest of the overlay UI is built around.
bun add @playlive/twitch-charity
No peer dependencies. Two dependencies are pulled in automatically —
@playlive/twitch-shared (the RawTwitchCharity type) and
@playlive/tiltify-core (the target shapes for the
converters). No other external runtime deps.
The client is stateless: every call takes the proxy baseUrl, so there is
nothing to construct and nothing to configure globally.
import {
fetchTwitchCampaign,
fetchTwitchCampaignDonations,
convertTwitchToTiltifyCampaign,
convertTwitchToTiltifyDonation,
} from "@playlive/twitch-charity";
const baseUrl = "https://twitch-charity-proxy.experience.stjude.org";
const campaign = await fetchTwitchCampaign({ baseUrl, campaignId: "tw-c-1" });
const tiltifyCampaign = convertTwitchToTiltifyCampaign(campaign, {
causeId: "400f5687-6017-4d1a-a4d9-7c9166b984c2", // St. Jude
});
const { data, metadata } = await fetchTwitchCampaignDonations({
baseUrl,
campaignId: "tw-c-1",
count: 50,
pageNumber: 0,
});
const tiltifyDonations = data.map((d) => convertTwitchToTiltifyDonation(d));
console.log(tiltifyCampaign?.amount_raised); // { value: "2847.5", currency: "USD" }
console.log(metadata.nextPage); // 1 — or null when the last page was served
Both converters are null-safe (null in → null out) and therefore return a
nullable type; narrow before use.
| Subpath | Description |
|---|---|
@playlive/twitch-charity |
Default barrel — re-exports everything below. |
@playlive/twitch-charity/client |
fetchTwitchCampaign, fetchTwitchCampaignDonations + options. |
@playlive/twitch-charity/convert |
Pure converters Twitch → Tiltify shapes. |
@playlive/twitch-charity/errors |
TwitchCharityApiError class for type-narrowing failed requests. |
@playlive/twitch-charity/types |
Wire shapes (camelCase proxy + RawTwitchCharity re-export). |
Full generated API documentation: https://packages.playlive.experience.stjude.org/p/@playlive/twitch-charity/docs/
| Export | Kind | Source | Signature |
|---|---|---|---|
fetchTwitchCampaign |
function | ./client |
(opts: FetchTwitchCampaignOptions) => Promise<TwitchCampaign> |
fetchTwitchCampaignDonations |
function | ./client |
(opts: FetchTwitchCampaignDonationsOptions) => Promise<PaginatedResponse<TwitchDonation>> |
convertTwitchToTiltifyCampaign |
function | ./convert |
(campaign: TwitchCampaign | null, ctx?: TwitchToTiltifyContext) => TiltifyCampaign | null |
convertTwitchToTiltifyDonation |
function | ./convert |
(donation: TwitchDonation | null) => TiltifyDonation | null |
Both fetchers issue a single GET and throw TwitchCharityApiError on any
non-2xx response. Both converters are pure — no fetch, no globals, no I/O.
| Export | Kind | Source | Notes |
|---|---|---|---|
TwitchCharityApiError |
class | ./errors |
new (status: number, statusText: string, message?: string). Carries .status, .statusText, .name === "TwitchCharityApiError". |
Omitting message yields Twitch Charity API error: <status> <statusText>.
| Export | Kind | Source | Notes |
|---|---|---|---|
TwitchCharityClientOptions |
interface | ./client |
Shared base: baseUrl, optional fetch override, optional signal. |
FetchTwitchCampaignOptions |
interface | ./client |
Adds campaignId. |
FetchTwitchCampaignDonationsOptions |
interface | ./client |
Adds campaignId, count (default 100), pageNumber (default 0), completedAfter, completedBefore. |
TwitchToTiltifyContext |
interface | ./convert |
{ causeId?: string } — stamped onto the generated TiltifyCampaign.cause_id. |
TwitchCampaign |
interface | ./types |
camelCase proxy campaign (broadcaster, charity, currentAmount, targetAmount). |
TwitchDonation |
interface | ./types |
camelCase proxy donation (amount, campaignID, donatedAt, user). |
TwitchPaginationMetadata |
interface | ./types |
{ nextPage: number | null } — null on the last page. |
PaginatedResponse<T, M> |
interface | ./types |
Generic { data: T[]; metadata: M } envelope; M defaults to TwitchPaginationMetadata. |
RawTwitchCharity |
interface | ./types |
Re-exported from @playlive/twitch-shared — the snake_case Helix shape for callers hitting Twitch directly. |
| Export | Kind | Source | Notes |
|---|---|---|---|
PACKAGE_NAME |
const | ./ |
"@playlive/twitch-charity", for version-pinning. |
KNOWN_URLS |
const | ./ |
Frozen empty array — see the disclosure section. |
Amount conventions: proxy amounts are integer minor units plus a
decimalPlaces divisor. convertTwitchToTiltifyCampaign divides and calls
.toString() (284_750 / 10 ** 2 → "2847.5");
convertTwitchToTiltifyDonation formats via Intl.NumberFormat("en-US")
(123_456_789 → "1,234,567.89").
The wire shapes this package exposes track the Twitch Helix Charity API, whose public reference is the authority:
GET /helix/charity/campaigns — https://dev.twitch.tv/docs/api/reference/#get-charity-campaignGET /helix/charity/donations — https://dev.twitch.tv/docs/api/reference/#get-charity-campaign-donationsThose two Helix operations are the only ones covered. The Play Live REST proxy
this client calls re-serves them at /campaigns/twitch/{id} and
/donations/twitch/{id} with camelCase field names and a
{ data, metadata } pagination envelope; RawTwitchCharity (re-exported from
@playlive/twitch-shared) is the untouched snake_case Helix
shape for callers going direct to Twitch.
Twitch's charity endpoints have no versioned schema beyond Helix itself — when Twitch adds a field, it surfaces here as an additive, non-breaking change.
The KNOWN_URLS export is empty because the base URL of the proxy is supplied
by the consumer at call time (it differs per environment — see
@playlive/fundraiser-data and
@playlive/realtime-pipeline for the canonical
disclosure lists).
import { KNOWN_URLS } from "@playlive/twitch-charity";
console.log(KNOWN_URLS); // []
If you embed this package inside a Twitch Extension, add the proxy base URL you actually call to the Extension's manifest URL allowlist yourself — the proxy host is environment-specific and not hard-coded.
The pattern below is what the overlay data layer actually does: walk every page
via metadata.nextPage, retry transient 5xx failures, surface 4xx to the
caller, and convert the result into the Tiltify shapes the UI renders. Note
that fetch and signal are injectable on every call — that is the extension
point for retries, tracing, or cancellation.
import {
convertTwitchToTiltifyCampaign,
convertTwitchToTiltifyDonation,
fetchTwitchCampaign,
fetchTwitchCampaignDonations,
TwitchCharityApiError,
type TwitchDonation,
} from "@playlive/twitch-charity";
import type { TiltifyCampaign, TiltifyDonation } from "@playlive/tiltify-core/types";
const BASE_URL = "https://twitch-charity-proxy.experience.stjude.org";
const SJ_CAUSE_ID = "400f5687-6017-4d1a-a4d9-7c9166b984c2";
async function withRetry<T>(op: () => Promise<T>, attempts = 3): Promise<T> {
for (let i = 0; ; i++) {
try {
return await op();
} catch (err) {
// Only 5xx is worth retrying — 404 means the campaign is gone and
// 401/403 means the proxy rejected us, neither improves on retry.
const retryable = err instanceof TwitchCharityApiError && err.status >= 500;
if (!retryable || i >= attempts - 1) throw err;
await new Promise((r) => setTimeout(r, 250 * 2 ** i));
}
}
}
export async function loadTwitchCampaign(
campaignId: string,
signal: AbortSignal,
): Promise<{ campaign: TiltifyCampaign; donations: TiltifyDonation[] } | null> {
try {
const twitchCampaign = await withRetry(() =>
fetchTwitchCampaign({ baseUrl: BASE_URL, campaignId, signal }),
);
const raw: TwitchDonation[] = [];
let pageNumber: number | null = 0;
while (pageNumber !== null) {
const page = await withRetry(() =>
fetchTwitchCampaignDonations({
baseUrl: BASE_URL,
campaignId,
count: 100,
pageNumber,
signal,
}),
);
raw.push(...page.data);
pageNumber = page.metadata.nextPage;
}
const campaign = convertTwitchToTiltifyCampaign(twitchCampaign, {
causeId: SJ_CAUSE_ID,
});
if (!campaign) return null;
const donations = raw
.map((d) => convertTwitchToTiltifyDonation(d))
.filter((d): d is TiltifyDonation => d !== null);
return { campaign, donations };
} catch (err) {
if (err instanceof TwitchCharityApiError) {
// `.status` / `.statusText` are the branch points; `.message`
// defaults to `Twitch Charity API error: <status> <statusText>`.
if (err.status === 404) return null;
console.error(`proxy rejected the request: ${err.status} ${err.statusText}`);
throw err;
}
// AbortError, DNS failure, malformed JSON — not a proxy-level error.
throw err;
}
}
const controller = new AbortController();
setTimeout(() => controller.abort(), 10_000);
const result = await loadTwitchCampaign("tw-c-1", controller.signal);
console.log(result?.campaign.name); // "Playliver's Campaign for St. Jude"
completedAfter / completedBefore are ISO-8601 strings forwarded as the
completed_after / completed_before query params:
const today = await fetchTwitchCampaignDonations({
baseUrl: BASE_URL,
campaignId: "tw-c-1",
completedAfter: "2025-01-01T00:00:00Z",
completedBefore: "2025-01-02T00:00:00Z",
count: 25,
});
Every fetcher accepts a fetch override, so tests need no interceptor:
import { fetchTwitchCampaign } from "@playlive/twitch-charity/client";
const stub = async () =>
new Response(
JSON.stringify({
id: "tw-c-1",
broadcaster: { id: "b-1", login: "playliver", name: "Playliver" },
charity: { logo: "", name: "St. Jude", website: "https://stjude.org" },
currentAmount: { value: 284_750, currency: "USD", decimalPlaces: 2 },
targetAmount: { value: 500_000, currency: "USD", decimalPlaces: 2 },
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
const campaign = await fetchTwitchCampaign({
baseUrl: "https://proxy.test",
campaignId: "tw-c-1",
fetch: stub as typeof fetch,
});
The same override is the seam for mounting the two proxy routes on any local
stub server: pass its fetch and no traffic leaves the process.
MIT © St. Jude Children's Research Hospital