🎉 Premium Proxies · 24-Hour Free TrialClaim Now
Guides

How to Bypass Vercel BotID When Web Scraping

Bypass Vercel BotID: confirm it in one request, read the Kasada engine underneath, see what a block really returns, and know where the ceiling sits.

S SparkProxy 3 31 min read
Share

To bypass Vercel BotID you first have to notice it, and noticing is the hard part: a protected endpoint answers 403 with {"error":"Access denied"}, and there's no challenge page, no cf-ray header, no _abck cookie, nothing in the response that names a vendor. BotID is unusual in how it arrives. It isn't a product the site went shopping for, it's a platform feature: a Vercel team installs one npm package, registers a path, and that path inherits enterprise-grade detection from a vendor the team never contracted with directly. This guide pins down what BotID is from Vercel's own documentation, who builds the detection underneath it, the single request that confirms it, and exactly where the ceiling sits for anything that isn't a real browser. Every figure below was read on 9 October 2026.

What Vercel BotID is, in Vercel's own words

Vercel's documentation calls BotID "an invisible CAPTCHA that protects against sophisticated bots without showing visible challenges or requiring user action. It's a client-side challenge that uses machine learning to distinguish between humans and bots." The same page positions it as "a protection layer to high-value routes, such as checkouts, signups, and APIs." That docs page carried a last_updated stamp of 16 June 2026 when read on 9 October 2026.

Two Vercel publications dated 25 June 2025 introduced it: the engineering post "Introducing BotID, invisible bot filtering for critical routes", and a changelog entry titled "Vercel BotID is now generally available" credited to Andrew Qu. The blog post's own framing is blunter than the docs: "Think of it as an invisible CAPTCHA to stop browser automation before it reaches your backend."

The validation flow Vercel documents has five moves. A client-side challenge goes to the browser. The browser solves it and attaches the solution to requests aimed at the protected endpoint. The application's server code calls checkBotId(). Vercel validates the integrity of the challenge response. Deep Analysis then runs a machine-learning model over the client-side signals, if the route is configured for it. The verdict comes back to the handler, which decides what to do.

That last clause is the whole reason BotID behaves unlike the edge products you're used to. Vercel doesn't block anything. It returns a boolean, and the application author writes the if statement. Compare that with Cloudflare Turnstile, which also aims at invisibility but is widely deployed as a widget with its own visible failure state. BotID has no failure state of its own.

There are two check levels, and the difference matters for how much compute you're fighting. Basic "validates the integrity and correctness of the challenge response, catching many less sophisticated bots", and Vercel says it's "provided free of charge for all plans". Deep Analysis "uses machine learning to analyze thousands of client side signals" and runs only after Basic has passed. Pricing, from the table on the BotID docs page read 9 October 2026, in US dollars:

ModePlans availablePrice
BasicAll plansFree
Deep AnalysisPro$1 per 1,000 `checkBotId()` Deep Analysis calls
Deep AnalysisEnterpriseCustom

Vercel adds a note that reads, in full: "Calling the checkBotId() function in your code triggers BotID Deep Analysis charges. Passive page views or requests that don't invoke the checkBotId() function are not charged."

Read that as a scraper and it tells you something useful about deployment economics. Deep Analysis bills per verification call, so a site that puts it on a hot public endpoint is choosing to pay per request. That's a real disincentive to blanket coverage, and it's one reason BotID tends to sit on a handful of POST routes rather than across a whole property.

Kasada is the engine under Deep Analysis

Vercel names its partner directly. The BotID docs page summary reads: "Protect your applications from automated attacks with intelligent bot detection and verification, powered by Kasada." The body is more specific: "BotID includes Deep Analysis, powered by Kasada. Kasada is a leading bot protection provider trusted by Fortune 500 companies and global enterprises." The launch post says "BotID with Deep Analysis is powered by Kasada, whose detection engine protects some of the most targeted platforms on the internet."

Those are Vercel's claims about Kasada, not measurements, and should be read that way.

What makes the attribution more than marketing is that you can see it in the shipped code. Reading botid@1.5.11 from the npm registry on 9 October 2026, the client module loads a second script and then waits on two DOM events named kpsdk-load and kpsdk-ready, before calling window.KPSDK.configure() with the list of protected routes:

// botid@1.5.11, dist/client/core/index.mjs (de-minified names)
const DEEP_ANALYSIS_SCRIPT = `${PREFIX}/p.js`;

document.addEventListener('kpsdk-load', onLoad, { once: true });
document.addEventListener('kpsdk-ready', onReady, { once: true });
loadScript(DEEP_ANALYSIS_SCRIPT);

// onLoad():
window.KPSDK.configure(
  routes.map(r => ({ domain: host(r.path), path: pathname(r.path), method: r.method }))
);

The server module is equally explicit. Before it asks Vercel for a verdict, it throws away every cookie on the request except the ones whose names begin with KP_:

// botid@1.5.11, dist/server/index.mjs (de-minified names)
const keepOnlyKasadaCookies = (headers) => {
  const { authorization, ...rest } = headers;
  const kp = parseCookies(rest).filter(c => c.name.startsWith('KP_'));
  rest.cookie = kp.length
    ? kp.map(c => `${c.name}=${c.value}`).join('; ')
    : undefined;
  return rest;
};

KPSDK, p.js, and the KP_ cookie prefix are Kasada's own naming. So the detection mechanics are not new, and this guide won't repeat them. If you want the proof-of-work, the polymorphic payload and the x-kpsdk-* header family explained properly, that's already covered in how to bypass Kasada. Everything in that guide about why a bare HTTP client can't mint a valid token applies here unchanged.

One difference is worth holding onto, because it changes your detection code. On a Kasada-native deployment the vendor is loud: you get x-kpsdk-ct and x-kpsdk-cd response headers and a 429 carrying the challenge. Under BotID, the Kasada session material lives in KP_* cookies set on the target's own domain, the verification call happens server-to-server between the app and api.vercel.com, and the verdict reaches you only as whatever the application chose to return. The vendor is silent. Nothing in a blocked response has to mention Kasada, Vercel, or bots at all.

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

One request that confirms BotID

Here's the part that isn't in anybody's documentation, and it's the most useful thing in this guide.

The botid package proxies Kasada's scripts through the target's own origin, so they can't be blocked by ad-blockers or third-party script filters. Vercel's setup guide says as much: the rewrites exist so "ad-blockers, third party scripts, and more won't make BotID any less effective." To do that, the package hard-codes a path prefix built from two UUIDs, and it's the same prefix in every install. From dist/next/config/index.mjs in botid@1.5.11:

const PREFIX = '/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3';
const CHALLENGE = `${PREFIX}/a-4-a/c.js`;

// withBotId() injects these into Next.js rewrites (beforeFiles):
[
  { source: CHALLENGE,         destination: 'https://api.vercel.com/bot-protection/v1/challenge' },
  { source: `${PREFIX}/:path*`, destination: 'https://api.vercel.com/bot-protection/v1/proxy/:path*' },
]

Vercel's own docs publish the identical pair for non-Next frameworks, as a vercel.json snippet. So a site running BotID serves a JavaScript file at a path that no ordinary application would ever route, and you can ask for it without touching a protected endpoint:

PREFIX='/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3'
HOST='vercel.com'

curl -s -o /dev/null -w '%{http_code} %{content_type} %{size_download}\n' \
  -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/141.0.0.0 Safari/537.36' \
  "https://$HOST$PREFIX/a-4-a/c.js?i=0&v=3&h=$HOST"

curl -s -o /dev/null -w '%{http_code} %{content_type} %{size_download}\n' \
  -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/141.0.0.0 Safari/537.36' \
  "https://$HOST$PREFIX/p.js"

Results I measured on 9 October 2026, with nextjs.org as a negative control because it's a Vercel-hosted property that doesn't answer on the prefix:

OriginPathStatusContent-TypeTransferred
`vercel.com``//a-4-a/c.js?i=0&v=3&h=vercel.com`200`text/javascript`24,873 bytes
`vercel.com``//p.js`200`application/javascript`65,431 bytes (Brotli; 149,967 decompressed)
`v0.app``//a-4-a/c.js?i=0&v=3&h=v0.app`200`text/javascript`24,872 bytes
`nextjs.org``//a-4-a/c.js`404`text/html`application 404 page

