Day 10 - Adding a Full Tradier Integration and OAuth Flow to VannaCharm

Implementing a complete Tradier OAuth integration in VannaCharm to allow premium users to connect their Tradier accounts for real-time data streaming and trading.

Written by Chris on December 10th, 2025

This post is part of the 'The 12 Days of Full Stack Dev' series, an adventure through full stack fintech app development. Join me through the first 12 days of December as we develop a variety of new features, fixes, and components within the Full Stack Craft fintech family. For the full list of days and topics in this series, see the original post: The 12 Days of Full Stack Dev.

OAuth...

OAuth is almost always a pain, no matter what connection you are trying to create to your app. Combine that with the financial world's totally inconsistent specs, and you have a recipe for frustration. However, Tradier's OAuth implementation is actually pretty straightforward, and they have a good guide on how to implement it here: Tradier OAuth Guide. Still, it requires quite a few moving parts, which I'll document in this post.

Let's get started.

First: The Client

The user first needs away to sign in through Tradier, right? At VannaCharm, we have a single dedicated /tradier page which handles both the initial OAuth redirect to Tradier, as well as the exchange of the authorization code for an access token once the user is redirected back to our app.

That page, and respective hook, looks like this:

// /pages/tradier.tsx

import Head from "next/head";
import Link from "next/link";
import { useTradierLoginFlow } from "@/hooks/useTradierLoginFlow";
import { useUserTier } from "@/hooks/useUserTier";
import { Constants } from "@/constants/constants";

