Skip to content
Botscent

Server API (TypeScript)

Every function, type and adapter of the TypeScript server half, with its options.

The server half reads the headers of one request. It runs on Node.js 22.12 or later, Cloudflare Workers, Vercel, Deno and Bun. The functions come from botscent/server. Each adapter runs the server half inside one framework. For the Python server half, see Server API (Python).

inspect

Returns the request's own verdict. Reads headers only, and makes no network request.

Signature
function inspect(request: RequestLike, options?: InspectOptions): Promise<Verdict>

type RequestLike =
  | Request
  | {
      headers: Headers | { get(name: string): string | null } | Record<string, string | string[] | undefined>
      method?: string
      url?: string
    }

type InspectOptions = {
  cf?: CloudflareHints | null
  now?: number
  debug?: boolean | ((line: string) => void)
}

type CloudflareHints = {
  verifiedBotCategory?: string | null
  botManagement?: { verifiedBot?: boolean | null } | null
}
app/api/checkout/route.ts
import { inspect } from 'botscent/server'

export async function POST(request: Request) {
  const verdict = await inspect(request)
  // => { type: 'agent', agent_name: 'curl', reasons: ['ua.declared-agent-token'] }
  return Response.json(verdict)
}

Parameters

NameTypeDefaultDescription
requestRequestLike(required)A Fetch Request, or an object with headers and an optional method and url.
options.cfCloudflareHints(none)Cloudflare's request.cf, read for its verified-bot field.
options.nownumberDate.now()The request time in milliseconds since the epoch, for tests and replays.
options.debugboolean | ((line: string) => void)(none)Logs each decision with console.debug, or sends each line to the function.

Returns

Promise<Verdict>. See The verdict. The function is asynchronous because WebCrypto verifies signatures asynchronously.

Notes

  • inspect reads the request headers, the method, the URL when given, and the platform hints. It reads no body and no page report.
  • inspect never throws. On an internal failure, it returns the verdict of the evidence so far, at worst { type: 'human', reasons: [] }.
  • inspect verifies signatures against signer keys bundled in the release. It makes no network request.
  • If debug is unset, BOTSCENT_DEBUG=1 in process.env turns on debug output. Each line has the prefix [botscent] and shows tokens, signatures, hints and the verdict.
  • In Nuxt, pass event.node.req. In SvelteKit, pass event.request.

readReport

Parses a page report. Returns a verdict whose reasons carry the page. prefix, or null.

Signature
function readReport(value: string | Verdict | null | undefined): Verdict | null
app/api/checkout/route.ts
import { inspect, readReport } from 'botscent/server'

export async function POST(request: Request) {
  const own = await inspect(request)
  const report = readReport(request.headers.get('botscent-report'))
  // => { type: 'agent', reasons: ['page.browser.webdriver-flag'] }
  return Response.json({ request: own, report })
}

Parameters

NameTypeDefaultDescription
valueunknown(required)The Botscent-Report header, the form's botscent field, or a verdict object from verdict.

Returns

Verdict | null. The value is null for anything malformed, longer than 256 bytes, or of an unknown major version.

Notes

  • Every reason of a parsed report gets the page. prefix. A report therefore never passes isVerified.
  • A verdict object must follow the same grammar as the wire string.
  • A person's page sends no report, so readReport returns null for the missing header.

Warning: Do not grant access on a page report. Scripts on the page can forge it. Use isVerified on the verdict from inspect.

combine

Adds a page report's evidence to the request's own verdict.

Signature
function combine(request: Verdict, report: Verdict | null | undefined): Verdict
app/api/checkout/route.ts
import { combine, inspect, readReport } from 'botscent/server'

export async function POST(request: Request) {
  const own = await inspect(request)
  const report = readReport(request.headers.get('botscent-report'))
  return Response.json({ request: own, report, combined: combine(own, report) })
}

Parameters

NameTypeDefaultDescription
requestVerdict(required)The request's own verdict from inspect.
reportVerdict | null | undefined(required)The page report from readReport.

Returns

Verdict. The combined verdict is an agent when either verdict is an agent. Its reasons are the request's reasons, then the report's reasons.

Notes

  • The agent_name is the request's name when it has one. Otherwise it is the report's name.
  • A missing or empty report returns the request verdict unchanged.
  • A combined verdict is for measurement and for changes to the interface. It is never for access.

isVerified

Returns true when the request itself was verified. With a name, returns true only when a verified signature names that agent.

Signature
function isVerified(verdict: Verdict | null | undefined, name?: AgentName): boolean
app/api/checkout/route.ts
import { inspect, isVerified } from 'botscent/server'

export async function POST(request: Request) {
  const verdict = await inspect(request)
  if (!isVerified(verdict, 'chatgpt')) return new Response('Forbidden', { status: 403 })
  return Response.json({ ok: true })
}

Parameters

NameTypeDefaultDescription
verdictVerdict | null | undefined(required)The request's own verdict from inspect.
nameAgentName(none)The agent name that a verified signature must give.

Returns

