Hey Devs! 👋

Ever wanted to react instantly when you receive a donation on DonationAlerts? Maybe trigger an effect in your stream, update a dashboard, or just log donations reliably without constantly hitting an API? Polling APIs works, but it's inefficient and not real-time.

DonationAlerts offers a way to get these notifications instantly using WebSockets, but setting it up involves navigating the OAuth 2.0 flow and their specific WebSocket protocol (powered by Centrifugo).

In this guide, we'll walk through how to:

  1. Register an application on DonationAlerts.
  2. Implement the OAuth 2.0 Authorization Code flow using Python (with FastAPI).
  3. Connect to the DonationAlerts Centrifugo WebSocket.
  4. Authenticate, subscribe, and receive real-time donation messages.
  5. Understand the format of the donation data.

Let's ditch the polling and get those donations flowing in real-time!

Prerequisites

  • A DonationAlerts account (the streamer account you want to monitor).
  • Python 3.8+ installed.
  • Basic familiarity with Python. FastAPI knowledge is helpful but not strictly required to understand the concepts.
  • pip for installing packages.

(We'll use FastAPI for the web server handling OAuth, but the core logic for interacting with DonationAlerts API and WebSockets can be adapted to other frameworks.)

Step 1: Register Your App on DonationAlerts 🔑

Before we write any code, we need to tell DonationAlerts about our application and get some credentials.

  1. Go to the DonationAlerts OAuth Applications page: https://www.donationalerts.com/application/clients
  2. Log in using your streamer account (Twitch, YouTube, etc.).
  3. On the "OAuth API Applications" page, click the "+ CREATE NEW APP" button.
  4. Fill in the "New Application" form:
    • App name: Give it a descriptive name, like "My Realtime Monitor". (This is shown to users).
    • Redirect URL: This is CRUCIAL. Enter the exact URL where DonationAlerts will send the user back after they authorize your app. For local development with our example, use: http://localhost:8000/api/auth/donationalerts/callback. If you deploy, this needs to be your public callback URL.
  5. Click the orange "CREATE" button.
  6. Find your newly created app in the list. You'll need two pieces of information:
    • App ID: This is your unique application identifier. We'll call it APP_ID.
    • API Key: This is your application's secret. Keep this safe and never expose it in frontend code! We'll call it API_KEY.

Copy these two values (APP_ID and API_KEY). We'll need them soon.

Step 2: Understanding the OAuth 2.0 Flow 🌊

We need the user (the streamer) to grant our application permission to access their data (specifically, view their profile and subscribe to donation events) without giving us their password. That's where OAuth 2.0 comes in. We'll use the "Authorization Code" flow, which is standard for web applications.

Here's the gist:

  1. User Initiates: The user clicks a "Login with DonationAlerts" button in our app.
  2. Redirect to DonationAlerts: Our backend redirects the user's browser to a special DonationAlerts URL, including our APP_ID, the requested scopes (permissions), and the redirect_uri.
  3. User Authorizes: The user logs into DonationAlerts (if needed) and sees a screen asking them to approve the permissions our app requested (oauth-user-show, oauth-donation-subscribe).
  4. Redirect Back with Code: If approved, DonationAlerts redirects the user's browser back to our redirect_uri, adding a temporary code to the URL.
  5. Exchange Code for Token: Our backend receives this code, verifies it, and then securely makes a server-to-server request to DonationAlerts, sending the code, our APP_ID, and our secret API_KEY.
  6. Receive Tokens: DonationAlerts verifies everything and sends back an access_token (used to make API calls) and a refresh_token (used to get a new access_token when the old one expires).

This access_token is the key to interacting with the DonationAlerts API on the user's behalf.

Step 3: Implementing OAuth in Python (FastAPI Example) 🐍

Let's set up a simple FastAPI app to handle this flow.

(For a full runnable example including token storage, WebSocket management, and an interactive setup helper, check out the code in the donationalerts-oauth-websocket-example repository.)

Configuration (config.py / .env)

It's best practice to store credentials and settings outside your code. We can use a .env file and a helper config.py module (as shown in the full example) to load these. Your .env would look like this:

# .env
APP_ID="YOUR_APP_ID_HERE"
API_KEY="YOUR_API_KEY_HERE"
REDIRECT_URI="http://localhost:8000/api/auth/donationalerts/callback"
SESSION_SECRET_KEY="GENERATE_A_STRONG_RANDOM_KEY_HERE" # For session security

# Usually fixed
DA_SCOPES="oauth-user-show oauth-donation-subscribe"
DA_AUTHORIZATION_URL="https://www.donationalerts.com/oauth/authorize"
DA_TOKEN_URL="https://www.donationalerts.com/oauth/token"
DA_API_BASE_URL="https://www.donationalerts.com/api/v1"
# ... other URLs if needed

FastAPI Endpoints (main.py)

We need two main endpoints: one to start the login, and one to handle the callback.

# main.py (Simplified Snippets)
import httpx
import secrets
from fastapi import FastAPI, Request, Depends
from fastapi.responses import RedirectResponse
from starlette.middleware.sessions import SessionMiddleware # For storing state

# Assume 'settings' is loaded from config.py or .env
# settings = load_app_config() # From our full example's config.py

app = FastAPI()

# IMPORTANT: SessionMiddleware is needed to store the OAuth state temporarily
app.add_middleware(SessionMiddleware, secret_key=settings["SESSION_SECRET_KEY"])

# --- Login Endpoint ---
@app.get("/api/auth/donationalerts/login")
async def login_donationalerts(request: Request):
    state = secrets.token_urlsafe(16)
    request.session['oauth_state'] = state # Store state to prevent CSRF

    params = {
        "client_id": settings["APP_ID"],
        "redirect_uri": settings["REDIRECT_URI"],
        "response_type": "code",
        "scope": settings["DA_SCOPES"],
        "state": state,
    }
    auth_request = httpx.Request('GET', settings["DA_AUTHORIZATION_URL"], params=params)
    authorization_url = str(auth_request.url)

    print(f"Redirecting to: {authorization_url}") # For debugging
    return RedirectResponse(authorization_url)

# --- Callback Endpoint ---
@app.get("/api/auth/donationalerts/callback")
async def auth_donationalerts_callback(request: Request, code: str = None, state: str = None, error: str = None):
    if error:
        # Handle authorization error from DonationAlerts
        return {"error": error}

    # --- Security Check: Verify State ---
    stored_state = request.session.pop('oauth_state', None)
    if not state or state != stored_state:
        # State mismatch, potential CSRF attack!
        return {"error": "Invalid state"}

    if not code:
        return {"error": "Missing authorization code"}

    # --- Exchange Code for Tokens ---
    token_data = await exchange_code_for_token(code) # See function below

    if not token_data:
        return {"error": "Failed to exchange code for token"}

    # --- Success! Store Tokens Securely ---
    # In a real app: save token_data['access_token'], token_data['refresh_token'],
    # and calculate token_data['expires_at'] in a database linked to the user.
    # For this example, we might use a simple file storage (like token_storage.py).
    # save_token(token_data) # From our full example's token_storage.py

    print("OAuth successful, tokens obtained!")
    # Redirect user back to the main page of your app
    return RedirectResponse(url="/?status=success", status_code=303)

# --- Helper: Token Exchange ---
async def exchange_code_for_token(code: str):
    data = {
        "grant_type": "authorization_code",
        "client_id": settings["APP_ID"],
        "client_secret": settings["API_KEY"],
        "code": code,
        "redirect_uri": settings["REDIRECT_URI"],
    }
    async with httpx.AsyncClient() as client:
        try:
            response = await client.post(settings["DA_TOKEN_URL"], data=data)
            response.raise_for_status() # Check for HTTP errors
            print("Token exchange successful!")
            return response.json()
        except httpx.HTTPStatusError as e:
            print(f"Token exchange failed: {e.response.status_code} - {e.response.text}")
            return None
        except Exception as e:
            print(f"An error occurred during token exchange: {e}")
            return None

This handles the core OAuth flow. Remember to securely store the obtained access_token and refresh_token.

Step 4: Connecting to the WebSocket (Centrifugo) 🕸️

Now that we have an access_token, we can connect to the WebSocket for real-time events. This is where it gets a bit tricky, as it involves three different types of tokens:

  1. OAuth access_token: The one we just obtained. Needed for API calls.
  2. socket_connection_token: A temporary token specifically for authenticating the WebSocket connection itself. We get this from the /api/v1/user/oauth API endpoint using our access_token.
  3. subscription_token: Another temporary token needed to subscribe to a specific private channel (like the donations channel) after the WebSocket is authenticated. We get this from the /api/v1/centrifuge/subscribe endpoint using our access_token and a client_id provided by the WebSocket upon authentication.

The Connection Sequence:

Here's the sequence implemented in our example's donationalerts_client.py:

# donationalerts_client.py (Simplified Snippets)
import websockets
import json
import httpx

# Assume 'settings' is loaded, and we have a valid 'access_token'
# Also assume '_make_api_request' is a helper function using httpx and access_token

async def connect_and_listen():
    # --- Stage 1: Get User ID and Socket Token (HTTP API) ---
    try:
        user_info_response = await _make_api_request('GET', '/user/oauth', access_token)
        if not user_info_response or 'data' not in user_info_response:
             print("Failed to get user info")
             return
        user_id = user_info_response['data'].get('id')
        socket_token = user_info_response['data'].get('socket_connection_token')
        if not user_id or not socket_token:
            print("Missing user_id or socket_token in API response")
            return
    except Exception as e:
        print(f"Error getting user info/socket token: {e}")
        return

    print(f"Got User ID: {user_id}, Socket Token: {socket_token[:10]}...") # Don't log full token

    # --- Stage 2: Connect and Authenticate WebSocket ---
    websocket_url = settings["DA_CENTRIFUGO_URL"]
    try:
        async with websockets.connect(websocket_url) as ws:
            print(f"Connected to WebSocket: {websocket_url}")

            # --- Stage 2a: Send Socket Token ---
            # IMPORTANT: The format is specific! {"params": {"token": ...}, "id": 1}
            auth_payload = {"params": {"token": socket_token}, "id": 1}
            await ws.send(json.dumps(auth_payload))
            print("Sent WebSocket authentication request.")

            # --- Stage 2b: Receive Auth Response & Client ID ---
            auth_response_str = await ws.recv()
            auth_response = json.loads(auth_response_str)
            print(f"Received auth response: {auth_response}")

            client_id = None
            if auth_response.get("id") == 1 and auth_response.get("result", {}).get("client"):
                client_id = auth_response["result"]["client"]
                print(f"WebSocket authenticated! Client ID: {client_id}")
            else:
                print("WebSocket authentication failed!")
                return

            # --- Stage 3: Get Subscription Token (HTTP API) ---
            channel_name = f"$alerts:donation_{user_id}"
            sub_token_payload = {"client": client_id, "channels": [channel_name]}
            try:
                sub_response = await _make_api_request('POST', '/centrifuge/subscribe', access_token, json=sub_token_payload)
                if not sub_response or "channels" not in sub_response:
                     print("Failed to get subscription token response")
                     return

                subscription_token = None
                for channel_info in sub_response.get("channels", []):
                    if channel_info.get("channel") == channel_name and "token" in channel_info:
                        subscription_token = channel_info["token"]
                        break

                if not subscription_token:
                     print(f"Subscription token for {channel_name} not found in response.")
                     return

                print(f"Got Subscription Token: {subscription_token[:10]}...")
            except Exception as e:
                print(f"Error getting subscription token: {e}")
                return

            # --- Stage 4: Subscribe to Channel (WebSocket) ---
            subscribe_payload = {
                "id": 2, # Use a new ID for this request
                "method": 1, # 1 = subscribe
                "params": {"channel": channel_name, "token": subscription_token}
            }
            await ws.send(json.dumps(subscribe_payload))
            print(f"Sent subscribe request for channel {channel_name}")

            # --- Stage 5: Listen for Messages ---
            print("Waiting for donation messages...")
            async for message_str in ws:
                print(f"RAW MESSAGE RECEIVED: {message_str}")
                # Now parse the message (see next step)
                # handle_donation_message(message_str) 

    except websockets.exceptions.ConnectionClosedOK:
        print("WebSocket connection closed normally.")
    except Exception as e:
        print(f"WebSocket Error: {e}")

# --- Helper for API Requests (Example) ---
async def _make_api_request(method, endpoint, token, **kwargs):
     headers = {"Authorization": f"Bearer {token}"}
     url = f"{settings['DA_API_BASE_URL']}{endpoint}"
     async with httpx.AsyncClient() as client:
         response = await client.request(method, url, headers=headers, **kwargs)
         response.raise_for_status()
         return response.json()

Phew! That's the most complex part. Getting this sequence right is key.

Step 5: Handling Donation Messages 📨

Once subscribed, DonationAlerts will push messages through the WebSocket without an id field. The actual donation data is nested inside.

Message Format:

Based on observations, a typical donation message looks like this:

{
  "result": {
    "channel": "$alerts:donation_YOUR_USER_ID",
    "data": {
      "seq": 15, // Sequence number from Centrifugo
      "data": {
        // ---> THIS is the actual donation object <---
        "id": 164405432, // Unique Donation ID! Important for deduplication.
        "name": "DonatorName", // Name entered by donator (fallback)
        "username": "DonatorDA_Username", // DA username if logged in, else null
        "message": "Your message here! \r\n Can contain newlines.",
        "message_type": "text",
        "amount": 22.0,
        "currency": "USD",
        "is_shown": 1, // If shown in standard DA widgets
        "amount_in_user_currency": 1500.0,
        "recipient_name": "YourStreamerName",
        "recipient": { // Info about the recipient (you)
            "user_id": YOUR_USER_ID,
            "code": "YourStreamerLogin",
            "name": "YourStreamerName",
            "avatar": "URL_to_avatar"
         },
        "created_at": "2025-04-12 07:15:49" // Server time
        // ... potentially other fields ...
      }
    }
  }
}

Parsing in Python:

def handle_donation_message(message_str):
    try:
        message = json.loads(message_str)
        # Check if it's a push message (no 'id') and has the expected structure
        if "id" not in message and "result" in message:
            result_data = message.get("result", {})
            outer_data = result_data.get("data", {})
            donation_data = outer_data.get("data") # The actual donation payload

            if donation_data and result_data.get("channel", "").startswith("$alerts:donation_"):
                # Extract useful fields (use .get for safety)
                donation_id = donation_data.get("id")
                sender = donation_data.get("username") or donation_data.get("name", "Anonymous")
                amount = donation_data.get("amount")
                currency = donation_data.get("currency")
                text = donation_data.get("message", "")
                created_at = donation_data.get("created_at")

                print("-" * 20)
                print(f"🎉 New Donation! (ID: {donation_id})")
                print(f"   From: {sender}")
                print(f"   Amount: {amount} {currency}")
                print(f"   Message: {text.strip()}") # Strip whitespace/newlines
                print(f"   Time: {created_at}")
                print("-" * 20)

                # --- TODO: Send this data to your frontend or trigger actions! ---
                # Example: await websocket_manager.broadcast({"type": "donation", "data": donation_data})

            elif result_data.get("channel", "").startswith("$alerts:donation_"):
                 # Might be a subscribe confirmation or other message on the channel
                 print(f"Received non-donation push on channel: {message}")

        elif "id" in message:
             # This is likely an ack for one of our requests (id: 1 or 2) or a ping
             print(f"Received message with ID: {message}")
        else:
             print(f"Received unhandled message format: {message}")

    except json.JSONDecodeError:
        print(f"Error decoding JSON: {message_str}")
    except Exception as e:
        print(f"Error handling message: {e}")

# Inside the WebSocket listening loop:
# async for message_str in ws:
#     handle_donation_message(message_str)

Remember to handle potential missing fields gracefully using .get(). The id field is crucial if you want to prevent processing the same donation multiple times (DonationAlerts might occasionally send duplicates).

(Optional) Step 6: Displaying Donations 🖥️

The Python code above just prints the donation. To show it in a web interface, you'd typically use another WebSocket connection between your backend and your frontend.

  1. Backend (websocket_manager.py / main.py): When handle_donation_message processes a donation, instead of printing, use a WebSocketManager to broadcast the donation_data to all connected frontend clients.
  2. Frontend (index.html / JavaScript): Establish a WebSocket connection to your backend (/ws). Listen for messages. When a message of type: "donation" arrives, use JavaScript to parse the data and append it to the HTML.
// Simplified Frontend JS Example
let ws = new WebSocket(`ws://${window.location.host}/ws`); // Connect to your backend WS

ws.onmessage = function(event) {
    try {
        const message = JSON.parse(event.data);
        if (message.type === 'donation' && message.data) {
            // Function to add the donation to the page
            displayDonation(message.data); 
        }
    } catch (e) {
        console.error("Error processing message from backend:", e);
    }
};

function displayDonation(donation) {
    const log = document.getElementById('donations-log');
    const item = document.createElement('div');
    const sender = donation.username || donation.name || 'Anonymous';
    item.innerHTML = `${sender} donated ${donation.amount} ${donation.currency}: ${donation.message || ''}`;
    log.appendChild(item);
}

Conclusion ✨

We've covered the essential steps to receive real-time DonationAlerts notifications using OAuth 2.0 and WebSockets with Python. It involves setting up an app, handling the multi-step OAuth and WebSocket connection process (including those tricky specific tokens!), and parsing the incoming donation messages.

While the setup is more involved than simple polling or the older widget token method, it gives you a standard, robust way to integrate directly with the DonationAlerts event stream.