export default function Tradier() {
  const { tier: userTier, isLoading: isTierLoading } = useUserTier();
  const { currentState, isConnected, isLoading, error } = useTradierLoginFlow();

  // Check if user has premium access
  const hasPremiumAccess = userTier === 'premium-monthly' || userTier === 'premium-lifetime';

  // Build the Tradier OAuth URL
  const tradierOAuthUrl = `${Constants.TRADIER_OAUTH_URL}?client_id=${Constants.TRADIER_CLIENT_ID}&scope=stream,market&state=${currentState}`;

  // Loading state
  if (isTierLoading) {
    return (
      <>
        <Head>
          <title>Connect Tradier - VannaCharm</title>
        </Head>
        <div className="min-h-screen bg-white dark:bg-black text-black dark:text-white flex items-center justify-center">
          <div className="text-center">
            <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-lime-400 mx-auto mb-4"></div>
            <p className="text-gray-600 dark:text-gray-400">Loading...</p>
          </div>
        </div>
      </>
    );
  }

  return (
    <>
      <Head>
        <title>Connect Tradier - VannaCharm</title>
      </Head>
      <div className="min-h-screen bg-white dark:bg-black text-black dark:text-white">
        {/* Hero Section */}
        <section className="pt-32 pb-12">
          <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
            {/* Back to Dashboard Link */}
            <div className="mb-8">
              <Link 
                href="/dashboard" 
                className="text-gray-600 dark:text-gray-400 hover:text-black dark:hover:text-white transition-colors flex items-center gap-2"
              >
                <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
                </svg>
                Return to Dashboard
              </Link>
            </div>

            <div className="flex flex-col items-center justify-center gap-6">
              <h1 className="text-5xl md:text-6xl lg:text-7xl text-center font-black leading-tight">
                Tradier Login
              </h1>
              
              {hasPremiumAccess ? (
                <div className="text-center max-w-2xl">
                  <p className="text-xl text-gray-600 dark:text-gray-400 mb-2">
                    Connect your Tradier account for real-time data streaming.
                  </p>
                  <p className="text-xl text-gray-600 dark:text-gray-400">
                    You'll be redirected to Tradier's login. Once you've logged in, you'll be redirected back to our site.
                  </p>
                </div>
              ) : (
                <div className="text-center max-w-2xl">
                  <p className="text-xl text-gray-600 dark:text-gray-400 mb-2">
                    Connect your Tradier account for real-time data streaming.
                  </p>
                  <p className="text-xl text-gray-600 dark:text-gray-400">
                    <Link href="/pricing" className="text-lime-400 hover:text-lime-300 font-bold">
                      Upgrade to Premium
                    </Link>{" "}
                    to connect to Tradier.
                  </p>
                </div>
              )}
            </div>
          </div>
        </section>

        {/* Error Message */}
        {error && (
          <section className="pb-8">
            <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
              <div className="bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-700 rounded-lg p-4 text-center">
                <p className="text-red-700 dark:text-red-400">{error}</p>
              </div>
            </div>
          </section>
        )}

        {/* Tradier Card */}
        <section className="pb-20">
          <div className="max-w-md mx-auto px-4 sm:px-6 lg:px-8">
            <div className="border border-gray-200 dark:border-gray-700 rounded-lg p-6 flex flex-col">
              {/* Logo Container */}
              <div className="bg-white rounded-lg p-8 flex items-center justify-center h-32 mb-6">
                <img 
                  src="https://tradier.com/assets/images/tradier-logo.svg" 
                  alt="Tradier logo" 
                  className="h-12 w-auto object-contain"
                />
              </div>

              {/* Broker Name */}
              <h2 className="text-2xl font-bold mb-4 text-black dark:text-white">Tradier</h2>

              {/* Description */}
              <p className="text-gray-600 dark:text-gray-400 mb-6 flex-grow">
                Use your Tradier account to stream real-time data and trade options through our easy-to-use and intuitive UI.
              </p>

              {/* Action Button */}
              <div className="text-center">
                {!hasPremiumAccess && (
                  <Link 
                    href="/pricing" 
                    className="inline-block bg-lime-400 text-black px-8 py-3 rounded-lg font-bold hover:bg-lime-300 transition-colors"
                  >
                    Premium Required
                  </Link>
                )}
                
                {hasPremiumAccess && !isConnected && !isLoading && (
                  <a 
                    href={tradierOAuthUrl}
                    className="inline-block bg-lime-400 text-black px-8 py-3 rounded-lg font-bold hover:bg-lime-300 transition-colors"
                  >
                    Connect
                  </a>
                )}

                {hasPremiumAccess && isLoading && (
                  <button 
                    disabled 
                    className="inline-block bg-gray-400 text-white px-8 py-3 rounded-lg font-bold cursor-not-allowed"
                  >
                    <span className="flex items-center gap-2">
                      <svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
                        <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
                        <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
                      </svg>
                      Connecting...
                    </span>
                  </button>
                )}

                {hasPremiumAccess && isConnected && (
                  <button 
                    disabled 
                    className="inline-block bg-green-600 text-white px-8 py-3 rounded-lg font-bold cursor-not-allowed"
                  >
                    ✓ Connected
                  </button>
                )}
              </div>
            </div>
          </div>
        </section>
      </div>
    </>
  );
}
// /hooks/useTradierLoginFlow.ts

import { useEffect, useState } from 'react';
import { Constants } from '@/constants/constants';

/**
 * Generates a random string for OAuth state parameter
 */
const generateRandomString = (length: number = 32): string => {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  let result = '';
  const randomValues = new Uint8Array(length);
  crypto.getRandomValues(randomValues);
  for (let i = 0; i < length; i++) {
    result += chars[randomValues[i] % chars.length];
  }
  return result;
};

interface TradierLoginFlowResult {
  currentState: string;
  accessToken: string | null;
  isLoading: boolean;
  error: string | null;
  isConnected: boolean;
}

