SurSolver

BlogShape Security with Playwright: Replay the Generated Request Headers

Shape Security with Playwright: Replay the Generated Request Headers

· Updated · 7 min read

Shape Security does not put a widget on the page. It stamps the requests your client sends to protected endpoints with generated headers, and it rejects the calls that arrive without them. That makes the integration different from a form-field captcha at every step: the solve returns a JSON map of header names and values, and your job is to generate it for the exact endpoint you are about to call, replay that request with the matching browser identity, and check the response the site only gives when the headers were accepted.

What a Shape task returns

The catalog lists the task as ShapeCookie or ShapeCookieProxyLess — a leftover of the naming, not a description of the result. What comes back is:

  • token: a JSON string whose parsed object maps header names to values. It is not a single header value; sending the whole JSON under a header named token is the most common way to fail this integration.
  • cookies: when present, the session jar as newline-separated JSON objects with host, path, secure and http_only fields.
  • userAgent and acceptLanguage: the browser identity the headers were generated for. They belong to the same request as the headers.

No caller proxy is involved: a Shape task neither requires nor uses one.

Where each input comes from

Field Required Source
websiteURL Yes The page that loads the protection — the page whose action you want to repeat, not the endpoint itself.
scriptUrl Yes The single Shape loader script that page loads, exactly as the page includes it — full URL, query string included. Copy it from DevTools → Network or the page's own <script src>; a loader URL from a different host describes a different integration, and a loader stripped of its query may never boot at all (see the failure table).
targetUrl Yes The protected endpoint the page calls — the login API, the checkout call, whatever request actually gets rejected without the headers.
method No The HTTP method of that endpoint. The default is POST; declare GET, PUT, PATCH or DELETE when the real request uses them.
title No The document title of the initiating page.

The split between websiteURL and targetUrl is the whole game: generate for the page and you get headers for the wrong request. If you are still locating the loader and the endpoint, identifying a protection and extracting its parameters walks the DevTools pass step by step.

Generate, then replay the protected request

The generated script does both halves. It first creates the task — no page needs to open for that — then parses the header map, attaches the returned userAgent and acceptLanguage, loads any returned cookies into a Playwright context, and re-sends the protected request through that context's request client so the identity matches the headers. It checks the response for SUCCESS_TEXT, a marker that appears only in a response the site accepted.

shape-playwright.js
// shape-playwright.js - solve Shape Security and send the protected request with the returned
// headers.
// Install: npm install playwright && npx playwright install chromium
// Run: node shape-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 loads the Shape script.
const TARGET_URL = 'https://example.com/page';
// The Shape loader script of the page (DevTools, Network, the script before the API call).
const SCRIPT_URL = 'https://example.com/challenge.js';
// The protected endpoint the page calls (DevTools, Network).
const API_URL = 'https://example.com/api/endpoint';
// The HTTP method of that endpoint (GET, POST, PUT, PATCH or DELETE); defaults to POST.
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 !== ''),
  );
}

// The jar holds one JSON cookie per line; Playwright needs a domain and a path on each.
function jarToCookies(jar) {
  return jar
    .split('\n')
    .filter(Boolean)
    .map((line) => JSON.parse(line))
    .map((cookie) => ({
      name: cookie.name,
      value: cookie.value,
      domain: cookie.host_only ? cookie.host : `.${cookie.host.replace(/^\./, '')}`,
      path: cookie.path,
      secure: cookie.secure,
      httpOnly: cookie.http_only,
      ...(cookie.persistent && cookie.expires_ms ? { expires: cookie.expires_ms / 1000 } : {}),
    }));
}