boolean. Without name, the value is true when reasons contains signer.web-bot-auth.verified or signer.edge-verified-bot without a prefix. With name, it is true when reasons contains signer.web-bot-auth.verified, agent_name is name, and no reason has the page. prefix.

Notes

  • Only inspect produces the two verified reasons.
  • The platform's verified-bot field never satisfies a check with a name. The field says that some bot was verified, not which bot.
  • To let one agent through, use isVerified(verdict, 'chatgpt'). The check isVerified(verdict) && verdict.agent_name === 'chatgpt' can pair a verified bot with a name that it only declared.
  • Signer keys are pinned in each release. A key that a signer removes still verifies until the installation upgrades. See Versions and stability.
  • Verification authenticates the operator's infrastructure, not the person. See The trust model.

Warning: Do not use agent_name alone to allow access. Anyone can send the headers that produce a name. Use isVerified(verdict, name).

VERSION

Holds the version of the library.

Signature
const VERSION: string
src/version.ts
import { VERSION } from 'botscent/server'

console.log(VERSION)

botscent/names.json

Gives every agent name as data, with its display name, vendor and kind.

Shape
Record<string, { display: string; vendor: string; kind: string }>
src/names.ts
import names from 'botscent/names.json'

console.log(names['chatgpt'])
// => { display: 'ChatGPT', vendor: 'OpenAI', kind: 'computer-use-agent' }

Notes

  • The keys are the agent_name values. The Agents page lists them.
  • kind is one of computer-use-agent, automation, fetcher, previewer, crawler or client.

Types

botscent/server exports these types. botscent exports AgentName, Reason, Verdict, Diagnostics and StartOptions.

Types
type Verdict = {
  readonly type: 'agent' | 'human'
  readonly agent_name?: AgentName
  readonly reasons: readonly Reason[]
}
type Reason = KnownReason | (string & {})
type AgentName = KnownAgentName | (string & {})
type TransportMode = 'auto' | 'always' | 'never'
// also: CloudflareHints, InspectOptions, RequestLike (see inspect)

KnownReason and KnownAgentName list every id in this release. A reason or a name from a newer server still type-checks.

What every adapter does

Every adapter below runs inspect for each request. Most adapters keep the request's own verdict where the framework keeps per-request state. An adapter also does these things:

  • It keeps status codes, redirects, cookies, streaming responses, request bodies and the application's own exceptions unchanged.
  • It never turns a statically rendered route into a dynamic route.
  • It removes inherited botscent entries from Server-Timing, and keeps all other entries. The Next.js proxy and Vercel Routing Middleware cannot remove an entry that the response itself carries.
  • If its transport is on, it adds the botscent entry and Cache-Control: no-store to an agent's document navigation.
  • On its own failure, it gives no evidence and passes the request on.

The transport option controls the Server-Timing entry. 'always' states that no shared cache stores the HTML. See From the server to the page.

botscent/next

Runs the server half as a Next.js proxy.

Signature
const proxy: NextProxy
function withBotscent(existing?: NextProxy, options?: BotscentNextOptions): NextProxy
function withBotscent(options: BotscentNextOptions): NextProxy

type Result = Response | NextResponse | null | undefined | void
type NextProxy = (request: NextRequest, event: NextFetchEvent) => Result | Promise<Result>
type BotscentNextOptions = { transport?: TransportMode; debug?: boolean | ((line: string) => void) }
proxy.ts
import { withBotscent } from 'botscent/next'

export const proxy = withBotscent({ transport: 'auto', debug: true })

Parameters

NameTypeDefaultDescription
existingNextProxy(none)The app's own proxy, which withBotscent wraps and keeps.
options.transport'auto' | 'always' | 'never''auto'Sends the entry on Vercel only with 'auto', always with 'always', and never with 'never'.
options.debugboolean | ((line: string) => void)(none)Logs each decision, as in inspect.

Notes

  • With no proxy in the app, proxy.ts is one line: export { proxy } from 'botscent/next'.
  • Before Next.js 16, the file is middleware.ts, with export { proxy as middleware } from 'botscent/next' or export const middleware = withBotscent(existingMiddleware).
  • The proxy does not pass on the verdict. Route handlers call inspect(request).
  • The wrapped proxy's response is kept. Only Server-Timing and, for agents, Cache-Control change.
  • A proxy cannot see headers that the route sets later. On an agent navigation that it decorates, its Server-Timing replaces one that the route set.

Examples

Wrap an existing proxy

Pass the app's own proxy to withBotscent:

proxy.ts
import { NextResponse } from 'next/server'
import { withBotscent } from 'botscent/next'

function existingProxy() {
  return NextResponse.next()
}

export const proxy = withBotscent(existingProxy)

botscent/vercel

Runs the server half as Vercel Routing Middleware, for projects that are not Next.js.

Signature
declare const middleware: VercelMiddleware
export default middleware
function withBotscent(existing?: VercelMiddleware, options?: BotscentVercelOptions): VercelMiddleware
function withBotscent(options: BotscentVercelOptions): VercelMiddleware

