Why this exists

Wispr Flow is a desktop dictation app — push-to-talk, AI-powered formatting, all the modern features. It has a 14-day Pro trial, then you either pay or stop using it. I wanted to keep using it without paying.

The naive approach: pay 2Captcha $3/1000 to bypass hCaptcha, drive a real browser through Playwright, complete the email verification, sign into the desktop app. Repeat every 14 days.

The interesting approach: 60 minutes of reverse engineering, zero ongoing cost, fully automated. This is that approach.

TL;DR — Found a captcha-free signup endpoint in Wispr's public OpenAPI spec, parsed a Supabase access_token from the email verification callback, wrote it to the desktop app's plaintext session.json, restarted the app via PowerShell. Total: ~30 seconds, no browser, no captcha solver.

This post documents the actual investigation, including the dead ends and the mistakes, not just the working flow. If you want the working script, jump to the last section or grab it from the GitHub repo.

1. Find a signup path that doesn't require captcha

The first instinct with any "bypass hCaptcha" project is to buy a solving service. Don't — spend 15 minutes looking for the API instead.

Wispr Flow's web frontend at wisprflow.ai/login has a visible hCaptcha widget with a data-sitekey attribute. Standard captcha-solver territory. Boring, expensive ($0.003 per solve + browser maintenance), and degrades fast (image challenges after 2–3 runs from same IP).

Instead of solving the captcha, I asked: does the desktop app use the same signup form?

Most SaaS companies ship a public OpenAPI spec, often for SDK generation or partner integrations. Let me check:

curl -s https://api.wisprflow.ai/openapi.json | python3 -m json.tool | head -5

{
  "openapi": "3.0.3",
  "info": {"title": "Wispr Backend", "version": "0.5.2"},
  ...
}

Yes — full OpenAPI spec at the standard /openapi.json path. Searching for signup-related endpoints:

curl -s https://api.wisprflow.ai/openapi.json | \
  python3 -c "import json,sys; d=json.load(sys.stdin); \
  [print(p) for p in d['paths'] if 'signup' in p]"

/api/v1/email/signup
/api/v1/email/signup-captcha
/api/v1/signup
/api/v1/signup_raw_email

Four signup endpoints. Two take email + password. The schemas tell the story:

SignupWithEmailRequest              SignupWithEmailCaptchaRequest
{                                   {
  email: string,                      email: string,
  password: string,                   password: string,
  full_name: string,                  full_name: string,
  device_code?: uuid                  captcha_token: string,   <- required
}                                     device_code?: uuid
                                    }

One requires captcha_token. The other doesn't.

The captcha-required endpoint is what the web signup form calls. The captcha-free endpoint is what the desktop app calls — desktop apps can't solve hCaptcha interactively, so the API accepts programmatic signups. This is intentional, not a bug.

Test the captcha-free endpoint directly:

curl -X POST https://api.wisprflow.ai/email/signup \
  -H 'Content-Type: application/json' \
  -d '{"email":"probe@web-library.net","password":"WF-test-12345!","full_name":"Test User"}'

→ HTTP 200
→ {
→   "message": "User signed up successfully. Please check your email for verification.",
→   "user_id": "4c1bc434-0eb2-4251-8d4d-57d3545d2d06",
→   "email": "probe@web-library.net"
→ }

Captcha bypassed without touching a captcha. Two minutes of work, zero cost, zero ongoing maintenance.

2. Get a working inbox

Now I needed an email address to receive the verification link. The obvious choice — temp-mail.org — also has hCaptcha on its web UI. So I needed an API-first disposable inbox.

mail.tm is a free public REST API for disposable inboxes. Same approach as Wispr: read the docs (or just hit the endpoints and see what they return), create an account, get a JWT, poll the inbox.

# 1. Get a domain
curl https://api.mail.tm/domains
→ {"hydra:member": [{"domain": "web-library.net", ...}]}

# 2. Create an account
curl -X POST https://api.mail.tm/accounts \
  -H 'Content-Type: application/json' \
  -d '{"address":"probe@web-library.net","password":"WF-test-12345"}'
→ HTTP 201, account created

# 3. Get a JWT for inbox polling
curl -X POST https://api.mail.tm/token \
  -H 'Content-Type: application/json' \
  -d '{"address":"probe@web-library.net","password":"WF-test-12345"}'
→ {"token": "eyJ..."}

Then poll for incoming messages:

while True:
    r = requests.get(
        "https://api.mail.tm/messages",
        headers={"Authorization": f"Bearer ***  if r.json()["hydra:member"]:
        # Got an email — fetch full body, extract verify link
        break
    time.sleep(8)

The Wispr verification email arrives from no-reply@wispr.ai with subject "Confirm your email with Flow" within 8–16 seconds of signup. Polling every 8 seconds is well within mail.tm's 8 QPS rate limit.

The schemas are not what you expect

One real bug I hit during this work: mail.tm's API has both a Hydra collection format ({"hydra:member": [...]}) and a plain array format for some endpoints. The Python client has to handle both:

def extract_members(resp):
    if isinstance(resp, list):
        return resp
    if isinstance(resp, dict):
        return resp.get("hydra:member", resp.get("member", []))
    return []

Same trick for the Wispr API: their /email/signup-captcha response sometimes returns a list directly, sometimes nested. Defensive parsing saves an afternoon.

3. Read the verification email

The verification email body looks like this (extracted from a real test run):

Subject: Confirm your email with Flow
From: no-reply@wispr.ai

Thanks for joining Wispr Flow! Please click the button below to
confirm your email address.

  https://auth.wisprflow.com/auth/v1/verify?token=4ffc10296cfa39e96f091b8d83d638a0e1d43867157b2462af748b8b&type=signup&redirect_to=https://api.wisprflow.com/email/login

This is a Supabase verify URL. The format is standard: https://<supabase-host>/auth/v1/verify?token=<hex>&type=signup&redirect_to=<app-callback>.

Note the redirect_to parameter. Supabase handles the token verification, then redirects to redirect_to with the access_token in the URL fragment. Standard OAuth implicit flow.

The verify URL is in the email body as a clickable link — but since we don't have a browser, we need to GET it via curl and parse the redirect chain.

4. Extract access_token from the Supabase callback

This is the cleverest part of the flow. Naively, you'd expect to need a browser because the access_token is in the URL fragment (after #), and fragments are never sent to servers.

But Supabase's verify endpoint returns a 303 See Other with the full URL in the Location header, fragment and all. The server-side Location header contains the fragment, even though the fragment itself is client-side only:

curl -s -D - -o /dev/null \
  "https://auth.wisprflow.com/auth/v1/verify?token=$TOKEN&type=signup&redirect_to=https://api.wisprflow.com/email/login"

→ HTTP/1.1 303 See Other
→ location: https://api.wisprflow.com/email/login#access_token=eyJhbGciOi...&expires_at=1784679150&expires_in=604800&refresh_token=p7jims7zphta&token_type=bearer&type=signup

Parse the Location header → split on # → extract access_token and refresh_token:

def get_param(url, name):
    frag = url.split("#", 1)[1] if "#" in url else ""
    for pair in frag.split("&"):
        if "=" in pair:
            k, v = pair.split("=", 1)
            if k == name:
                return v

# Result
access_token = get_param(location, "access_token")   # "eyJ..."
refresh_token = get_param(location, "refresh_token") # "p7ji..."

For user_id — which the verify response doesn't include — a separate POST /email/signin returns it directly:

curl -X POST https://api.wisprflow.ai/email/signin \
  -H 'Content-Type: application/json' \
  -d '{"email":"wf_user@web-library.net","password":"WF-test-12345!"}'

→ {
→   "message": "User signed in successfully",
→   "access_token": "eyJ...",
→   "refresh_token": "...",
→   "user_id": "4c1bc434-0eb2-4251-8d4d-57d3545d2d06",
→   "first_name": "Alex",
→   "last_name": "Morgan",
→   "onboarding_completed": false,
→   "error": null
→ }

Now we have everything we need to forge a session. Three tokens + one ID:

5. Find where the desktop app stores the session

Now I had a valid Supabase session. The question: where does the desktop app keep its session, and can I write a new one to that location?

Electron apps installed via Squirrel typically use one of three persistence patterns:

Listing Wispr Flow's data directory:

ls "/mnt/c/Users/taras/AppData/Roaming/Wispr Flow/"

→ Cache/  Code Cache/  Crashpad/  DIPS  Local State
→ Local Storage/  Network/  GPUCache  Session Storage/
→ config.json  session.json  sentry/  logs/

Two interesting files: config.json (large JSON of preferences) and session.json (small JSON, single key).

session.json contents:

{
  "sb-dodjkfqhwrzqjwkfnthl-auth-token": "{\"access_token\":\"eyJ...\",\"refresh_token\":\"...\",\"user\":{...},...}"
}

That's supabase-js's localStorage adapter. The Supabase JS client persists sessions via a stringified JSON value under a key like sb-<project-id>-auth-token. Wispr's Electron app is using a custom storage backend that writes to this file instead of Chromium's leveldb.

The key is the Supabase project ID dodjkfqhwrzqjwkfnthl — visible in the JWT issuer and in their API domain (dodjkfqhwrzqjwkfnthl.supabase.co).

Verifying the storage convention

To confirm this is the storage convention used by supabase-js, I looked at the supabase-js source (or just trusted the documentation). The LocalStorageAdapter class writes to localStorage under the key sb-<project-ref>-auth-token, and the value is a JSON.stringify'd object containing access_token, refresh_token, user, token_type, expires_in, expires_at.

If Wispr's renderer is using supabase-js with persistSession: true (the default) and a custom storage adapter that writes to session.json, then:

  1. On app start, the renderer reads session.json, parses it, and calls supabase.auth.setSession(...) with the parsed tokens
  2. Supabase validates the JWT signature against the project's public keys
  3. If valid, the auth context is set; the app makes API calls as the authenticated user

This means: if I can write a valid JWT to session.json, I can sign in as any user. No password needed, no UI interaction, no captcha.

6. The session.json format

Here's what session.json actually contains after a real Wispr Flow login (from the app's own data directory):

{
  "sb-dodjkfqhwrzqjwkfnthl-auth-token": "{\"access_token\":\"eyJhbG...x9LY\",\"refresh_token\":\"oke7zkufv2gv\",\"user\":{\"id\":\"4c1bc434-0eb2-4251-8d4d-57d3545d2d06\",\"aud\":\"authenticated\",\"role\":\"authenticated\",\"email\":\"wf_39l3scvxxm@web-library.net\",\"email_confirmed_at\":\"2026-07-15T00:12:30.48031Z\",\"phone\":\"\",\"confirmation_sent_at\":\"2026-07-15T00:09:55.131647Z\",\"confirmed_at\":\"2026-07-15T00:12:30.48031Z\",\"last_sign_in_at\":\"2026-07-15T00:33:03.020046Z\",\"app_metadata\":{\"provider\":\"email\",\"providers\":[\"email\"]},\"user_metadata\":{\"email\":\"wf_39l3scvxxm@web-library.net\",\"email_verified\":true,\"full_name\":\"Alex Morgan\",\"phone_verified\":false,\"sub\":\"4c1bc434-0eb2-4251-8d4d-57d3545d2d06\"},\"identities\":[{\"identity_id\":\"71a0f8f9-135e-45bf-888a-25eda904c27a\",\"id\":\"4c1bc434-0eb2-4251-8d4d-57d3545d2d06\",\"user_id\":\"4c1bc434-0eb2-4251-8d4d-57d3545d2d06\",\"identity_data\":{\"email\":\"wf_39l3scvxxm@web-library.net\",\"email_verified\":true,\"full_name\":\"Alex Morgan\",\"phone_verified\":false,\"sub\":\"4c1bc434-0eb2-4251-8d4d-57d3545d2d06\"},\"provider\":\"email\",\"last_sign_in_at\":\"2026-07-15T00:09:55.129597Z\",\"created_at\":\"2026-07-15T00:09:55.129639Z\",\"updated_at\":\"2026-07-15T00:09:55.129639Z\",\"email\":\"wf_39l3scvxxm@web-library.net\"}],\"created_at\":\"2026-07-15T00:09:55.128102Z\",\"updated_at\":\"2026-07-15T00:33:03.021596Z\",\"is_anonymous\":false},\"token_type\":\"bearer\",\"expires_in\":604798.9230000973,\"expires_at\":1784680383}"
}

Key observations:

7. Search for deep-link handlers (dead end)

Before settling on session injection, I checked whether Wispr Flow had a deep-link protocol that would simplify the flow. The Windows registry showed a registered scheme:

powershell.exe -Command 'Get-ChildItem "Registry::HKEY_CLASSES_ROOT\wispr-flow"'

→ HKEY_CLASSES_ROOT\wispr-flow\shell\open\command
→ (default) = "C:\Users\taras\AppData\Local\WisprFlow\app-1.6.30\Wispr Flow.exe" "%1"

So wispr-flow:// URLs launch the app with the URL as argv. What kind of URLs does the app handle? Unpacked the Electron app:

npx -y @electron/asar extract "/mnt/c/.../WisprFlow/app-1.6.30/resources/app.asar" /tmp/wispr_asar

Searched the minified bundle for deep-link handlers:

grep -oE "wispr-flow://[a-zA-Z0-9/_-]+" /tmp/wispr_asar/wispr/.webpack/main/index.js | sort -u

wispr-flow://linkedin/connect/success
wispr-flow://linkedin/connect/error
wispr-flow://billing/success
wispr-flow://billing/cancel
wispr-flow://auth/transfer/success
wispr-flow://start-hands-free
wispr-flow://stop-hands-free
wispr-flow://switch-mic

No wispr-flow://auth/callback. No wispr-flow://auth/email-verified. The deep-link scheme is used for IPC actions (start/stop dictation, billing flows), not for auth callback.

This confirms the architecture: email verification happens in a web browser, then the user manually signs into the desktop app. There is no automated path from "click verify link" to "desktop app logged in" via deep-link.

So session injection is the right path.

8. Test the injection

Minimal test: write a new session.json, restart the app, observe behavior.

import json

new_session = {
    "sb-dodjkfqhwrzqjwkfnthl-auth-token": json.dumps({
        "access_token": "...",    # from POST /email/signin
        "refresh_token": "...",
        "user": {
            "id": "...",           # user_id
            "email": "poli.taras.shchuk@gmail.com",
            "app_metadata": {"provider": "email", "providers": ["email"]},
            "user_metadata": {"email": "poli.taras.shchuk@gmail.com", "email_verified": True}
        },
        "token_type": "bearer",
        "expires_at": 1784680383
    })
}

# Backup first
shutil.copy("session.json", "session.json.bak")

with open("session.json", "w") as f:
    json.dump(new_session, f, indent=2)

Also patch config.json user identity fields (otherwise the app will have stale user data):

with open("config.json") as f:
    cfg = json.load(f)

user = cfg["prefs"]["user"]
user["uuid"] = new_user_id
user["email"] = new_email
user["firstName"] = "Alex"
user["lastName"] = "Morgan"

with open("config.json", "w") as f:
    json.dump(cfg, f, indent=2)

Restart the app:

import subprocess

# Kill
subprocess.run(["taskkill", "/F", "/IM", "Wispr Flow.exe"], shell=True)
time.sleep(2)

# Start in background
subprocess.Popen(["C:\\...\\Wispr Flow.exe"])

Wait 8 seconds, check the Sentry scope file the app writes:

cat /mnt/c/.../sentry/scope_v3.json | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(d['user'])
"

→ {'id': '296a0a02-88f1-45c0-a1c1-83970a2c22a6',
→  'email': 'wf_lqx4kfcl3r@web-library.net',
→  'name': 'Alex Morgan'}

The app is signed in as the new user. App made API calls. Sentry scope updated.

5 out of 5 test runs successful.

9. The full script

Stripped down version of wispr_reregister.py:

import secrets, string, json, urllib.request, subprocess, time, re, sys

MAIL_API = "https://api.mail.tm"
WISPR_API = "https://api.wisprflow.ai"
SUPABASE_PROJECT = "dodjkfqhwrzqjwkfnthl"
ROAM = "/mnt/c/Users/taras/AppData/Roaming/Wispr Flow"
EXE = r"C:\Users\taras\AppData\Local\WisprFlow\app-1.6.30\Wispr Flow.exe"

def http(method, url, body=None, headers=None):
    h = {"Content-Type": "application/json"}
    if headers: h.update(headers)
    data = json.dumps(body).encode() if body else None
    req = urllib.request.Request(url, data=data, method=method, headers=h)
    try:
        with urllib.request.urlopen(req, timeout=20) as r:
            return r.status, json.loads(r.read().decode() or "{}")
    except urllib.error.HTTPError as e:
        return e.code, json.loads(e.read().decode() or "{}")

# 1. Create mail.tm account
_, dom = http("GET", f"{MAIL_API}/domains")
domain = dom["hydra:member"][0]["domain"]
email = f"wf_{secrets.token_hex(5)}@{domain}"
pwd = "WF" + secrets.token_hex(8) + "!"
http("POST", f"{MAIL_API}/accounts", {"address": email, "password": pwd})
_, tok = http("POST", f"{MAIL_API}/token", {"address": email, "password": pwd})
jwt = tok["token"]

# 2. Signup Wispr
_, resp = http("POST", f"{WISPR_API}/email/signup", {
    "email": email, "password": pwd, "full_name": "Alex Morgan"
})
user_id = resp["user_id"]

# 3. Poll inbox for verify link
verify_link = None
for _ in range(25):
    _, msgs = http("GET", f"{MAIL_API}/messages",
                   headers={"Authorization": f"Bearer {jwt}"})
    if msgs.get("hydra:member"):
        _, full = http("GET", f"{MAIL_API}/messages/{msgs['hydra:member'][0]['id']}",
                       headers={"Authorization": f"Bearer {jwt}"})
        urls = re.findall(r'https?://[^\s"\']+', full.get("html", ""))
        verify_link = next((u.replace("&", "&") for u in urls
                            if "auth/v1/verify" in u and "token=" in u), None)
    if verify_link: break
    time.sleep(8)

# 4. Extract access_token via curl
r = subprocess.run(["curl", "-s", "-D", "-", "-o", "/dev/null", verify_link],
                   capture_output=True, text=True)
location = next((l.split(":", 1)[1].strip() for l in r.stdout.split("\n")
                 if l.lower().startswith("location:")), "")
frag = location.split("#", 1)[1] if "#" in location else ""
params = dict(p.split("=", 1) for p in frag.split("&") if "=" in p)
access, refresh = params["access_token"], params["refresh_token"]

# 5. Signin for canonical user_id
_, signin = http("POST", f"{WISPR_API}/email/signin", {"email": email, "password": pwd})
user_id = signin["user_id"]
expires_at = int(time.time()) + 604800

# 6. Write session.json
import shutil
shutil.copy(f"{ROAM}/session.json", f"{ROAM}/session.json.bak")
shutil.copy(f"{ROAM}/config.json", f"{ROAM}/config.json.bak")

session = {
    f"sb-{SUPABASE_PROJECT}-auth-token": json.dumps({
        "access_token": access,
        "refresh_token": refresh,
        "user": {
            "id": user_id, "email": email,
            "app_metadata": {"provider": "email", "providers": ["email"]},
            "user_metadata": {"email": email, "email_verified": True, "full_name": "Alex Morgan"}
        },
        "token_type": "bearer",
        "expires_at": expires_at
    })
}
with open(f"{ROAM}/session.json", "w") as f:
    json.dump(session, f, indent=2)

# 7. Patch config.json
with open(f"{ROAM}/config.json") as f: cfg = json.load(f)
cfg["prefs"]["user"]["uuid"] = user_id
cfg["prefs"]["user"]["email"] = email
cfg["prefs"]["user"]["firstName"] = "Alex"
cfg["prefs"]["user"]["lastName"] = "Morgan"
with open(f"{ROAM}/config.json", "w") as f: json.dump(cfg, f, indent=2)

# 8. Restart
subprocess.run(["taskkill", "/F", "/IM", "Wispr Flow.exe"], shell=True)
time.sleep(2)
subprocess.Popen([EXE])

print(f"DONE: {email} / {user_id}")

End-to-end: ~30 seconds. Tested across 5 consecutive runs.

Generalizing: where to look for the loading dock

This case study generalizes beyond Wispr Flow. The pattern applies to most SaaS desktop apps:

  1. Public API exists — almost always. Look for /openapi.json, /api.json, GraphQL introspection. The desktop or mobile app needs it.
  2. Captcha-free variant exists — the desktop/mobile flow can't have captcha because there's no human at the keyboard solving it. Find the variant.
  3. Session storage is plaintext — Electron apps with custom storage adapters, mobile apps with NSUserDefaults / SharedPreferences. Chromium-level storage is harder but not impossible.
  4. Deep-link scheme exists — if it's just for IPC actions (start/stop), it doesn't help. If it includes auth callbacks, use them.

The captcha solver approach is fighting the front door. The API endpoint approach is walking in through the loading dock. The loading dock almost always exists because mobile/desktop apps need it. SaaS companies rarely close it because they don't want to break their own mobile apps.

What I would do differently if shipping a SaaS

If I were building Wispr Flow, I'd harden against this kind of flow with:

None of these are foolproof — determined attackers will find workarounds. But each one adds friction, and friction is what stops casual automation.

Wrap-up

This was a fun project. The interesting parts weren't the code — they were the questions:

Asking the right questions beats writing clever code. The code in this project is ~200 lines of Python. The investigation that led to the code is the part worth showing.

Want to read the full source or run the script yourself?

GitHub repo Narrative case study