A 200 with a JavaScript content type on that prefix means the site has BotID wired up. A 404 means it hasn't, or it's on a version that moved the prefix. Two extra tells sharpen the read. First, the challenge body is self-identifying: the c.js I pulled from vercel.com begins window.V_C=window.V_C||[];window.V_C.L=Date.now(); and then runs into an obfuscated string-array decoder, which matches the client module's use of a global window.V_C array as the delivery channel for the solved challenge. Second, the presence of p.js on the prefix tells you Deep Analysis is in play, not just Basic.

A Python version, if you want to sweep a list of targets:

import sys
import requests

PREFIX = (
    "/149e9513-01fa-4fb0-aad4-566afd725d1b"
    "/2d206a39-8ed7-437e-a3be-862e0f06eea3"
)
UA = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"
)


def botid_probe(origin):
    host = origin.split("//")[-1].strip("/")
    checks = {
        "basic": f"{PREFIX}/a-4-a/c.js?i=0&v=3&h={host}",
        "deep": f"{PREFIX}/p.js",
    }
    report = {}
    for label, path in checks.items():
        r = requests.get(origin + path, headers={"User-Agent": UA}, timeout=20)
        report[label] = {
            "status": r.status_code,
            "ctype": r.headers.get("content-type", ""),
            "bytes": len(r.content),
            "v_c_marker": r.text.startswith("window.V_C"),
        }
    report["verdict"] = (
        "deep-analysis" if report["deep"]["status"] == 200
        else "basic-only" if report["basic"]["status"] == 200
        else "no botid"
    )
    return report


if __name__ == "__main__":
    for target in sys.argv[1:]:
        print(target, botid_probe(target))

Two cheaper signals round this out. The page HTML of a protected app usually contains the literal prefix, because the client bootstrap references it, so a plain grep on the document body often answers the question before you make a second request. And an x-vercel-id response header tells you the app is on Vercel at all, which is the precondition for any of this. If you'd rather work through vendor identification systematically, the anti-bot identification guide covers the header, cookie and script-path triage for the whole field.

Three Vercel defences everyone calls bot protection

Half the confusion around BotID comes from people attributing a block to it when something else did the blocking. Vercel ships three separate things, and they behave nothing alike:

FeatureWhere it runsDefault state (Vercel docs, read 9 Oct 2026)What you observe
BotID Basic / Deep AnalysisClient challenge plus an explicit `checkBotId()` call in the app's handlerOff until a developer writes the codeWhatever that handler returns. No vendor artefact
Bot protection managed rulesetVercel's edge, via the WAF"inactive by default… In the dashboard this is labeled **Off**"A JavaScript challenge served at the edge
AI bots managed rulesetVercel's edge, via the WAF"inactive by default… In the dashboard this is labeled **Allow**"Log or deny, matched on known AI crawler identity

The managed rulesets are the ones that produce a recognisable interstitial. Vercel describes the bot protection ruleset as identifying "clients that violate browser-like behavior" and preventing "requests that falsely claim to be from a browser such as a curl request identifying as Chrome." It also "automatically excludes verified bots, such as Google's crawler, from evaluation."

One documented limitation is a genuinely useful signal. Vercel states: "Bot Protection doesn't work when a reverse proxy (e.g. Cloudflare, Azure, or other CDNs) is placed in front of your Vercel deployment", and explains why, listing "obscured detection signals" and "frequent re-challenges" as the failure modes. So if your target is a Vercel app sitting behind another CDN, the edge ruleset is almost certainly not what's stopping you, which pushes suspicion back onto BotID or the app's own logic.

There's also a bypass path on the defender's side worth knowing about, because it explains inconsistent results. Vercel documents that you can "add a bypass rule to the Vercel WAF to let through traffic that would have otherwise been detected as a bot by BotID." The checkBotId() return value carries a bypassed field for exactly this. If a site has whitelisted a partner's IP range or user agent, requests from inside it sail through while identical requests from elsewhere don't.

BotID guards routes, not sites

This is the single most actionable fact about BotID, and it falls straight out of how the client works.

The client module patches two things in the page: window.fetch, and XMLHttpRequest.prototype.open and .send. For each outgoing request it builds a regular expression from the configured path glob, then applies three conditions. The request must be same-origin. The pathname must match a registered pattern. The method must match the registered method, or the route must be registered with *. If any condition fails, the original unpatched function runs and nothing is attached:

// the shape a site author writes, from Vercel's get-started guide
import { initBotId } from 'botid/client/core';

initBotId({
  protect: [
    { path: '/api/checkout', method: 'POST' },
    { path: '/team/*/activate', method: 'POST' },
    { path: '/api/user/*', method: 'POST' },
  ],
});

Nothing outside that list is touched. And on the server side, checkBotId() is a function call inside one handler. A route where nobody called it isn't protected, whatever the dashboard says. Vercel's docs put the dependency the other way round too: "Not adding the protected route to will result in checkBotId() failing."

For data collection that means the interesting question isn't how to defeat BotID. It's which paths are in the list. In practice the list is short and skewed towards writes, because that's what the product is sold for and because Deep Analysis bills per call. The surfaces that usually stay open on the same application:

  • Server-rendered HTML and the Next.js _next/data JSON that backs client navigation
  • GET endpoints that back public listings, search and filters
  • sitemap.xml, RSS and any Open Graph or oEmbed endpoint
  • Any route whose method differs from the registered one, since the client matches on method

There's one structural gap documented by Vercel itself. The form-submissions page states that BotID "does not support traditional HTML forms that use the action and method attributes", because "native form submissions don't work with BotID due to how they are handled by the browser." A browser's native form POST can't carry the x-is-human header the client patch would have added. So a site with a plain

either has an endpoint nobody is checking, or a form that's broken for real users. Both are informative. The related trick of finding the JSON endpoint behind a rendered page is covered in scraping hidden JSON API endpoints.

What a BotID block looks like on the wire

Every other anti-bot gives you a signature to match. BotID gives you nothing, and scrapers built on string matching break quietly here.

There's no canonical status code and no canonical body, because Vercel doesn't send the response. The application does. In Vercel's own examples the handler returns 403 with {"error":"Access denied"} for an API route, and throws new Error('Access denied') inside a Server Action, which surfaces to the client as a server-error page or a rejected action rather than a clean 403. Those are the documented examples, not a measurement: I probed the challenge paths first-hand but did not run a bot verdict against any third-party production app, so treat the specific 403 shape as the default a developer copies rather than a guarantee.

What you can rely on, because Vercel states it plainly in the setup guide: "BotID actively runs JavaScript on page sessions and sends headers to the server. If you test with curl or visit a protected route directly, BotID will block you in production." A direct hit on a protected route from a stateless client is a guaranteed negative verdict. That's not a tuning problem you can soften with better headers.

Practical consequence: stop writing block detectors that look for a vendor. Write ones that assert the shape of a success. A payload that doesn't contain what you came for is a failure regardless of its status code:

from typing import Any


class NotTheRealPayload(Exception):
    pass


def require_rows(payload: Any, minimum: int = 1, keys=("sku", "price")):
    """Treat any response that isn't the expected shape as a block."""
    if not isinstance(payload, dict):
        raise NotTheRealPayload(f"expected object, got {type(payload).__name__}")
    rows = payload.get("rows")
    if not isinstance(rows, list) or len(rows) < minimum:
        raise NotTheRealPayload(f"rows missing or short: {rows!r}"[:200])
    missing = [k for k in keys if k not in rows[0]]
    if missing:
        raise NotTheRealPayload(f"row shape changed, absent: {missing}")
    return rows

Three outcomes to keep apart in your logs, because the remedy differs for each:

ObservationMost likely causeWhat to change
`403` or `500` on a `POST` to an app endpoint, but the page itself loads fineRoute is registered with BotID; your request never carried `x-is-human`Drive the request from inside the page, or find an unprotected equivalent
An edge interstitial naming a JavaScript check, before your handler runsVercel's bot protection managed ruleset, not BotIDA real browser; this is an edge check, not a route check
Inconsistent results from different exits for identical requestsIP reputation, or a WAF bypass rule you're outside ofExit quality and rotation discipline

The broader discipline of telling "blocked" from "changed" is in how to detect when your scraper is blocked.

Inside the botid package: x-is-human, KP_ cookies, is-bot

Reading the published package beats guessing, and botid is plain JavaScript on npm. Everything here comes from botid@1.5.11, published 3 March 2026, read 9 October 2026.

