SurSolver

BlogKasada with Playwright: request headers end to end

Kasada with Playwright: request headers end to end

· Updated · 6 min read

A Kasada-protected site rarely shows anything to solve: the check rides on the browser signals of the requests themselves, and a blocked integration usually sees an error status and a block page instead of content. This post assembles the complete Playwright flow for it: capture the page's p.js loader, create the task, replay the returned x-kpsdk-* headers on the protected request, and get the real response the page normally receives.

What the solve returns

A Kasada result is a set of request headers, not a cookie:

Solution key Goes to
token x-kpsdk-ct
kpsdkCd x-kpsdk-cd
kpsdkH (when returned) x-kpsdk-h
kpsdkV (when returned) x-kpsdk-v
userAgent User-Agent
acceptLanguage Accept-Language

The kpsdkCd challenge response is single-use: it belongs to one protected request. Retries and parallel calls each need a fresh solve. Storing token as a cookie is the classic mix-up with cookie-returning tasks — the protected request is judged on its headers.

No proxy is involved: KasadaToken and KasadaTokenProxyLess work without caller proxy details.

Collect the loader and the protected request

Two inputs describe the integration, and both come from the same page:

  • websiteURL — the page that initiates the protected action (the login page, the search page), not the API endpoint itself;
  • scriptUrl — the complete p.js loader URL that page loads, copied from the page source or the Network panel. This type takes a single scriptUrl string.

If the URL you open answers with the Kasada block page, that page loads Kasada's own ips.js instead of the site's p.js. Copy the p.js URL that sits on the same path and paste it into the example's SCRIPT_URL constant — the override exists for exactly that.

The remaining inputs are yours to choose deliberately: API_URL, the protected request the page makes after Kasada ran — the exact endpoint, its method and, for writes, its body — plus API_HEADERS for any application-specific headers the endpoint requires beyond the x-kpsdk-* set (a JSON object, for example {"x-app-id":"web"}). Decide the acceptance criterion too, as SUCCESS_TEXT: a string that appears only in a response the site accepted.

The Playwright flow

kasada-playwright.js
// kasada-playwright.js - solve Kasada and send the protected request with the returned headers.
// Install: npm install playwright && npx playwright install chromium
// Run: node kasada-playwright.js

const { chromium } = require('playwright');
const { setTimeout: sleep } = require('node:timers/promises');

// Your SurSolver API key.
const CLIENT_KEY = 'YOUR_API_KEY';
// The page that starts the protected action.
const TARGET_URL = 'https://example.com/page';
// Optional, read from the page when empty: the p.js request of the page that starts the
// protected action.
const SCRIPT_URL = '';
// The protected request the page makes after Kasada ran.
const API_URL = 'https://example.com/api/endpoint';
// Its HTTP method; GET when unset.
const API_METHOD = '';
// Its JSON body for POST, PUT and PATCH requests.
const API_BODY = '';
// Other headers the protected request needs, as a JSON object, such as {"x-app-id":"web"}.
const API_HEADERS = undefined;
// Text that appears only in a response the site accepted.
const SUCCESS_TEXT = 'PASTE_ACCEPTED_MARKER';

const API = 'https://api.sursolver.com';
const POLL_ATTEMPTS = 60;

