Automate Twitch charity donation alerts using GET /charity/campaigns to fetch the active campaign, GET /charity/donations for real-time donor data, and EventSub channel.charity.campaign.donate subscription for push notifications. All three require the channel:read:charity scope with a User Access Token from the broadcaster — an App Access Token will not work. The 800 points/minute Helix bucket is rarely an issue since charity endpoints are low-traffic.
API Quick Reference
User Access Token (broadcaster, channel:read:charity)
800 points/minute Helix bucket
JSON
Available
Understanding the Twitch Charity API
The Twitch Charity API is part of Helix (base URL: https://api.twitch.tv/helix) and provides two REST endpoints: GET /charity/campaigns (returns the broadcaster's current active campaign with goal amount and currency) and GET /charity/donations (returns paginated donor entries with user name, amount, and timestamp). Both require the channel:read:charity scope in a User Access Token from the broadcaster specifically — not a moderator, not an App Access Token.
For real-time donation alerts during a stream, subscribe to the channel.charity.campaign.donate EventSub event type. This fires immediately when a donation is recorded and includes the donor name, amount, and campaign totals. EventSub WebSocket transport works well here since charity events are low-frequency and you do not need a public URL. The session_id from the WebSocket welcome message is used to create the subscription via POST /eventsub/subscriptions.
Note that Twitch charity features are only available to Partners and Affiliates running an officially approved charity campaign through Twitch. The charity API cannot be used to process payments or run external donation flows — it reads Twitch's own charity system. Full reference: https://dev.twitch.tv/docs/api/reference/#get-charity-campaign
https://api.twitch.tv/helixSetting Up Twitch Charity API Authentication
The charity endpoints require a User Access Token from the broadcaster's account — not from a moderator or a separate bot account. The channel:read:charity scope must be explicitly requested during the Authorization Code OAuth flow. Refresh tokens for Confidential clients do not expire; store them securely and use them to refresh the access token before the ~60-day expiry.
- 1Register an app at dev.twitch.tv/console/apps if you have not already. Set Client Type to Confidential and add your redirect URI
- 2Build the authorization URL with the channel:read:charity scope: https://id.twitch.tv/oauth2/authorize?response_type=code&client_id=<CLIENT_ID>&redirect_uri=<REDIRECT_URI>&scope=channel:read:charity
- 3Have the broadcaster visit the URL and authorize the app — they will be redirected to your callback with a ?code= parameter
- 4Exchange the code for tokens: POST to https://id.twitch.tv/oauth2/token with grant_type=authorization_code
- 5Store the access_token and refresh_token in a secure database (encrypted)
- 6Also resolve the broadcaster's numeric user ID via GET /users?login=<username> and store it
- 7Validate the token hourly via GET https://id.twitch.tv/oauth2/validate and refresh on 401 responses
1import os2import requests34CLIENT_ID = os.environ['TWITCH_CLIENT_ID']5CLIENT_SECRET = os.environ['TWITCH_CLIENT_SECRET']6ACCESS_TOKEN = os.environ['BROADCASTER_ACCESS_TOKEN'] # channel:read:charity scope7BROADCASTER_ID = os.environ['BROADCASTER_ID']89def helix_headers():10 return {11 'Authorization': f'Bearer {ACCESS_TOKEN}',12 'Client-Id': CLIENT_ID13 }1415def validate_token():16 resp = requests.get(17 'https://id.twitch.tv/oauth2/validate',18 headers={'Authorization': f'OAuth {ACCESS_TOKEN}'}19 )20 if resp.status_code == 401:21 raise Exception('Token expired — refresh needed')22 return resp.json()2324def refresh_token(refresh_token_value: str) -> dict:25 resp = requests.post(26 'https://id.twitch.tv/oauth2/token',27 data={28 'grant_type': 'refresh_token',29 'refresh_token': refresh_token_value,30 'client_id': CLIENT_ID,31 'client_secret': CLIENT_SECRET32 }33 )34 resp.raise_for_status()35 return resp.json()3637# Validate on startup38token_info = validate_token()39print(f'Token valid for user: {token_info.get("login")}')Security notes
- •The broadcaster's User Access Token is sensitive — store it encrypted in a database, not in environment variables
- •Never share or log the full access token — it grants read access to the broadcaster's charity data
- •Validate the token hourly; users can revoke authorization at any time from their Twitch settings
- •Store CLIENT_ID and CLIENT_SECRET only in environment variables on your server
- •If the broadcaster revokes the app's authorization, all API calls return 401 — implement monitoring and re-authorization prompts
- •Use HTTPS for all OAuth redirect URIs — Twitch rejects HTTP redirect endpoints
Key endpoints
/charity/campaignsReturns the broadcaster's currently active charity campaign including name, description, fundraising goal, current total raised, and currency. Returns an empty data array if no active campaign exists.
| Parameter | Type | Required | Description |
|---|---|---|---|
broadcaster_id | string | required | The broadcaster's numeric user ID. The access token must be from this broadcaster's account. |
Response
1{"data": [{"id": "campaign123", "broadcaster_id": "12345678", "broadcaster_name": "Ninja", "charity_name": "St. Jude Children's Research Hospital", "charity_description": "Fighting childhood cancer", "charity_logo": "https://example.com/logo.png", "charity_website": "https://stjude.org", "current_amount": {"value": 150000, "decimal_places": 2, "currency": "USD"}, "target_amount": {"value": 500000, "decimal_places": 2, "currency": "USD"}}]}/charity/donationsReturns a paginated list of donations made to the active charity campaign. Each entry includes donor display name, amount, and currency. Use the cursor in response.pagination.cursor for pagination.
| Parameter | Type | Required | Description |
|---|---|---|---|
broadcaster_id | string | required | The broadcaster's numeric user ID. |
first | number | optional | Number of results per page. Maximum 100. |
after | string | optional | Pagination cursor from a previous response to get the next page. |
Response
1{"data": [{"campaign_id": "campaign123", "user_id": "98765432", "user_name": "viewer1", "user_login": "viewer1", "amount": {"value": 500, "decimal_places": 2, "currency": "USD"}}], "pagination": {"cursor": "eyJiIjpudWxsLCJhIjp7Ik9mZnNldCI6MX19"}}/eventsub/subscriptionsCreates an EventSub subscription for channel.charity.campaign.donate to receive real-time donation events. Use WebSocket transport with the session_id from the EventSub WebSocket welcome message.
| Parameter | Type | Required | Description |
|---|---|---|---|
type | string | required | Use 'channel.charity.campaign.donate' for individual donation events. |
condition.broadcaster_user_id | string | required | The broadcaster's user ID whose charity donations to subscribe to. |
Request
1{"type": "channel.charity.campaign.donate", "version": "1", "condition": {"broadcaster_user_id": "12345678"}, "transport": {"method": "websocket", "session_id": "<session_id>"}}Response
1{"data": [{"id": "sub123", "status": "enabled", "type": "channel.charity.campaign.donate", "version": "1"}]}Step-by-step automation
Fetch the Active Charity Campaign
Why: Before displaying donation alerts, you need the campaign ID, goal amount, and charity name to show context in your alerts and progress overlays.
Call GET /charity/campaigns?broadcaster_id=<id> with the broadcaster's User Access Token. Check that the data array is not empty — if it is, there is no active campaign. Extract the current_amount, target_amount, and charity_name for use in your alert formatting. The amount values are integers scaled by decimal_places (e.g., value=500, decimal_places=2 means $5.00).
1curl -X GET 'https://api.twitch.tv/helix/charity/campaigns?broadcaster_id=12345678' \2 -H 'Authorization: Bearer <broadcaster_user_access_token>' \3 -H 'Client-Id: <client_id>'Pro tip: The current_amount.value is an integer — divide by 10^decimal_places to get the human-readable amount. For USD, decimal_places=2, so value=500 means $5.00.
Expected result: Returns the active campaign object with charity name, goal, current total, and currency. Store the campaign_id for use in donation queries.
Subscribe to channel.charity.campaign.donate via EventSub
Why: Polling GET /charity/donations every few seconds is inefficient — EventSub push notifications fire immediately when a donation is recorded, giving sub-second alert latency.
Connect to the EventSub WebSocket at wss://eventsub.wss.twitch.tv/ws, capture the session_id from the welcome message, then create a channel.charity.campaign.donate subscription using POST /eventsub/subscriptions with the broadcaster's User Access Token. Each notification payload includes the donor's name, amount, and the updated campaign total.
1# After getting session_id from WebSocket welcome message:2curl -X POST 'https://api.twitch.tv/helix/eventsub/subscriptions' \3 -H 'Authorization: Bearer <broadcaster_user_access_token>' \4 -H 'Client-Id: <client_id>' \5 -H 'Content-Type: application/json' \6 --data '{7 "type": "channel.charity.campaign.donate",8 "version": "1",9 "condition": {"broadcaster_user_id": "12345678"},10 "transport": {11 "method": "websocket",12 "session_id": "<session_id_from_welcome>"13 }14 }'Pro tip: Subscribe to channel.charity.campaign.progress as well — this fires when the campaign total updates and lets you show a live progress bar without polling GET /charity/campaigns.
Expected result: Your app receives a notification for every charity donation in real time. The event payload includes user_name, amount (with decimal_places), currency, campaign_id, and the updated campaign total.
Format and Display the Donation Alert
Why: Donation alerts need to show the donor name, formatted amount, campaign progress, and a link for viewers to donate — all within 2-3 seconds of the donation being received.
When a channel.charity.campaign.donate notification arrives, extract user_name, amount.value, amount.decimal_places, and the campaign total from the event payload. Convert the amount to a human-readable currency string. Post the formatted alert to Discord via an embed or to Slack via chat.postMessage. For an OBS stream overlay, emit it via a WebSocket connection to your overlay frontend.
1# Post formatted donation alert to Discord webhook2curl -X POST 'https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN' \3 -H 'Content-Type: application/json' \4 --data '{5 "embeds": [{6 "title": ":heart: Charity Donation!",7 "description": "**viewer1** just donated **$5.00** to St. Jude!\n\nTotal raised: **$1,500.00 / $5,000.00**",8 "color": 16711680,9 "footer": {"text": "Donate at twitch.tv/ninja"}10 }]11 }'Pro tip: The channel.charity.campaign.donate event payload already includes the updated campaign total amounts (campaign_current_amount and campaign_target_amount) — you do not need to call GET /charity/campaigns separately after each donation.
Expected result: A formatted Discord embed or console message displays the donor name, donation amount, and running campaign total within seconds of the donation being made.
Complete working code
This complete script connects to EventSub WebSocket for real-time charity donation events, formats each donation with the donor name and amount, posts an alert to Discord, and logs all donations to a local JSON file for post-stream reporting.
1import asyncio2import json3import os4import time5import logging6import requests7import websockets89logging.basicConfig(level=logging.INFO)10logger = logging.getLogger(__name__)1112CLIENT_ID = os.environ['TWITCH_CLIENT_ID']13ACCESS_TOKEN = os.environ['BROADCASTER_ACCESS_TOKEN']14BROADCASTER_ID = os.environ['BROADCASTER_ID']15DISCORD_WEBHOOK_URL = os.environ.get('DISCORD_WEBHOOK_URL', '')16LOG_FILE = 'charity_donations.json'1718def hdrs(json_content=False):19 h = {'Authorization': f'Bearer {ACCESS_TOKEN}', 'Client-Id': CLIENT_ID}20 if json_content:21 h['Content-Type'] = 'application/json'22 return h2324def fmt_amount(value, decimal_places, currency):25 return f'{currency} {value / (10 ** decimal_places):,.2f}'2627def log_donation(donation):28 donations = []29 try:30 with open(LOG_FILE, 'r') as f:31 donations = json.load(f)32 except FileNotFoundError:33 pass34 donations.append({'timestamp': time.time(), **donation})35 with open(LOG_FILE, 'w') as f:36 json.dump(donations, f, indent=2)3738def get_campaign():39 resp = requests.get('https://api.twitch.tv/helix/charity/campaigns',40 headers=hdrs(), params={'broadcaster_id': BROADCASTER_ID})41 resp.raise_for_status()42 data = resp.json()['data']43 return data[0] if data else None4445async def on_donation(donation):46 donor = donation.get('user_name') or 'Anonymous'47 amount_str = fmt_amount(donation['amount']['value'],48 donation['amount']['decimal_places'],49 donation['amount']['currency'])50 current_str = fmt_amount(donation['campaign_current_amount']['value'],51 donation['campaign_current_amount']['decimal_places'],52 donation['campaign_current_amount']['currency'])53 target_str = fmt_amount(donation['campaign_target_amount']['value'],54 donation['campaign_target_amount']['decimal_places'],55 donation['campaign_target_amount']['currency'])56 charity = donation.get('charity_name', 'the charity')57 broadcaster = donation.get('broadcaster_user_name', '')58 desc = (f'**{donor}** donated **{amount_str}** to {charity}!\n'59 f'Total raised: **{current_str} / {target_str}**')60 logger.info(f'Donation: {desc}')61 log_donation(donation)62 if DISCORD_WEBHOOK_URL:63 requests.post(DISCORD_WEBHOOK_URL, json={'embeds': [{64 'title': ':heart: Charity Donation!',65 'description': desc,66 'color': 16711680,67 'footer': {'text': f'Donate at twitch.tv/{broadcaster.lower()}'}68 }]})6970async def run():71 campaign = get_campaign()72 if not campaign:73 logger.warning('No active charity campaign — waiting for one to start')74 else:75 logger.info(f'Campaign: {campaign["charity_name"]}')76 while True:77 try:78 async with websockets.connect('wss://eventsub.wss.twitch.tv/ws') as ws:79 welcome = json.loads(await ws.recv())80 session_id = welcome['payload']['session']['id']81 requests.post(82 'https://api.twitch.tv/helix/eventsub/subscriptions',83 headers=hdrs(True),84 json={'type': 'channel.charity.campaign.donate', 'version': '1',85 'condition': {'broadcaster_user_id': BROADCASTER_ID},86 'transport': {'method': 'websocket', 'session_id': session_id}}87 ).raise_for_status()88 logger.info('Listening for charity donations...')89 async for raw in ws:90 ev = json.loads(raw)91 if ev.get('metadata', {}).get('message_type') == 'notification':92 d = ev['payload']['event']93 await on_donation(d)94 except Exception as e:95 logger.error(f'Connection error: {e}. Reconnecting in 5s')96 await asyncio.sleep(5)9798asyncio.run(run())Error handling
{"status": 401, "message": "Invalid OAuth token"}The broadcaster's User Access Token has expired (~60 days) or they revoked the app's authorization from their Twitch settings.
Use the stored refresh token to get a new access token via POST https://id.twitch.tv/oauth2/token with grant_type=refresh_token. If refresh also returns 401, the broadcaster needs to re-authorize via the OAuth flow.
Refresh the token immediately on 401. If refresh fails, send an alert to the broadcaster to re-authorize.
{"status": 403, "message": "Missing scope channel:read:charity"}The access token does not include the channel:read:charity scope. This happens if the broadcaster authorized the app before channel:read:charity was added to the scope list.
Ask the broadcaster to re-authorize the app. Build a new authorization URL that includes channel:read:charity in the scope parameter and have them visit it.
Not retryable until the broadcaster re-authorizes with the correct scope.
{"status": 404, "message": "No active charity campaign found"}The broadcaster does not currently have an active Twitch charity campaign. GET /charity/campaigns returns an empty data array, not a 404, but EventSub subscriptions to channel.charity.campaign.donate may return 404 if there is no campaign.
Check for an empty data array before proceeding. Run GET /charity/campaigns before subscribing to EventSub to verify a campaign is active.
Poll GET /charity/campaigns every 5 minutes until a campaign becomes active.
{"status": 400, "message": "The broadcaster_user_id must be the same as the authenticated user"}Attempting to access charity endpoints for a different broadcaster than the one who authorized the token. The channel:read:charity scope is broadcaster-specific.
Ensure the broadcaster_id in your query parameters matches the user ID of the account that authorized the app. Use GET /users (with the same token) to verify the authenticated user ID.
Not retryable. Use the correct broadcaster's token and ID.
Rate Limits for Twitch Charity API
| Scope | Limit | Window |
|---|---|---|
| Helix REST (per user access token) | 800 points | per minute |
| EventSub WebSocket per client_id+user_id | max_total_cost 10 | across all WebSocket connections |
1import time2import requests34def helix_request_with_retry(method, url, headers, params=None, json=None, max_retries=3):5 for attempt in range(max_retries):6 resp = requests.request(method, url, headers=headers, params=params, json=json)7 if resp.status_code == 429:8 reset_ts = int(resp.headers.get('Ratelimit-Reset', time.time() + 30))9 wait = max(reset_ts - int(time.time()), 1)10 print(f'Rate limited. Waiting {wait}s')11 time.sleep(wait)12 continue13 if resp.status_code == 401:14 raise Exception('Token expired — refresh needed')15 return resp16 raise Exception('Max retries exceeded')- Charity endpoints are low-traffic — rate limits are rarely an issue, but still read Ratelimit-Remaining on every response to monitor your budget
- Use EventSub for real-time donation events instead of polling GET /charity/donations every few seconds — EventSub is push-based and consumes no Helix quota while idle
- The donation event payload includes the updated campaign totals — no need to call GET /charity/campaigns separately after each donation
Security checklist
- Store the broadcaster's User Access Token in an encrypted database — it grants read access to their charity campaign data and should be treated as a secret
- Store CLIENT_ID and CLIENT_SECRET only in server-side environment variables
- Validate the token hourly via GET https://id.twitch.tv/oauth2/validate; implement monitoring so you know immediately if the broadcaster revokes authorization
- Request only the channel:read:charity scope — do not request broader scopes than needed for this automation
- Use HTTPS for all OAuth redirect URIs — Twitch enforces this
- Log all donation alert deliveries for audit purposes in case of disputes or data discrepancies
Automation use cases
OBS Stream Overlay with Live Progress Bar
intermediateDisplay a browser source overlay in OBS showing the charity goal progress bar, most recent donor, and running total — all updating in real time via WebSocket-to-frontend event emission.
Post-Stream Charity Report
beginnerAfter the stream ends, aggregate all donation events from the log file, compute top donors, average donation size, and total raised, then post a formatted report to Discord.
Milestone Celebration Triggers
intermediateSubscribe to campaign progress events and trigger a special alert (sound effect, overlay animation, Discord @everyone) when the campaign crosses 25%, 50%, 75%, and 100% of the goal.
No-code alternatives
Don't want to write code? These platforms can automate the same workflows visually.
Zapier
Free tier (100 tasks/month); Starter from $19.99/monthZapier's Twitch integration can trigger on certain channel events, but as of 2026 it does not natively support charity donation events — you would need to use a Catch Hook to receive EventSub notifications forwarded from your server.
- + Easy to route charity alerts to 7,000+ apps once the trigger is set up
- + No code needed for the routing and formatting logic
- + Free tier for low-volume streams
- - No native Twitch charity donation trigger — requires a custom webhook receiver to forward EventSub events
- - Not truly no-code if you still need a server to handle EventSub verification
- - Limited formatting customization on free/starter plans
Make
Free tier (1,000 ops/month); Core from $9/monthMake can receive EventSub webhook notifications for charity donations via its HTTP webhook module and route them to Discord, Slack, Google Sheets for donation logging, or any other app.
- + 1,000 free operations/month
- + Can handle HTTP webhook verification manually with an HTTP module response
- + Google Sheets module for donation logging without a database
- - Webhook EventSub transport requires a public URL — Make provides this, but configuration is complex
- - Still requires webhook challenge verification logic in the HTTP module
- - Not suited for OBS overlay use cases
n8n
Self-hosted free; Cloud Starter from €20/monthn8n can receive EventSub webhooks and process charity donation events, route them to Discord or Slack, and log them to a database — all without writing code.
- + Native webhook node with custom response capability for EventSub challenge verification
- + Discord and Slack nodes for zero-code alert delivery
- + Self-hosted option with no per-execution cost
- - Webhook EventSub transport requires a public URL for the n8n instance
- - Setting up EventSub webhook challenge verification in n8n requires careful response configuration
- - WebSocket EventSub transport is not natively supported in n8n
Best practices
- Verify that a charity campaign is active via GET /charity/campaigns before subscribing to EventSub events — subscriptions to non-existent campaign events may fail silently
- Store donation events in a database or JSON file during the stream so you can generate a post-stream report even if your alert delivery fails mid-stream
- The donation event payload includes campaign_current_amount and campaign_target_amount — use these directly rather than making an extra API call to GET /charity/campaigns per donation
- Convert integer amount values to human-readable currency using the decimal_places field (e.g., value=500, decimal_places=2 means $5.00) before displaying them
- Implement WebSocket reconnection logic — if your process crashes during the stream, reconnect and resubscribe automatically
- Validate the broadcaster's User Access Token at startup and hourly — a revoked token will silently stop delivering donation events
Ask AI to help
Copy one of these prompts to get a personalized, working implementation.
I'm building a Twitch charity donation alert system using the Helix API. I have a broadcaster User Access Token with channel:read:charity scope. I'm subscribing to channel.charity.campaign.donate via EventSub WebSocket. Issue: [describe your problem — e.g., 'GET /charity/campaigns returns empty data array', 'donation amounts are displaying incorrectly', 'EventSub subscription returns 403']. My broadcaster_id: [ID]. Token scopes verified: channel:read:charity. What is wrong with my charity API setup?
Build a Twitch charity donation alert overlay for OBS Browser Source. The overlay should show a card at the bottom of the screen whenever a donation comes in, displaying the donor name, amount, and a brief thank-you message. The card should animate in from the right, stay for 5 seconds, then fade out. Also show a persistent progress bar at the bottom showing current raised / goal amount as a percentage. Connect to a local WebSocket at ws://localhost:8765 that emits JSON events with {type: 'donation', donor, amount, currency, current_total, target_amount} and {type: 'progress', current, target, percentage}. Style it to look like a Twitch-branded overlay (purple accent color #9147FF).
Frequently asked questions
Why do I get 401 when calling GET /charity/campaigns?
This endpoint requires a User Access Token from the broadcaster's account — not an App Access Token from Client Credentials. Make sure the broadcaster authorized your app with the channel:read:charity scope. If they did authorize but you're still getting 401, the token may have expired (~60 days) or been revoked. Use your stored refresh token to get a new access token.
Why does GET /charity/campaigns return an empty data array?
The broadcaster does not have an active Twitch charity campaign. Charity campaigns must be approved and started through Twitch's official charity program, available to Partners and Affiliates. An empty data array (not a 404) is the expected response when no campaign is running.
Can I use this API to accept donations outside of Twitch's charity system?
No. The Twitch charity API only reads data from Twitch's own charity campaign feature — it cannot process external donations, connect to third-party fundraising platforms, or access Streamlabs/StreamElements charity integrations. For custom donation systems, you would use those platforms' own APIs.
How do I convert the donation amount to a dollar amount?
Twitch returns amounts as integers scaled by decimal_places. Divide value by 10^decimal_places to get the human-readable amount. For example, value=500 with decimal_places=2 equals $5.00. Always use the decimal_places field from the response rather than assuming a fixed divisor — different currencies may have different decimal place conventions.
Can I subscribe to charity donation events for a channel I don't own?
No. The channel:read:charity scope must be granted by the broadcaster themselves — you cannot access another broadcaster's charity data with your own token. The broadcaster needs to authorize your app via the OAuth flow.
Is the Twitch charity API free?
Yes, the Twitch Helix API including charity endpoints is free with no per-request charges. Twitch's charity campaign feature itself requires Partner or Affiliate status and approval from Twitch. There are no API fees.
Can RapidDev help build a custom charity stream overlay and alert system?
Yes. RapidDev has built 600+ apps including stream overlay systems, real-time donation displays, and Twitch integrations. Book a free consultation at rapidevelopers.com.
Need this automated?
Our team has built 600+ apps with API automations. We can build this for you.
Book a free consultation