The client attaches exactly three headers to a matched request: x-is-human, which is JSON.stringify() of the solved challenge object; x-path, the request pathname; and x-method, the method. Nothing else.

The challenge object itself has two fields the client branches on, and they tell you how the escalation works. If b !== 0, the request goes straight out with no further work. Otherwise, if d === 1 or the route was registered with checkLevel: 'deepAnalysis', the client loads the KPSDK payload and only then re-fires the request. So d is the server's instruction to escalate from Basic to Deep Analysis, decided per challenge rather than per route. A site can be configured for Basic and still see Kasada's full payload load on a session the challenge service doesn't like.

The challenge is also refreshed on its own schedule. The client registers handlers for the online and visibilitychange window events; on either, when the document isn't hidden, it clears the resolved challenge, removes the existing c.js script element, increments a counter, and reloads the challenge with the new counter in the i query parameter. Tab-switching in an automated browser session invalidates what you were holding.

On the server, checkBotId() does four things worth knowing:

  • Pulls request headers from a Vercel request-context symbol, falling back to next/headers in development
  • Strips the authorization header, and reduces cookies to the KP_* set described earlier
  • POSTs that material to https://api.vercel.com/bot-protection/v1/is-bot?v=3, authenticating with an x-vercel-oidc-token
  • Applies any response headers the verification service asks for, through addHeaders, addValuesToHeaders and replaceHeaders instructions

Two return fields in the code aren't on the docs pages I read: classificationReason, present on every verdict, and responseHeaders, returned when the caller passes advancedOptions.returnResponseHeaders. Both are undocumented as of 9 October 2026. The full verdict object is { isHuman, isBot, isVerifiedBot, verifiedBotName, verifiedBotCategory, bypassed, classificationReason }.

There's a logging quirk that explains a lot of inconsistent field reports. When x-is-human is absent, the package logs a message naming the exact route it expected to be registered. Outside production it prints the config snippet the developer should add; in production it degrades to a single warning: "Possible misconfiguration of Vercel BotId or malicious request to ' '." Your unheadered request shows up in the target's logs as a misconfiguration warning, which is also why some teams discover their own BotID is half-wired only after someone scrapes them.

Local development is a separate universe. checkBotId() returns { isHuman: true, isBot: false, isVerifiedBot: false, bypassed: true } and prints "[Dev Only] Without setting the developmentOptions.bypass value, the bot protection will return HUMAN." The developmentOptions.bypass switch accepts 'HUMAN', 'BAD-BOT' or 'GOOD-BOT'. None of that works in production, and the package reads OVERRIDE_BOTID_SERVER_URL only for pointing the verification call elsewhere, which requires control of the deployment. There is no client-side equivalent.

Why you can't bypass Vercel BotID from a stateless client

Be honest about the ceiling, because it's lower than the siblings in this field and in a different place.

To produce a valid x-is-human you need the output of c.js executed in a DOM, in a document served from the target's origin, with the host matching. The host binding is not incidental: the challenge URL carries h=, and checkBotId() validates that requests come from the host that served the challenge. Vercel exposes an extraAllowedHosts option precisely so split frontend and backend domains keep working, and warns "Only add trusted domains to extraAllowedHosts."

Replay doesn't work either, by design. Vercel describes the client payload as "lightweight, obfuscated code into the requester's environment that evolves on every load and is designed to resist replay, tampering, and static analysis", and lists "mutating detection logic on every load to prevent reverse engineering." The challenge object is refreshed on visibility and connectivity events. A token captured in a browser session and pasted into a Python script is stale on arrival, and once Deep Analysis is in the loop the signals Kasada collected belong to that browser, not yours.

Here's where BotID differs from the edge products, and it's the practical insight: TLS and HTTP/2 fingerprinting carry much less weight here. The first gate isn't a handshake score computed on a connection you control, it's a JavaScript artefact you either have or don't. Matching a Chrome TLS fingerprint with curl_cffi buys you nothing against a route that wants x-is-human. The signals that do matter are the ones a browser emits once it's running, which is the same surface covered in headless browser detection. Spend your effort there, not on the handshake.