async function main() {
  const browser = await chromium.launch();
  try {
    const solution = await solve({
      type: 'ShapeCookieProxyLess',
      websiteURL: TARGET_URL,
      scriptUrl: SCRIPT_URL,
      targetUrl: API_URL,
      ...withoutEmpty({
        method: API_METHOD,
      }),
    });

    // `token` is a JSON map of header names to values; send them exactly as returned.
    const headers = {
      ...(API_HEADERS ?? {}),
      ...JSON.parse(solution.token),
      'User-Agent': solution.userAgent,
      'Accept-Language': solution.acceptLanguage,
    };
    // Replay with the browser identity the solve presented.
    const context = await browser.newContext({ userAgent: solution.userAgent });
    if (solution.cookies) await context.addCookies(jarToCookies(solution.cookies));
    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;
});

Reading the flow out of the script:

  • API_URL, API_METHOD and API_BODY describe the protected request your app sends. API_BODY is required for POST, PUT and PATCH calls with a JSON payload; the script sets Content-Type: application/json only when a body is present.
  • Every header from the parsed token is sent exactly as returned, by its returned name. Do not rename them and do not hardcode a header family copied from another integration.
  • Some endpoints require their own app headers on top of the generated set. The script accepts them through an optional API_HEADERS constant (a JSON object of header names and values); it merges them under the solved map, so the generated headers keep precedence.
  • The replay runs in a browser context created with solution.userAgent, so a fingerprint-shaped header set is not bolted onto a conflicting identity.
  • Success is the response body check, not HTTP 200. A 200 page can still be a soft block.
  • The negative control is worth running once: send the protected request without the generated headers and watch what the endpoint does. On a live Shape-protected endpoint that request is rejected with HTTP 429 and an empty body, and the header map with the matching User-Agent and Accept-Language is what turns it into a normal response. If your endpoint accepts headerless requests too, you are not testing the protection.

Keep one solve per request session

The header set, the cookies and the browser identity are one package from one solve. Mixing a token from an earlier run with fresh cookies — or replaying with your own user agent — sends values that were never generated together. Regenerate for each protected request the same way the page would, and keep the endpoint, method and body identical between the generate call and the replay; a mismatch there produces headers for a request you never send.

Node.js

node shape-node.js — Node 18 or newer, no packages — runs both halves without a browser: it parses the returned token JSON into the request headers, adds the solve's userAgent and acceptLanguage, attaches the returned jar as one Cookie header, and sends the protected request from fetch.

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

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

// Your SurSolver API key.
const CLIENT_KEY = 'YOUR_API_KEY';
// The page that loads the Shape script.
const TARGET_URL = 'https://example.com/page';
// The Shape loader script of the page (DevTools, Network, the script before the API call).
const SCRIPT_URL = 'https://example.com/challenge.js';
// The protected endpoint the page calls (DevTools, Network).
const API_URL = 'https://example.com/api/endpoint';
// The HTTP method of that endpoint (GET, POST, PUT, PATCH or DELETE); defaults to POST.
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 !== ''),
  );
}

// The jar cookies that apply to this URL, as one Cookie header.
function cookieHeader(jar, url) {
  const { hostname, pathname } = new URL(url);
  return jar
    .split('\n')
    .filter(Boolean)
    .map((line) => JSON.parse(line))
    .filter((cookie) => {
      const host = cookie.host.replace(/^\./, '');
      const hostMatches = cookie.host_only
        ? hostname === host
        : hostname === host || hostname.endsWith(`.${host}`);
      return hostMatches && pathname.startsWith(cookie.path);
    })
    .map((cookie) => `${cookie.name}=${cookie.value}`)
    .join('; ');
}

