Skip to content

Your first unsurf

We will deploy unsurf to your Cloudflare account and use the scout tool to capture every API endpoint on a public website. By the end, you will have a complete OpenAPI spec generated automatically.

  1. Clone and install

    Terminal window
    git clone https://github.com/acoyfellow/unsurf
    cd unsurf
    bun install
  2. Set your Alchemy password

    Terminal window
    echo 'ALCHEMY_PASSWORD=your-secure-password' > .env
  3. Generate the database migration

    Terminal window
    bunx drizzle-kit generate
  4. Deploy to Cloudflare

    Terminal window
    bun run deploy

    Alchemy will create your D1 database, R2 bucket, and Browser Rendering binding automatically. You will see a URL in the output — that is your unsurf instance.

Now we will capture the API behind JSONPlaceholder, a free public API.

  1. Call the scout tool

    Terminal window
    curl -X POST https://your-unsurf-url.workers.dev/tools/scout \
    -H "Content-Type: application/json" \
    -d '{
    "url": "https://jsonplaceholder.typicode.com",
    "task": "discover all API endpoints"
    }'
  2. Read the result

    The response contains:

    • siteId — the unique identifier for this site
    • endpointCount — how many endpoints were captured
    • pathId — a replayable path
    • openApiSpec — a complete OpenAPI 3.1 specification
  3. View captured endpoints

    Terminal window
    curl https://your-unsurf-url.workers.dev/sites/{siteId}/endpoints

    You will see every endpoint that was called during the scout — GET /posts, GET /posts/:id, GET /users, and more — each with inferred request and response schemas.

unsurf launched a headless browser on Cloudflare’s edge, navigated to the site, and captured every XHR/fetch request. For each unique endpoint, it:

  1. Normalized the URL pattern (/posts/1/posts/:id)
  2. Inferred a JSON Schema from the response body
  3. Saved everything to D1 (via Drizzle) and R2
  4. Generated an OpenAPI 3.1 spec from all captured endpoints

You now have a typed API definition for a site that may have no public documentation.

Here is how a captured endpoint is represented internally:

Captured endpoint schema View source →
import { Schema } from "effect";

const HttpMethod = Schema.Literal("GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS");

export class CapturedEndpoint extends Schema.Class<CapturedEndpoint>("CapturedEndpoint")({
	id: Schema.String,
	siteId: Schema.String,
	method: HttpMethod,
	pathPattern: Schema.String,
	requestSchema: Schema.optionalWith(Schema.Unknown, { as: "Option" }),
	responseSchema: Schema.optionalWith(Schema.Unknown, { as: "Option" }),
	sampleCount: Schema.Number,
	firstSeenAt: Schema.String,
	lastSeenAt: Schema.String,
}) {}