Overview
When a SaaS public API is missing features the UI clearly supports, the UI is usually talking to a private API on a different subdomain. This pattern captures how to discover, authenticate, and replay those calls.
When to Use
- Public API docs say "not supported" for something the UI does
- You need bulk or admin operations at scale (50+ sub-accounts)
- You need to replicate web app behavior from a script
Legal Check First
Verify the platform's ToS does not explicitly forbid reverse engineering. Do not use private API results in client-facing production without a fallback — private APIs change without notice.
Step 1: Identify the Endpoint
- Open Chrome DevTools → Network → XHR/Fetch filter
- Perform the action in the UI
- Note the request domain — if different from the public API base, that's the private one
- Capture: auth header name, token format, required custom headers (
channel,source,version, tenant ID, etc.)
Example (GoHighLevel):
Public API: api.gohighlevel.com
Private API: backend.leadconnectorhq.com
Custom headers: channel: APP, source: WEB_USER, version: 2021-07-28
Step 2: Capture Authenticated Session
Use Playwright to log in manually and persist storage state:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
ctx = browser.new_context()
page = ctx.new_page()
page.goto("https://app.example.com/login")
input("Log in manually, then press Enter...")
ctx.storage_state(path="auth.json")
browser.close()
auth.json contains cookies + localStorage. Most private APIs authenticate via one of these.
Step 3: Extract the Token
import json
auth = json.load(open("auth.json"))
# Find token in localStorage
token = next(
item["value"]
for origin in auth["origins"]
for item in origin["localStorage"]
if item["name"] == "token-id" # adjust key name per platform
)
print(f"Token: {token[:20]}...")
Common localStorage key names: token-id, access_token, jwt, authToken, session.
Step 4: Replay with httpx
Copy a UI request as cURL from DevTools (right-click → Copy as cURL). Convert to Python:
import httpx
PRIVATE_BASE = "https://backend.leadconnectorhq.com"
LOCATION_ID = "loc_abc123"
headers = {
"Authorization": f"Bearer {token}",
"channel": "APP",
"source": "WEB_USER",
"version": "2021-07-28",
"content-type": "application/json",
}
r = httpx.get(
f"{PRIVATE_BASE}/contacts/",
headers=headers,
params={"locationId": LOCATION_ID, "limit": 100},
)
r.raise_for_status()
print(r.json())
Step 5: Map the Schema
To learn payload shape for POST/PUT, intercept a UI save action:
- Perform the create/update in the UI while capturing network traffic
- Copy the request body — it's your payload template
- Start from the full known-good payload
- Remove optional fields one at a time to find the minimum required set
# Template from captured UI request
payload = {
"firstName": "Test",
"lastName": "User",
"email": "test@example.com",
"locationId": LOCATION_ID,
"source": "API",
# ... other fields
}
r = httpx.post(f"{PRIVATE_BASE}/contacts/", headers=headers, json=payload)
Step 6: Handle Token Refresh
Tokens expire. On 401:
def refresh_token():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
ctx = browser.new_context(storage_state="auth.json")
page = ctx.new_page()
page.goto("https://app.example.com/dashboard")
page.wait_for_load_state("networkidle")
ctx.storage_state(path="auth.json")
def request_with_retry(url, headers, params=None, max_retries=2):
for attempt in range(max_retries):
r = httpx.get(url, headers=headers, params=params)
if r.status_code == 401 and attempt < max_retries - 1:
refresh_token()
# reload token and update headers
continue
r.raise_for_status()
return r.json()
Step 7: Throttle and Build Resilience
import time
import logging
def safe_request(client, url, headers, params=None, delay=1.0):
time.sleep(delay) # start at 1 req/sec, back off on 429
try:
r = client.get(url, headers=headers, params=params, timeout=30)
if r.status_code == 429:
retry_after = int(r.headers.get("Retry-After", 60))
logging.warning(f"Rate limited. Waiting {retry_after}s...")
time.sleep(retry_after)
return safe_request(client, url, headers, params, delay)
r.raise_for_status()
return r.json()
except Exception as e:
logging.error(f"Request failed: {e}")
raise
Gotchas
- Headers change: Private APIs often check custom headers strictly. Missing one returns 400 or silent empty results.
- Pagination: Private APIs rarely match public API pagination. Common patterns:
page+limit,offset+limit, cursor-based. - Response shape: Private responses often differ from public API docs. Always log raw responses during schema mapping.
- Breaking changes: Private APIs are versioned internally. A platform update can break your integration with no notice.