export const useTradierLoginFlow = (): TradierLoginFlowResult => {
  const [currentState, setCurrentState] = useState('');
  const [accessToken, setAccessToken] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const fetchTradierAccessToken = async (code: string) => {
    setIsLoading(true);
    setError(null);
    
    try {
      const res = await fetch('/.netlify/functions/tradierOauth', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ code }),
      });
      
      const data = await res.json();
      
      if (!res.ok || data.error) {
        setError('Failed to authenticate with Tradier');
        localStorage.removeItem(Constants.TRADIER_ACCESS_TOKEN);
        // Clear search params
        window.history.replaceState({}, document.title, window.location.pathname);
        return;
      }

      // Store access token
      if (data.access_token) {
        localStorage.setItem(Constants.TRADIER_ACCESS_TOKEN, data.access_token);
        setAccessToken(data.access_token);
        // Clear search params after successful auth
        window.history.replaceState({}, document.title, window.location.pathname);
      }
    } catch (err) {
      console.error('Error fetching Tradier access token:', err);
      setError('Unknown error logging in to Tradier');
      localStorage.removeItem(Constants.TRADIER_ACCESS_TOKEN);
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    // Check if we already have a token in localStorage
    const existingToken = localStorage.getItem(Constants.TRADIER_ACCESS_TOKEN);
    if (existingToken) {
      setAccessToken(existingToken);
    }

    // Get URL parameters
    const urlParams = new URLSearchParams(window.location.search);
    const code = urlParams.get('code');
    const state = urlParams.get('state');
    const storedState = localStorage.getItem(Constants.TRADIER_OAUTH_STATE);

    console.log('Tradier login flow - code:', code, 'state:', state);
    console.log('Expected state:', storedState);

    // If code is present but state doesn't match, show error
    if (code && state !== storedState) {
      setError('Error logging in to Tradier. State mismatch. Please try again.');
      localStorage.removeItem(Constants.TRADIER_OAUTH_STATE);
      localStorage.removeItem(Constants.TRADIER_ACCESS_TOKEN);
      return;
    }

    // If we have a valid code, exchange it for an access token
    if (code && state === storedState) {
      fetchTradierAccessToken(code);
      return;
    }

    // If not returning from broker, generate new state for next login attempt
    if (!existingToken) {
      localStorage.removeItem(Constants.TRADIER_OAUTH_STATE);
      const newState = generateRandomString();
      localStorage.setItem(Constants.TRADIER_OAUTH_STATE, newState);
      setCurrentState(newState);
    }
  }, []);

  return {
    currentState,
    accessToken,
    isLoading,
    error,
    isConnected: !!accessToken,
  };
};

Second: The Serverless Function

I've opted with VannaCharm to go with Go based Netlify Functions for all serverless work, so our Tradier OAuth exchange function is written in Go as well. This function takes the authorization code that the client received from Tradier, and exchanges it for an access token, which we then store in our Supabase database for future use. Here's that function:

// /netlify/functions/tradierOauth/main.go

package main

import (
	"encoding/base64"
	"encoding/json"
	"log"
	"net/http"
	"net/url"
	"os"
	"strings"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
)

// CORS headers for cross-origin requests
var corsHeaders = map[string]string{
	"Access-Control-Allow-Origin":  "*",
	"Access-Control-Allow-Headers": "Content-Type",
	"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
}

// OAuthRequest is the expected payload from the client
type OAuthRequest struct {
	Code string `json:"code"`
}

// TokenResponse is the response returned to the client
type TokenResponse struct {
	Status      string `json:"status,omitempty"`
	AccessToken string `json:"access_token,omitempty"`
	ExpiresIn   int64  `json:"expires_in,omitempty"`
	IssuedAt    string `json:"issued_at,omitempty"`
}

// TradierOAuthResponse is the response from Tradier's OAuth endpoint
type TradierOAuthResponse struct {
	Status      string `json:"status"`
	AccessToken string `json:"access_token"`
	ExpiresIn   int64  `json:"expires_in"`
	IssuedAt    string `json:"issued_at"`
}