What's genuinely out of reach from a stateless client, stated plainly:

  • Minting a valid x-is-human without executing the target's own challenge script
  • Reusing a captured challenge across requests, sessions, or processes
  • Forcing a Basic verdict when the challenge service has set d === 1
  • Defeating Deep Analysis with header spoofing of any kind

What remains reachable is everything else in this guide: finding the unprotected surface, running a real browser on the real page, keeping exit reputation clean, or using the supported bot route.

The ladder, in the order that costs least

Work this top to bottom and stop at the first rung that gets you the data. Most jobs stop at rung two or three.

RungQuestion to answerCostStop here if
1Is BotID even present? Probe the prefix for `c.js` and `p.js`One request`404`: you're fighting something else entirely
2Is the path you want actually registered? Check the page bootstrap and try the plain `GET`A few requestsThe data is reachable without the protected route
3Is there an equivalent surface? Server-rendered HTML, `_next/data`, sitemap, RSS, a native formMinutes of readingAny of them carries the fields you need
4Can a real browser on the real page do the work, letting the app's own `fetch` carry the headers?A browser per sessionThe interaction is scriptable
5Is the exit the problem rather than the challenge? Clean residential IP, geo-matched, pacedProxy spendResults go consistent
6Should you be a verified bot, or just ask the site owner?Days to weeksYou need durable, sanctioned access

Rung three deserves more credit than it usually gets. BotID's billing model and its route-scoped design both push defenders towards narrow coverage, so the reasonable expectation is a protected list of a handful of write endpoints with the read surface left open. That's an inference from how the product is built and priced, not a survey, so check it on your own target. Checking it properly costs less than an afternoon and saves you a browser farm.

Rung four is the one people get wrong, so it gets its own section.

Driving the app's own fetch with the SparkProxy Scraping API

The mistake is to point a headless browser at the protected endpoint. That fails for the same reason curl fails: a direct navigation isn't a patched fetch call from inside the page, so no headers get attached.

Vercel spells out the correct approach in its own testing note: "To effectively test, make a fetch request from a page in your application to the protected route." That's the technique. Load the page that owns the interaction, let the page's patched fetch do the request, and read the result the app renders. You're not bypassing the check, you're satisfying it the way a user does.

In browser terms that's: navigate, wait for the challenge to resolve, click the thing, wait for the result to land in the DOM. Running that yourself means a browser pool, a residential exit per session, and keeping a Kasada-grade payload happy on every page load. SparkProxy's Scraping API runs the browser and the interaction server-side instead, at https://scrape.sparkproxy.io/api/v1 with an X-API-Key header:

curl -G "https://scrape.sparkproxy.io/api/v1" \
  -H "X-API-Key: $SPARKPROXY_KEY" \
  --data-urlencode "url=https://example.com/pricing" \
  --data-urlencode "render_js=true" \
  --data-urlencode "proxy_type=premium" \
  --data-urlencode "country_code=US" \
  --data-urlencode "json_response=true" \
  --data-urlencode 'js_scenario=[{"wait":1500},{"click":"button#load-prices"},{"wait_for":"#price-table tbody tr"},{"wait":500}]'

The same thing in Python, with the envelope handled correctly:

import base64
import json
import requests

API = "https://scrape.sparkproxy.io/api/v1"
KEY = "YOUR_API_KEY"

SCENARIO = [
    {"wait": 1500},                               # let c.js resolve before interacting
    {"click": "button#load-prices"},              # the app's own fetch fires here
    {"wait_for": "#price-table tbody tr"},        # hold until the result is in the DOM
    {"wait": 500},
]


def render_through_app(page_url, country="US"):
    r = requests.get(
        API,
        headers={"X-API-Key": KEY},
        params={
            "url": page_url,
            "render_js": "true",          # real browser; the challenge script executes
            "js_scenario": json.dumps(SCENARIO),
            "proxy_type": "premium",      # file-based premium pool, not a flagged subnet
            "country_code": country,      # geo-match the audience the app expects
            "json_response": "true",      # the ONLY switch that returns an envelope
        },
        timeout=180,
    )
    env = r.json()
    body = base64.b64decode(env.get("body") or "").decode("utf-8", "replace")
    return env.get("status_code"), env.get("credits_used"), body


if __name__ == "__main__":
    status, credits, html = render_through_app("https://example.com/pricing")
    print(status, credits, len(html))