type Result = Response | null | undefined | void
type VercelMiddleware = (request: Request, context?: unknown) => Result | Promise<Result>
type BotscentVercelOptions = { transport?: TransportMode; debug?: boolean | ((line: string) => void) }
middleware.ts
export { default } from 'botscent/vercel'

Parameters

NameTypeDefaultDescription
existingVercelMiddleware(none)The project's own middleware, which withBotscent wraps.
options.transport'auto' | 'always' | 'never''auto'Sends the entry with 'auto' and 'always', and never with 'never'.
options.debugboolean | ((line: string) => void)(none)Logs each decision, as in inspect.

Notes

  • The middleware does not pass on the verdict. It continues to the project.
  • Routing Middleware runs before the response exists. It cannot remove an entry that the response itself carries.
  • Vercel adds the middleware's Server-Timing to the response's own.

botscent/workers

Runs the server half around a Cloudflare Worker's fetch handler or a Netlify Edge Function.

Signature
function withBotscent<Env = unknown>(
  handler: (request: Request, env: Env, ctx: Context, verdict: Verdict) => Response | Promise<Response>,
  options?: BotscentWorkersOptions,
): (request: Request, env: Env, ctx: Context) => Promise<Response>

type Context = { waitUntil(promise: Promise<unknown>): void; passThroughOnException?(): void }
type BotscentWorkersOptions = { transport?: TransportMode; debug?: boolean | ((line: string) => void) }
src/index.ts
import { withBotscent } from 'botscent/workers'

export default {
  fetch: withBotscent(async (request, env, ctx, verdict) => fetch(request)),
}

Parameters

NameTypeDefaultDescription
handlerFetchWithVerdict<Env>(required)The Worker's handler, which gets the request's verdict as its fourth argument.
options.transport'auto' | 'always' | 'never''auto'Sends the entry with 'auto' and 'always', and never with 'never'.
options.debugboolean | ((line: string) => void)(none)Logs each decision, as in inspect.

Notes

  • The adapter passes Cloudflare's request.cf to inspect.
  • If the Worker stores HTML with the Cache API, set transport: 'never'.
  • Behind Workers Cache, cached pages carry no entry.
  • On Netlify, withBotscent<Context>((request, context) => context.next()) continues to the site, with Context from @netlify/edge-functions.

botscent/hono

Runs the server half as Hono middleware.

Signature
function botscent(options?: BotscentHonoOptions): MiddlewareHandler<{ Variables: { botscent: Verdict } }>

type BotscentHonoOptions = { transport?: TransportMode; debug?: boolean | ((line: string) => void) }
src/index.ts
import { Hono } from 'hono'
import { botscent } from 'botscent/hono'

const app = new Hono().use(botscent())

app.get('/verdict', (c) => c.json(c.get('botscent')))

export default app

Parameters

NameTypeDefaultDescription
options.transport'auto' | 'always' | 'never''auto'Sends the entry on Cloudflare Workers only with 'auto', always with 'always', and never with 'never'.
options.debugboolean | ((line: string) => void)(none)Logs each decision, as in inspect.

Notes

  • The request's verdict is c.get('botscent'). Chain .use(botscent()) on new Hono() to type it.
  • On Cloudflare Workers, the middleware passes request.cf to inspect.

botscent/express

Runs the server half as Express or Connect middleware.

Signature
function botscent(options?: BotscentExpressOptions): (req: IncomingMessage, res: ServerResponse, next: (error?: unknown) => void) => void

type BotscentExpressOptions = { transport?: 'always' | 'never'; debug?: boolean | ((line: string) => void) }
server.js
const express = require('express')
const { botscent } = require('botscent/express')

const app = express()
app.use(botscent())

app.get('/verdict', (req, res) => res.json(req.botscent))

app.listen(3000)

Parameters

NameTypeDefaultDescription
options.transport'always' | 'never''never'Sends the entry with 'always', and never with 'never'.
options.debugboolean | ((line: string) => void)(none)Logs each decision, as in inspect.

Notes

  • The request's verdict is req.botscent in every handler after the middleware.
  • Express runs at the origin, which cannot see a CDN in front of it. The transport is therefore off by default.

botscent/astro

Adds both halves to an Astro site as one integration.

Signature
export default function botscent(options?: BotscentAstroOptions): AstroIntegration

type BotscentAstroOptions = { transport?: 'always' | 'never'; debug?: boolean }
astro.config.mjs
import { defineConfig } from 'astro/config'
import botscent from 'botscent/astro'

export default defineConfig({
  integrations: [botscent()],
})

Parameters

NameTypeDefaultDescription
options.transport'always' | 'never''never'Sends the entry with 'always', and never with 'never'.
options.debugbooleanfalseLogs each decision of the server half with console.debug.

Notes

  • The integration adds botscent/auto to every page.
  • On routes rendered on demand, the request's verdict is Astro.locals.botscent.
  • Prerendered pages have no request to inspect, so the server half does not run for them.