func handler(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	// Handle preflight OPTIONS request
	if req.HTTPMethod == "OPTIONS" {
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusOK,
			Body:       "OK",
			Headers:    corsHeaders,
		}, nil
	}

	// Check for empty body
	if req.Body == "" {
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusBadRequest,
			Body:       "event.body is null",
			Headers:    corsHeaders,
		}, nil
	}

	// Parse the request body
	var oauthReq OAuthRequest
	if err := json.Unmarshal([]byte(req.Body), &oauthReq); err != nil {
		log.Printf("Error parsing JSON: %v", err)
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusBadRequest,
			Body:       "Bad Request",
			Headers:    corsHeaders,
		}, nil
	}

	// Retrieve Tradier credentials from environment variables
	clientId := os.Getenv("TRADIER_CLIENT_ID")
	clientSecret := os.Getenv("TRADIER_CLIENT_SECRET")

	if clientId == "" || clientSecret == "" {
		log.Println("TRADIER_CLIENT_ID or TRADIER_CLIENT_SECRET not set")
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Body:       "Server Error",
			Headers:    corsHeaders,
		}, nil
	}

	// Base64 encode the credentials
	credentials := clientId + ":" + clientSecret
	base64Credentials := base64.StdEncoding.EncodeToString([]byte(credentials))

	// Build the form data
	formData := url.Values{}
	formData.Set("grant_type", "authorization_code")
	formData.Set("code", oauthReq.Code)

	// Make the request to Tradier OAuth endpoint
	httpReq, err := http.NewRequest("POST", "https://api.tradier.com/v1/oauth/accesstoken", strings.NewReader(formData.Encode()))
	if err != nil {
		log.Printf("Error creating request: %v", err)
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Body:       "Server Error",
			Headers:    corsHeaders,
		}, nil
	}

	httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	httpReq.Header.Set("Authorization", "Basic "+base64Credentials)

	client := &http.Client{}
	resp, err := client.Do(httpReq)
	if err != nil {
		log.Printf("Error making OAuth request: %v", err)
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Body:       "Server Error",
			Headers:    corsHeaders,
		}, nil
	}
	defer resp.Body.Close()

	log.Printf("Status: %d", resp.StatusCode)
	log.Printf("Headers: %v", resp.Header)

	// Parse the response from Tradier
	var tradierResp TradierOAuthResponse
	if err := json.NewDecoder(resp.Body).Decode(&tradierResp); err != nil {
		log.Printf("Error parsing Tradier response: %v", err)
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Body:       "Server Error",
			Headers:    corsHeaders,
		}, nil
	}

	log.Printf("Response data: %+v", tradierResp)

	// Build the token response
	tokenData := TokenResponse{
		Status:      tradierResp.Status,
		AccessToken: tradierResp.AccessToken,
		ExpiresIn:   tradierResp.ExpiresIn,
		IssuedAt:    tradierResp.IssuedAt,
	}

	responseBody, err := json.Marshal(tokenData)
	if err != nil {
		log.Printf("Error marshalling response: %v", err)
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Body:       "Server Error",
			Headers:    corsHeaders,
		}, nil
	}

	return events.APIGatewayProxyResponse{
		StatusCode: http.StatusOK,
		Body:       string(responseBody),
		Headers:    corsHeaders,
	}, nil
}

func main() {
	lambda.Start(handler)
}

Third: Connection and Usage with floe

Once we have an auth token returned by our go serverless function, we can use the auth token floe, Full Stack Craft's premier front end client for easily accessing real-time broker data and calculating things like dealer exposures, black scholes pricing, and more. More detailed examples can be viewed on our documentation site, but in general, it looks like this:

const client = new FloeClient();

// Set up event listeners
client
    .on('connected', () => setIsConnected(true))
    .on('disconnected', () => setIsConnected(false))
    .on('error', (err) => setError(err))
    .on('tickerUpdate', (ticker) => {
        setTickerData((prev) => new Map(prev).set(ticker.symbol, ticker));
    })
    .on('optionUpdate', (option) => {
    
// Use a composite key for options: symbol + strike + expiry + type
const key = `${option.expiration}-${option.strike}-${option.optionType}`;
    setOptionData((prev) => new Map(prev).set(key, option));
});

client.connect(Brokers.TRADIER, authToken);
// event listeners will update the requested tickers and options as they change.

And that's about it - VannaCharm customers can now connect their Tradier accounts for real-time data streaming for dealer exposure calculations.

More posts:

footer-frame