Four parameter facts to get straight before you cost this out. render_js defaults to true and bills 5 credits; set render_js=false and the same request is a plain fetch at 1 credit, which is what you want for rung-three reconnaissance. js_scenario adds 5 credits, requires render_js=true, and caps at 50 steps. country_code adds 5. And format only chooses content conversion, one of html, md, mdx, json, screenshot or pdf, so format=json is not an envelope: json_response=true is the only switch that produces one, carrying job_id, status_code, duration_ms, credits_used and a body that arrives base64-encoded. Decode before any marker matching runs against it, or your block detector will match the base64, not the page. If you'd rather have fields than markup, extract_rules replaces the response with an extracted object, and it doesn't stack with json_response.

A BotID-shaped request is therefore 15 credits: 5 for the render, 5 for the scenario, 5 for the country. A new account opens with 1,000 free credits, which is about 66 of those, enough to prove the approach works on your target before you commit. Batch mode is cheaper still at one credit for the whole batch, but it requires render_js=false, so it belongs to the unprotected-surface work, not to this.

Underneath the API the routing is a plan rather than a meter. Entry pricing reads "Starting from $75/month" on sparkproxy.io, checked 9 October 2026 and quoted in USD, with unlimited bandwidth included, so the billing unit is the plan and its concurrency rather than traffic consumed. The pool is 1M+ premium IPs across 80+ countries, with random rotation or 5-minute auto-rotation, over HTTP, HTTPS and SOCKS5, with IP whitelist or user:pass auth on a 1 Gbps port. There's a 24-hour free trial with no card, and term discounts of 5% at three months, 10% at six and 15% at twelve. Whether to run this yourself or buy it is the usual trade, worked through in scraping API vs self-managed proxies.

Honest limitation: this approach returns what the application rendered, not the clean JSON the endpoint emitted. If the app writes results into the DOM, you're fine. If it holds them in client state and never paints them, you'll need a scenario step that triggers whatever does paint them, or you're back to rung three.

The supported route: become a verified bot

There's a sanctioned path, and for anyone operating a named crawler it's the right one.

botid@1.5.0, published 11 August 2025, added verified-bot handling. checkBotId() returns isVerifiedBot, verifiedBotName and verifiedBotCategory, and Vercel's docs show how an app uses them:

const { isBot, verifiedBotName, isVerifiedBot, verifiedBotCategory } = await checkBotId();
const isOperator = isVerifiedBot && verifiedBotName === "chatgpt-operator";

if (isBot && !isOperator) {
  return Response.json({ error: "Access denied" }, { status: 403 });
}

Read that example carefully, because it answers a question people get wrong: isBot and isVerifiedBot can both be true at once. Verification establishes who you are, not that you're allowed in. The app still decides.

Vercel maintains the directory at bots.fyi and invites submissions: "Submit a bot request if you are a SaaS provider and would like to be added to this list." The directory is long and includes names you'd expect alongside commercial crawlers and automation platforms, in categories like ai_crawler, ai_assistant, search_engine_crawler, monitor and search_engine_optimization.

The verification methods Vercel documents are worth knowing because they set the bar. IP address verification against ranges owned by the operator. Reverse DNS, where "an IP claiming to be Googlebot should resolve to .googlebot.com or .google.com". And cryptographic verification "through protocols like Web Bot Authentication, which employs HTTP Message Signatures (RFC 9421) to cryptographically verify automated requests."

That third one is the direction this is heading, and it has a consequence for proxy architecture. A signed-request scheme ties identity to a key you hold rather than to an IP, which decouples "who is this crawler" from "where did the packet come from". If you're building something long-lived against platforms that ship this kind of detection by default, signing your requests and registering the agent is a more durable investment than any fingerprint work. It also removes you from the adversarial loop entirely, which is the only permanent fix.

What's likely to change, and how to date your notes

BotID is new and the package moves, so anything here with a number on it should be re-verified rather than trusted.

From the npm registry on 9 October 2026: botid has 218 published versions. The first, 1.0.0, landed 3 June 2025, three weeks before the public announcement. The current stable tag is 1.5.11, published 3 March 2026. The newest publication of any kind is a prerelease, 0.0.0-f3ef33f2-20260416071625, from 16 April 2026, and the registry's modified timestamp is 18 May 2026. So: actively maintained, with a prerelease channel still moving, and a stable channel that has been quiet for roughly seven months. Nothing here suggests abandonment.

