D
Dev SOPKnowledge Base
Search
← All topics

Reverse Engineering Private APIs: SaaS Endpoint Discovery and Replay

Pattern for discovering and replaying private SaaS API endpoints — network tab capture, auth extraction, Playwright session state, httpx replay, schema mapping, token refresh, and rate limiting.

apireverse-engineeringplaywrighthttpxprivate-apiautomation
Agent trigger phrases: reverse engineer API · private API · undocumented API · crack API · SaaS private endpoint · backend API · network tab capture

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

  1. Open Chrome DevTools → Network → XHR/Fetch filter
  2. Perform the action in the UI
  3. Note the request domain — if different from the public API base, that's the private one
  4. 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:

  1. Perform the create/update in the UI while capturing network traffic
  2. Copy the request body — it's your payload template
  3. Start from the full known-good payload
  4. 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.