// API errors are HTTP 200 with errorId 1; anything else is a transport failure.
async function call(path, body) {
  const res = await fetch(`${API}${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = await res.json();
  if (data.errorId !== 0) throw new Error(`${data.errorCode}: ${data.errorDescription}`);
  return data;
}

async function solve(task) {
  const created = await call('/createTask', { clientKey: CLIENT_KEY, task });

  for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
    const result = await call('/getTaskResult', {
      clientKey: CLIENT_KEY,
      taskId: created.taskId,
    });
    if (result.status === 'ready') return result.solution;
    await sleep(3000);
  }
  throw new Error(`task ${created.taskId} was not ready in ${POLL_ATTEMPTS} polls`);
}

// Optional task fields are sent only when they have a value.
function withoutEmpty(fields) {
  return Object.fromEntries(
    Object.entries(fields).filter(([, value]) => value !== undefined && value !== ''),
  );
}

// A value the page did not show must be pasted into its constant at the top.
function need(name, value) {
  if (value === undefined || value === null || value === '') {
    throw new Error(`Could not read ${name} from the page; fill in the ${name} constant.`);
  }
  return value;
}

// Opens the page once and reads what the solve needs from it.
async function readPage(browser) {
  const page = await browser.newPage();
  const requests = [];
  page.on('request', (request) => requests.push(request));
  try {
    await page.goto(TARGET_URL, {
      waitUntil: 'domcontentloaded',
      timeout: 45000,
    });
    // Requests made after load count too; wait for them, but never hang on a busy page.
    await page.waitForLoadState('networkidle', { timeout: 5000 })
      .catch(() => undefined);
    const urls = (matches) =>
      [...new Set(requests.filter(matches).map((request) => request.url()))];
    return {
      pageUrl: page.url(),
      scriptUrl: urls((request) => /\/p\.js(\?|$)/.test(request.url()))[0],
    };
  } finally {
    await page.close();
  }
}

async function main() {
  const browser = await chromium.launch();
  try {
    const found = await readPage(browser);
    const solution = await solve({
      type: 'KasadaTokenProxyLess',
      websiteURL: found.pageUrl,
      scriptUrl: need('scriptUrl', SCRIPT_URL || found.scriptUrl),
    });

    const headers = {
      ...(API_HEADERS ?? {}),
      'x-kpsdk-ct': solution.token,
      'x-kpsdk-cd': solution.kpsdkCd,
      'User-Agent': solution.userAgent,
      'Accept-Language': solution.acceptLanguage,
    };
    // The challenge returns these two only when the site uses them.
    if (solution.kpsdkH) headers['x-kpsdk-h'] = solution.kpsdkH;
    if (solution.kpsdkV) headers['x-kpsdk-v'] = solution.kpsdkV;
    // Replay with the browser identity the solve presented.
    const context = await browser.newContext({ userAgent: solution.userAgent });
    if (API_BODY) headers['Content-Type'] = 'application/json';
    const response = await context.request.fetch(API_URL, {
      method: API_METHOD || 'GET',
      headers,
      ...(API_BODY ? { data: API_BODY } : {}),
    });
    const text = await response.text();
    if (!response.ok() || !text.includes(SUCCESS_TEXT)) {
      throw new Error(`The site did not accept the headers (HTTP ${response.status()}).`);
    }
    console.log('The site accepted the headers.');
  } finally {
    await browser.close();
  }
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Step by step:

  1. The initiating page is opened and the script records its requests, waiting for the DOM first and never hanging on a busy page. The p.js URL is taken from what the page actually loaded; SCRIPT_URL overrides the capture.
  2. A KasadaTokenProxyLess task is created with the page URL and the loader URL.
  3. Polling continues every three seconds until the status is ready. A terminal ERROR_FAILED or ERROR_TASK_TIMEOUT fails loudly instead of falling through to the request.
  4. The request headers are assembled in order: API_HEADERS first, then x-kpsdk-ct, x-kpsdk-cd, User-Agent and Accept-Language from the solution, plus x-kpsdk-h and x-kpsdk-v when the solve returned them — the challenge provides those two only when the site uses them. A JSON body adds Content-Type.
  5. The protected request is sent with Playwright's request client (context.request.fetch) and the response must be OK and contain SUCCESS_TEXT. A 200 with a soft-deny body is a rejection, which is why the check reads the body and not just the status.

Replay with a request client, not a page navigation

Kasada judges the client that presents the headers, and it rejects a replay that navigates a browser page to the protected URL. The headers must be presented by an HTTP request client: Playwright's context.request, or any plain HTTP client carrying the same headers. Every generated script for this task replays that way, regardless of framework.

The headers describe one client identity. User-Agent and Accept-Language travel with the x-kpsdk-* values because they belong to the browser the solve presented; a client that silently rewrites its user agent is a different client as far as the site can tell.

Some protected endpoints also require their own application headers — an app id, a CSRF token, whatever the page itself sends on that request. Collect them from the page's real request and pass them as API_HEADERS; they merge in before the solve's values, so x-kpsdk-ct, x-kpsdk-cd, User-Agent and Accept-Language always win.

Node.js

node kasada-node.js (Node 18 or newer, no packages) replays with fetch: the p.js URL, the protected request and the success text sit in constants at the top, the solve's x-kpsdk-* headers go out with its User-Agent and Accept-Language, and API_HEADERS merges in first.

kasada-node.js
// kasada-node.js - solve Kasada and send the protected request with the returned headers.
// Install: Node.js 18 or newer (no packages).
// Run: node kasada-node.js

const { setTimeout: sleep } = require('node:timers/promises');

// Your SurSolver API key.
const CLIENT_KEY = 'YOUR_API_KEY';
// The page that starts the protected action.
const TARGET_URL = 'https://example.com/page';
// The p.js request of the page that starts the protected action.
const SCRIPT_URL = 'https://example.com/challenge.js';
// The protected request the page makes after Kasada ran.
const API_URL = 'https://example.com/api/endpoint';
// Its HTTP method; GET when unset.
const API_METHOD = '';
// Its JSON body for POST, PUT and PATCH requests.
const API_BODY = '';
// Other headers the protected request needs, as a JSON object, such as {"x-app-id":"web"}.
const API_HEADERS = undefined;
// Text that appears only in a response the site accepted.
const SUCCESS_TEXT = 'PASTE_ACCEPTED_MARKER';

const API = 'https://api.sursolver.com';
const POLL_ATTEMPTS = 60;

// API errors are HTTP 200 with errorId 1; anything else is a transport failure.
async function call(path, body) {
  const res = await fetch(`${API}${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = await res.json();
  if (data.errorId !== 0) throw new Error(`${data.errorCode}: ${data.errorDescription}`);
  return data;
}

async function solve(task) {
  const created = await call('/createTask', { clientKey: CLIENT_KEY, task });

  for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
    const result = await call('/getTaskResult', {
      clientKey: CLIENT_KEY,
      taskId: created.taskId,
    });
    if (result.status === 'ready') return result.solution;
    await sleep(3000);
  }
  throw new Error(`task ${created.taskId} was not ready in ${POLL_ATTEMPTS} polls`);
}

// Optional task fields are sent only when they have a value.
function withoutEmpty(fields) {
  return Object.fromEntries(
    Object.entries(fields).filter(([, value]) => value !== undefined && value !== ''),
  );
}

async function main() {
  const solution = await solve({
    type: 'KasadaTokenProxyLess',
    websiteURL: TARGET_URL,
    scriptUrl: SCRIPT_URL,
  });

  const headers = {
    ...(API_HEADERS ?? {}),
    'x-kpsdk-ct': solution.token,
    'x-kpsdk-cd': solution.kpsdkCd,
    'User-Agent': solution.userAgent,
    'Accept-Language': solution.acceptLanguage,
  };
  // The challenge returns these two only when the site uses them.
  if (solution.kpsdkH) headers['x-kpsdk-h'] = solution.kpsdkH;
  if (solution.kpsdkV) headers['x-kpsdk-v'] = solution.kpsdkV;
  if (API_BODY) headers['Content-Type'] = 'application/json';
  const response = await fetch(API_URL, {
    method: API_METHOD || 'GET',
    headers,
    ...(API_BODY ? { body: API_BODY } : {}),
  });
  const text = await response.text();
  if (!response.ok || !text.includes(SUCCESS_TEXT)) {
    throw new Error(`The site did not accept the result (HTTP ${response.status}).`);
  }
  console.log('The site accepted the result.');
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Python

pip install requests and python kasada-python.py send the same header set on a plain request — one request per solve, since kpsdkCd is single-use.

kasada-python.py
# kasada-python.py - solve Kasada and send the protected request with the returned headers.
# Install: pip install requests
# Run: python kasada-python.py

import time

import requests


# Your SurSolver API key.
CLIENT_KEY = "YOUR_API_KEY"
# The page that starts the protected action.
TARGET_URL = "https://example.com/page"
# The p.js request of the page that starts the protected action.
SCRIPT_URL = "https://example.com/challenge.js"
# The protected request the page makes after Kasada ran.
API_URL = "https://example.com/api/endpoint"
# Its HTTP method; GET when unset.
API_METHOD = None
# Its JSON body for POST, PUT and PATCH requests.
API_BODY = None
# Other headers the protected request needs, as a JSON object, such as {"x-app-id":"web"}.
API_HEADERS = None
# Text that appears only in a response the site accepted.
SUCCESS_TEXT = "PASTE_ACCEPTED_MARKER"


API = "https://api.sursolver.com"
POLL_ATTEMPTS = 60


def call(path, body):
    # API errors are HTTP 200 with errorId 1; anything else is a transport failure.
    response = requests.post(f"{API}{path}", json=body, timeout=30)
    response.raise_for_status()
    data = response.json()
    if data["errorId"] != 0:
        raise RuntimeError(f'{data["errorCode"]}: {data["errorDescription"]}')
    return data


def solve(task):
    created = call("/createTask", {"clientKey": CLIENT_KEY, "task": task})
    for _ in range(POLL_ATTEMPTS):
        result = call("/getTaskResult", {"clientKey": CLIENT_KEY, "taskId": created["taskId"]})
        if result["status"] == "ready":
            return result["solution"]
        time.sleep(3)
    raise TimeoutError(f'task {created["taskId"]} was not ready in {POLL_ATTEMPTS} polls')


def main():
    solution = solve({
        "type": "KasadaTokenProxyLess",
        "websiteURL": TARGET_URL,
        "scriptUrl": SCRIPT_URL,
    })

    headers = {
        **(API_HEADERS or {}),
        "x-kpsdk-ct": solution["token"],
        "x-kpsdk-cd": solution["kpsdkCd"],
        "User-Agent": solution["userAgent"],
        "Accept-Language": solution["acceptLanguage"],
    }
    # The challenge returns these two only when the site uses them.
    if solution.get("kpsdkH"):
        headers["x-kpsdk-h"] = solution["kpsdkH"]
    if solution.get("kpsdkV"):
        headers["x-kpsdk-v"] = solution["kpsdkV"]
    cookies = None
    if API_BODY:
        headers["Content-Type"] = "application/json"
    response = requests.request(
        API_METHOD or "GET",
        API_URL,
        headers=headers,
        data=API_BODY,
        cookies=cookies,
        timeout=30,
    )
    if not response.ok or SUCCESS_TEXT not in response.text:
        raise SystemExit(f"The site did not accept the result (HTTP {response.status_code}).")
    print("The site accepted the result.")


if __name__ == "__main__":
    main()

When the flow fails

Symptom Likely cause What to check
Could not read scriptUrl from the page; set SCRIPT_URL. The opened URL answered with the block page, which loads ips.js, not the site's p.js Copy the p.js URL on the same path and set SCRIPT_URL
The replay is refused although the task was ready The replay navigated a browser page, or x-kpsdk-cd was reused Send the headers with the request client; one request per solve
The endpoint refuses the request although the headers are fresh Missing application-specific headers Compare the page's real request with yours; pass the difference as API_HEADERS
The endpoint answers OK but with a denial body The wrong API_URL — an endpoint the page's Kasada integration does not guard — or a stale request shape Capture the exact request the page makes after Kasada ran, including method and body
token stored as a cookie, request still refused The result applied with the wrong mechanism Kasada reads request headers: send x-kpsdk-ct and x-kpsdk-cd as headers
ERROR_TASK_TIMEOUT or ERROR_FAILED A final solve-side failure Submit a new task; Errors lists the codes

The Kasada solver guide documents recognition and parameter sources, and the Kasada API reference shows the exact request and solution fields. When the headers are fresh and the replay is still refused, check the client too: Headers, User-Agent and TLS shows how to keep userAgent and acceptLanguage consistent with the client that sends them. If your capture instead contains a rotating, JSON-shaped set of request headers, compare it with the Shape Security guide before choosing a task type.

More articles