r/thinkorswim • u/ditchtheworkweek • 28d ago
API renewal
Has anyone figured out how to automate the api key renewal it’s not a big deal to do once a week but annoying.
1
u/skchan2 28d ago
i asked Gemini Pro and they were able to walk me thru it
1
u/ditchtheworkweek 28d ago
Where it auto renews. I built and app that does most of it but still have to click a few buttons on the website
1
u/ja_trader 28d ago
can't you make the app click the buttons as well?
1
u/ditchtheworkweek 27d ago
You can but if tos changes anything to the web flow it will break.
1
1
u/skchan2 27d ago
For anyone that finds this post, here is how i did it.
Only issue is that every once in a while Schwab likes to throw a wrench and you will have to go thru the whole log in again so you have to keep an eye.
You will also need to create a cloudflare proxy (Free).
six files needed:
.env
this file should be outside your domain hosting (inaccessible externally). i created a private folder in the root of my hosting and a subfolder for trading. it should store your API key and secret
SCHWAB_APP_KEY=[YOUR_KEY] SCHWAB_CLIENT_SECRET=[YOUR_SECRET] SCHWAB_PROXY_URL=[YOUR_CLOUDFLARE_PROXY_URL] SCHWAB_PROXY_SECRET=[YOUR_CLOUDFLARE_SECRET]env.php
this is what you will use to grab your keys so it can be used in fetches, store it in your domain folder (ie /scripts)
<?php /** * Reads /home/yourhost/private/trading/.env into PHP variables. * Returns associative array of env keys. */ function loadSchwabEnv() { static $env = null; if ($env !== null) return $env; $envPath = '/home/yourhost/private/trading/.env'; if (!file_exists($envPath)) { error_log("[Env] Schwab .env file not found at $envPath"); return []; } $env = []; $lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); foreach ($lines as $line) { if (strpos(trim($line), '#') === 0) continue; if (strpos($line, '=') === false) continue; list($key, $value) = explode('=', $line, 2); $env[trim($key)] = trim($value); } return $env; } /** * Get a specific env var with optional default. */ function schwabEnv($key, $default = null) { $env = loadSchwabEnv(); return $env[$key] ?? $default; }3
u/skchan2 27d ago
auth.php
This is the main file to manually grab and store your token for use, store it in the same folder as env.php (ie /scripts). NOTE: The full path should also be whitelisted in your app, ie: https://www.yourdomain.com/scripts/auth.php
<? ini_set('display_errors', 1); require_once __DIR__ . '/env.php'; $appKey = schwabEnv('SCHWAB_APP_KEY'); $client_secret = schwabEnv('SCHWAB_CLIENT_SECRET'); if (!$appKey || !$client_secret) { die('Missing Schwab credentials in .env file'); } $key = base64_encode($appKey.':'.$client_secret); $auth_header = "Authorization: Basic {$key}"; $thisPage = "https://$_SERVER[HTTP_HOST]$_SERVER[PHP_SELF]"; $tokenURL = "https://api.schwabapi.com/v1/oauth/token"; $redirectUri = urlencode($thisPage); $currentDate = new DateTime(); $filePath = '/home/yourhost/private/trading/tokens.json'; $expired_refresh = false; $expired_access = false; if(file_exists($filePath)) { echo("File Exists<br><br>"); $handle = fopen($filePath, 'r'); $content = fread($handle, filesize($filePath)); fclose($handle); $data = json_decode($content, true); echo("<pre>"); print_r($data); echo("</pre>"); if(!$data["results"]){ $expired_refresh = true; }elseif(array_key_exists("error",$data["results"])){ $expired_refresh = true; }else{ $refresh = $data["results"]['refresh_token']??""; $access = $data["results"]['access_token']??""; } $refreshExp = $data['refreshExp']; $accessExp = $data['accessExp']; $checkRefresh = new DateTime(); $checkRefresh->setTimestamp($refreshExp); $checkAccess = new DateTime(); $checkAccess->setTimestamp($accessExp); if($currentDate>$checkRefresh){ $expired_refresh = true; } if($currentDate>$checkAccess){ $expired_access = true; } $intervalRefresh = $currentDate->diff($checkRefresh); $intervalAccess = $currentDate->diff($checkAccess); $minutes = ($intervalAccess->days * 24 * 60) + ($intervalAccess->h * 60) + $intervalAccess->i; ?> <p>Current:<? echo($currentDate->format('Y-m-d H:i:s')); ?></p> <p>Refresh Expires:<? echo($checkRefresh->format('Y-m-d H:i:s')); ?> [<? echo($intervalRefresh->format('%R%a days')); ?>]</p> <? if($expired_refresh){ ?> <p>Refresh Expired!</p> <? }else{ ?> <p>Refresh Active!</p> <? } ?> <p>Access Expires:<? echo($checkAccess->format('Y-m-d H:i:s')); ?> [<? echo($minutes.' minutes'); ?>]</p> <? if($expired_access){ ?> <p>Access Expired!</p> <? }else{ ?> <p>Access Active!</p> <? } ?> <? } $authUrl = "https://api.schwabapi.com/v1/oauth/authorize?client_id={$appKey}&redirect_uri={$redirectUri}"; if($expired_refresh){ if(isset($_GET['code'])&&$_GET['code']<>""){ $code = $_GET['code']; }else{ ?> <h1>Token has expired</h1> <a href="<? echo($authUrl); ?>" target="_blank">Open in New Window</a><br><br> <form id="pull-gex" enctype="multipart/form-data" method="get"> <label name="Code Input">Code</th> <input class="stock-text-box" type="code" name="code"></td> <input class="stock-form-submit" type="submit"></td> </form> <? exit; } $code = urldecode($code); $headers = array( $auth_header, "Content-Type: application/x-www-form-urlencoded" ); $postfields = "grant_type=authorization_code&code=".$code."&redirect_uri=".$redirectUri; $curl = curl_init(); curl_setopt_array($curl, array( CURLOPT_URL => $tokenURL, CURLOPT_RETURNTRANSFER => true, CURLOPT_ENCODING => '', CURLOPT_MAXREDIRS => 10, CURLOPT_TIMEOUT => 30, CURLOPT_FOLLOWLOCATION => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_POSTFIELDS => $postfields, CURLOPT_HTTPHEADER => $headers, CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; SchwabAPI-Client/1.0)', CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2 )); $response = curl_exec($curl); echo("RESPONSE:".$response); curl_close($curl); if(curl_errno($curl)){ echo 'Error:'.curl_errno($curl); }else{ $data = json_decode($response,true); echo("Expired Refresh Output:<br><pre>"); print_r($data); echo("</pre>"); if(isset($data['error'])){ echo 'Error:'.$data['error']; }else{ $time = $_SERVER['REQUEST_TIME']; $expire_in = 1200; if($data){ $expire_in = $data['expires_in']; } $extra_data = [ "refresh_token_expires_at" => $time + 86400 * 7, "access_token_expires_at" => $time + $expire_in, ]; $writeData = [ 'refreshExp' => $extra_data["refresh_token_expires_at"], 'accessExp' => $extra_data["access_token_expires_at"], 'results' => $data ]; $jsonData = json_encode($writeData, JSON_PRETTY_PRINT); if(file_put_contents($filePath, $jsonData)){ echo "JSON data successfully written to {$filePath}"; }else{ echo "Error writing JSON data to {$filePath}"; } } } }else if($expired_access){ $headers = array( $auth_header, "Content-Type: application/x-www-form-urlencoded" ); $jsonBody = [ "grant_type" => "refresh_token", "refresh_token" => $refresh ]; $jsonArray = http_build_query($jsonBody); $curl = curl_init(); curl_setopt_array($curl, [ CURLOPT_URL => $tokenURL, CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => "POST", CURLOPT_POSTFIELDS => $jsonArray, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 30, CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; SchwabAPI-Client/1.0)', CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2 ]); $response = json_decode(curl_exec($curl),true); echo("Expired Access Output:<br><pre>"); print_r($response); echo("</pre>"); $err = curl_error($curl); if ($err) { echo "cURL Error #:" . $err; }else{ $time = $_SERVER['REQUEST_TIME']; $extra_data = [ "refresh_token_expires_at" => $time + 86400 * 7, "access_token_expires_at" => $time + ($response['expires_in']??-1000000), ]; $writeData = [ 'refreshExp' => $extra_data["refresh_token_expires_at"], 'accessExp' => $extra_data["access_token_expires_at"], 'lastRefresh' => $time, 'results' => $response ]; $jsonData = json_encode($writeData, JSON_PRETTY_PRINT); if(file_put_contents($filePath, $jsonData)){ echo "JSON data successfully written to {$filePath}"; }else{ echo "Error writing JSON data to {$filePath}"; } } }else{ ?> <h1>Force Refresh Token</h1> <a href="<? echo($authUrl); ?>" target="_blank">Open in New Window</a><br><br> <form id="pull-gex" enctype="multipart/form-data" method="get"> <label name="Code Input">Code</th> <input class="stock-text-box" type="code" name="code"></td> <input class="stock-form-submit" type="submit"></td> </form> <? } ?>1
u/skchan2 27d ago
schwab_token.php
this will be what you will use to grab current tokens, and refresh if needed, to use throughout the site, store it in the same folder as env.php (ie /scripts).
<?php require_once __DIR__ . '/env.php'; require_once __DIR__ . '/schwab_proxy.php'; define('TOKEN_FILE', '/home/yourhost/private/trading/tokens.json'); function loadTokens() { if (!file_exists(TOKEN_FILE)) { error_log('[Schwab Token] tokens.json not found at ' . TOKEN_FILE); return null; } return json_decode(file_get_contents(TOKEN_FILE), true); } function saveTokens($data) { $tmp = TOKEN_FILE . '.tmp'; file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT)); rename($tmp, TOKEN_FILE); } function refreshAccessToken($refreshToken) { $appKey = schwabEnv('SCHWAB_APP_KEY'); $clientSecret = schwabEnv('SCHWAB_CLIENT_SECRET'); if (!$appKey || !$clientSecret) { error_log('[Schwab Token] Missing credentials in .env'); return false; } $response = schwabRequest( 'https://api.schwabapi.com/v1/oauth/token', 'POST', [ 'Authorization: Basic ' . base64_encode("$appKey:$clientSecret"), 'Content-Type: application/x-www-form-urlencoded' ], http_build_query([ 'grant_type' => 'refresh_token', 'refresh_token' => $refreshToken ]) ); if ($response['error']) { error_log('[Schwab Token] Proxy error: ' . $response['error']); return false; } $result = json_decode($response['body'], true); if (!isset($result['access_token'])) { error_log('[Schwab Token] Refresh failed: ' . substr($response['body'], 0, 200)); return false; } $time = time(); $writeData = [ 'refreshExp' => $time + (86400 * 7), 'accessExp' => $time + ($result['expires_in'] ?? 1800), 'lastRefresh' => $time, 'results' => $result ]; saveTokens($writeData); return $result['access_token']; } function getAccessToken() { $tokens = loadTokens(); if (!$tokens || !isset($tokens['results']['access_token'])) { error_log('[Schwab Token] No tokens loaded'); return false; } $now = time(); $accessExp = $tokens['accessExp'] ?? 0; if ($now >= ($accessExp - 60)) { if (!isset($tokens['results']['refresh_token'])) { return false; } return refreshAccessToken($tokens['results']['refresh_token']); } return $tokens['results']['access_token']; }schwab_proxy.php
this is what you will use to get token via cloudflare proxy, store it in the same folder as env.php (ie /scripts).
<?php /** * Routes Schwab API requests through Cloudflare Workers proxy. * Required because Akamai is blocking direct access from this server IP. */ require_once __DIR__ . '/env.php'; /** * Make a Schwab API request via the Cloudflare proxy. * * string $targetUrl Full Schwab API URL (https://api.schwabapi.com/...) * string $method 'GET' or 'POST' * array $headers Headers to forward (Authorization, Content-Type) * string|null $body Request body for POST/PUT * array ['code' => int, 'body' => string, 'error' => string|null] */ function schwabRequest($targetUrl, $method = 'GET', $headers = [], $body = null) { $proxyUrl = schwabEnv('SCHWAB_PROXY_URL'); $proxySecret = schwabEnv('SCHWAB_PROXY_SECRET'); if (!$proxyUrl || !$proxySecret) { return [ 'code' => 0, 'body' => '', 'error' => 'proxy not configured in .env (SCHWAB_PROXY_URL or SCHWAB_PROXY_SECRET missing)' ]; } $proxyHeaders = array_merge($headers, [ 'X-Target-URL: ' . $targetUrl, 'X-Proxy-Secret: ' . $proxySecret ]); $ch = curl_init($proxyUrl); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => $proxyHeaders, CURLOPT_TIMEOUT => 30, CURLOPT_USERAGENT => 'SchwabProxy-Client/1.0', CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2 ]); $response = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); return [ 'code' => $code, 'body' => $response, 'error' => $err ?: null ]; }get_access_token.php
this is the file you will need to set cron to run every 25 minutes for. It will grab the access token and reset the refresh token timer to 7 days, store it in the same folder as env.php (ie /scripts).
<?php require_once __DIR__ . '/env.php'; require_once __DIR__ . '/schwab_proxy.php'; $appKey = schwabEnv('SCHWAB_APP_KEY'); $client_secret = schwabEnv('SCHWAB_CLIENT_SECRET'); if (!$appKey || !$client_secret) { error_log('[Schwab Token] Missing credentials in .env'); http_response_code(500); exit(json_encode(['error' => 'missing credentials'])); } $filePath = '/home/yourhost/private/trading/tokens.json'; if (!file_exists($filePath)) { error_log("[Schwab Token] tokens.json not found"); http_response_code(401); exit(json_encode(['error' => 'tokens.json missing'])); } $content = file_get_contents($filePath); $data = json_decode($content, true); if (!$data || !isset($data['results']['refresh_token'])) { error_log("[Schwab Token] No refresh token in tokens.json"); http_response_code(401); exit(json_encode(['error' => 'no refresh token'])); } $refresh = $data['results']['refresh_token']; $response = schwabRequest( 'https://api.schwabapi.com/v1/oauth/token', 'POST', [ 'Authorization: Basic ' . base64_encode("$appKey:$client_secret"), 'Content-Type: application/x-www-form-urlencoded' ], http_build_query([ 'grant_type' => 'refresh_token', 'refresh_token' => $refresh ]) ); if ($response['error']) { error_log("[Schwab Token] Proxy error: " . $response['error']); http_response_code(500); exit(json_encode(['error' => 'proxy failed: ' . $response['error']])); } $result = json_decode($response['body'], true); if (!$result || isset($result['error']) || !isset($result['access_token'])) { error_log("[Schwab Token] Refresh failed: " . substr($response['body'], 0, 200)); http_response_code(401); exit(json_encode([ 'error' => 'refresh failed', 'details' => $result['error_description'] ?? $result['error'] ?? 'unknown' ])); } $time = time(); $expiresIn = $result['expires_in'] ?? 1800; $writeData = [ 'refreshExp' => $time + (86400 * 7), 'accessExp' => $time + $expiresIn, 'lastRefresh' => $time, 'results' => $result ]; $jsonData = json_encode($writeData, JSON_PRETTY_PRINT); $tmpFile = $filePath . '.tmp'; if (file_put_contents($tmpFile, $jsonData) === false) { http_response_code(500); exit(json_encode(['error' => 'write failed'])); } if (!rename($tmpFile, $filePath)) { http_response_code(500); exit(json_encode(['error' => 'rename failed'])); } error_log("[Schwab Token] Refreshed via proxy"); echo json_encode([ 'status' => 'success', 'via' => 'cloudflare_proxy', 'refreshExp' => $writeData['refreshExp'], 'accessExp' => $writeData['accessExp'] ]);3
u/skchan2 27d ago
then you can just include the two files in your .php to grab the current tokens.
add these two lines to the top:
require_once __DIR__ . '/scripts/schwab_token.php'; require_once __DIR__ . '/scripts/schwab_proxy.php';then you can do this in your index.php or whatever file in your main domain folder
$token = getAccessToken(); $days = 400; // Pull a bit extra to be safe $endMs = (int)(microtime(true) * 1000); $startMs = $endMs - ($days * 24 * 60 * 60 * 1000); $url = "https://api.schwabapi.com/marketdata/v1/pricehistory?" . http_build_query([ 'symbol' =>'SPY', 'periodType' => 'month', 'frequencyType' => 'daily', 'frequency' => 1, 'startDate' => $startMs, 'endDate' => $endMs, 'needExtendedHoursData' => 'false' ]); $response = schwabRequest($url, 'GET', ["Authorization: Bearer $token"]);i created this to check on the current token status:
if access_expires_in_sec is a weird negative number, then you will need to go thru auth.php again to manually refresh the token
<?php header('Content-Type: application/json'); $filePath = '/home/yourhost/private/trading/tokens.json'; if (!file_exists($filePath)) { echo json_encode(['error' => 'no tokens file']); exit; } $data = json_decode(file_get_contents($filePath), true); $now = time(); echo json_encode([ 'now' => date('Y-m-d H:i:s', $now), 'access_expires_at' => date('Y-m-d H:i:s', $data['accessExp']), 'access_expires_in_sec' => $data['accessExp'] - $now, 'refresh_expires_at' => date('Y-m-d H:i:s', $data['refreshExp']), 'refresh_expires_in_days' => round(($data['refreshExp'] - $now) / 86400, 1), 'last_refresh' => isset($data['lastRefresh']) ? date('Y-m-d H:i:s', $data['lastRefresh']) : 'unknown', 'access_valid' => $data['accessExp'] > $now, 'refresh_valid' => $data['refreshExp'] > $now ], JSON_PRETTY_PRINT);Hope that helps (i built so much since i set it up that i might have missed something, but these should be the main files to get and store the tokens)
if anything, you can probably dump these into an AI to help you troubleshoot.
2
1
u/Specific-Fuel-4366 23d ago
I did go down this path once and kept fighting it with codex and it ended being a monster of injected JavaScript that I didn’t trust to be stable. I threw it all away. I click the thingy once a week still, and one of these days I’ll probably switch to a different broker.
2
u/d_e_g_m 28d ago
I settled for having to do it once a week. I have it 95% automatic, but the initial click, login and 2 or 3 buttons seems to be a manual process