async function main() {
  const solution = await solve({
    type: 'ShapeCookieProxyLess',
    websiteURL: TARGET_URL,
    scriptUrl: SCRIPT_URL,
    targetUrl: API_URL,
    ...withoutEmpty({
      method: API_METHOD,
    }),
  });

  // `token` is a JSON map of header names to values; send them exactly as returned.
  const headers = {
    ...(API_HEADERS ?? {}),
    ...JSON.parse(solution.token),
    'User-Agent': solution.userAgent,
    'Accept-Language': solution.acceptLanguage,
  };
  if (solution.cookies) headers.Cookie = cookieHeader(solution.cookies, API_URL);
  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 shape-python.py carry the parsed header map, the identity and the jar on one requests call to the protected endpoint, judged by the success text.

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

import json
import time

import requests


# Your SurSolver API key.
CLIENT_KEY = "YOUR_API_KEY"
# The page that loads the Shape script.
TARGET_URL = "https://example.com/page"
# The Shape loader script of the page (DevTools, Network, the script before the API call).
SCRIPT_URL = "https://example.com/challenge.js"
# The protected endpoint the page calls (DevTools, Network).
API_URL = "https://example.com/api/endpoint"
# The HTTP method of that endpoint (GET, POST, PUT, PATCH or DELETE); defaults to POST.
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 without_empty(fields):
    # Optional task fields are sent only when they have a value.
    return {name: value for name, value in fields.items() if value not in (None, "")}


def cookie_jar(lines):
    # The solver returns one JSON cookie per line; keep each cookie's domain and path.
    jar = requests.cookies.RequestsCookieJar()
    for line in lines.splitlines():
        if not line.strip():
            continue
        cookie = json.loads(line)
        host = cookie["host"].lstrip(".")
        domain = host if cookie["host_only"] else f".{host}"
        jar.set(cookie["name"], cookie["value"], domain=domain, path=cookie["path"])
    return jar


def main():
    solution = solve({
        "type": "ShapeCookieProxyLess",
        "websiteURL": TARGET_URL,
        "scriptUrl": SCRIPT_URL,
        "targetUrl": API_URL,
        **without_empty({
            "method": API_METHOD,
        }),
    })

    # `token` is a JSON map of header names to values; send them exactly as returned.
    headers = {
        **(API_HEADERS or {}),
        **json.loads(solution["token"]),
        "User-Agent": solution["userAgent"],
        "Accept-Language": solution["acceptLanguage"],
    }
    cookies = cookie_jar(solution["cookies"]) if solution.get("cookies") else 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 it fails

Symptom Likely cause What to do
The endpoint rejects the request although the solve was ready The whole token JSON was sent as one header, or the headers were renamed. Parse token and send every name/value pair as returned, then compare the outgoing header names with the parsed keys.
The site did not accept the headers (HTTP ...) in the script targetUrl, method or body do not match the request the page really sends. Capture the real request once, copy its URL, method and body into API_URL, API_METHOD, API_BODY, and regenerate for that exact tuple.
HTTP 200 but SUCCESS_TEXT never appears The response is a soft-block page, or the marker text is wrong. Dump the response body and pick a string that exists only in the accepted case — a user-specific field, not a generic heading.
The replay works with the headers but fails later in the session The solve's cookies were dropped, or values from two solves were mixed. Load the returned jar into the same context, and use one solution's token, cookies, userAgent and acceptLanguage together.
The task polls to the deadline and fails solver-side (ERROR_FAILED), although the page itself loads The loader URL was sent without its query string. A loader that only bootstraps with a query such as ?async never starts, so nothing is stamped and the solve has nothing to work with. Copy the loader URL exactly as the page's <script src> includes it — query included — and prefer a page whose loader really boots when you test.
createTask answers errorId: 1 A stale or foreign scriptUrl, or a method that does not exist in the contract. Recopy the loader URL from the page's own source; the accepted methods are GET, POST, PUT, PATCH and DELETE.
Headers accepted once, then rejections resume The protected action consumed the session, as it does in the real app. Regenerate per protected request instead of caching one header set.

Where this fits

The Shape solver guide keeps recognition and the field contract on one page, and the Shape API card carries the exact request and solution shapes; all runnable variants are generated from one source via the examples index. If your target instead protects requests with fixed x-kpsdk-... headers, that is a different product: see the Kasada solver guide and the Kasada Playwright integration. For diagnosing a rejection that is not header-shaped, stop getting blocked separates the usual causes; browser headers and user agents explains which identity values have to match.

More articles