Feature gates that matter if you're reading a target's bundle to work out what it can do:

Versionnpm publish dateWhat it added
`1.0.0`3 June 2025First public release, three weeks before the GA announcement
`1.4.5`25 July 2025`advancedOptions.checkLevel`, per-route Basic vs Deep Analysis
`1.5.0`11 August 2025Verified-bot fields: `isVerifiedBot`, `verifiedBotName`, `verifiedBotCategory`
`1.5.11`3 March 2026Current stable tag as of 9 October 2026

The specific things most likely to rot, in rough order of likelihood:

The UUID prefix is a hard-coded constant in the package today, which means a single release can change it and every fingerprint check built on it goes dark at once. Treat it as a cached value with an expiry, not a law. The v=3 version parameter appears in both the challenge URL and the is-bot call, so an increment there is a protocol change worth noticing. classificationReason and returnResponseHeaders are undocumented, so they may be renamed or removed without a changelog entry. And the pricing in this guide is a table on a docs page, which is the kind of thing that moves without announcement.

The workflow that survives all of that: date every observation, re-pull the package when a check stops matching, and diff the client module rather than guessing from behaviour. The package is 13 KB packed. Reading it takes ten minutes and beats a week of inference.

Frequently asked questions

FAQ

No. BotID only applies to paths a developer registered with initBotId() on the client and guarded with checkBotId() in the handler, and the client patch only matches same-origin requests whose path and method both match. Server-rendered pages, sitemaps and unregistered GET endpoints on the same app are untouched by it.

BotID's Deep Analysis mode is "powered by Kasada" in Vercel's own words, and the botid package loads Kasada's KPSDK payload and forwards only KP_* cookies for verification. The Basic mode is Vercel's own integrity check on the challenge response. So the hard detection is Kasada's, delivered through Vercel's rewrites and Vercel's billing.

Whatever the application chose. BotID returns a boolean to the handler and the developer writes the response, so there's no vendor status code or block page. Vercel's documented examples return 403 with {"error":"Access denied"} from an API route, or throw inside a Server Action, which surfaces as a server error instead.

No. The challenge is per page load, bound to the serving host, and refreshed on visibility and connectivity events, and Vercel describes the payload as evolving on every load specifically to resist replay. A header captured in a browser is stale by the time a separate process sends it.

Request the first-party prefix on the target's origin. A 200 on //a-4-a/c.js means BotID is installed; a 200 on //p.js means the Kasada Deep Analysis payload is being served too. Escalation can also happen per challenge, so a Basic-configured route may still load the deep payload on a session the challenge service distrusts.

They help with the part of the problem that's about where your traffic comes from, not with the challenge itself. No exit IP will produce a valid x-is-human header. Clean, geo-matched residential exits matter once you're already running a real browser on the real page, because reputation and geography still feed the classifier.

Special Discount · 20% off

Get 20% off your first month

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Save up to 15% more on quarterly, half-yearly and yearly plans

Claim Discount

About the Author

This guide was written by the SparkProxy Technical Team. We build and operate SparkProxy's datacenter and residential proxy networks and the SparkProxy Scraping API, and we read other people's bot-management stacks so our customers don't have to. The BotID specifics here were taken from Vercel's own documentation and from botid@1.5.11 on npm, with the challenge-path behaviour probed first-hand on 9 October 2026 against vercel.com, v0.app and a non-BotID Vercel property as a control.

Citations: Vercel, BotID documentation · Vercel, Get Started with BotID · Vercel, Advanced BotID Configuration · Vercel, Handling Verified Bots · Vercel, Form Submissions · Vercel, Local Development Behavior · Vercel, Bot Management · Vercel blog, Introducing BotID (25 June 2025) · Vercel changelog, BotID generally available (25 June 2025) · Kasada · npm, botid package · SparkProxy Scraping API documentation

Keep reading

Related articles

How to Bypass Fastly Next-Gen WAF

Bypass Fastly Next-Gen WAF (Signal Sciences) the right way: how its agent decides per signal at the edge, the 406 block, and why clean IPs beat header tricks.

SparkProxy·Guides