This file describes how to integrate protection service [BotBye!](https://botbye.com) using Core

# Core

BotBye! core module for Node.js — the low-level building block used by all BotBye framework integrations.

Use this package when no framework-specific integration is available for your environment, or when you need full control over how request information is passed to the SDK.

For most use cases, prefer a framework-specific package:

- [@botbye/node-http]( /docs/server-side/node-js/http ) — Node.js `http` module
- [@botbye/express]( /docs/server-side/node-js/express ) — Express
- [@botbye/fastify]( /docs/server-side/node-js/fastify ) — Fastify
- [@botbye/cloudflare-worker]( /docs/edge-servers/cloudflare-workers ) — Cloudflare Workers
- [@botbye/nextjs]( /docs/server-side/node-js/nextjs ) — Next.js
- [@botbye/nuxt]( /docs/server-side/node-js/nuxt ) — Nuxt

## Install


```bash
npm i @botbye/node-core
```

---or---

```bash
yarn add @botbye/node-core
```


## Configuration


`@botbye/node-core` does not export `init` and `evaluate` directly. Call `moduleApiFactory` to create an SDK instance:

```javascript
import { moduleApiFactory } from "@botbye/node-core";
import { nodeHttpClient } from "@botbye/node-core/node-http-client";

const { init, evaluate, dev } = moduleApiFactory({
  httpClient: nodeHttpClient,
});

init({
  // Use your project server-key
  serverKey: "00000000-0000-0000-0000-000000000000",
});
```

Call `init` once at application startup, before any calls to `evaluate`.


### `moduleApiFactory` options

| Option | Type | Required | Description |
|---|---|---|---|
| `httpClient` | `THttpClient` | Yes | HTTP client used for API calls. |
| `requestInfoExtractor` | `(request: R, global: TGlobalOptions) => TRequestInfo` | No | Converts a custom request object into request info, enabling `{ request: R }` in `evaluate`. |
| `url` | `string` | No | Override BotBye API endpoint. Can also be set via `init`. |

### HTTP clients

Two built-in HTTP clients are available:

| Import path | When to use |
|---|---|
| `@botbye/node-core/node-http-client` | Standard Node.js environments (uses built-in `http`/`https`) |
| `@botbye/node-core/fetch-http-client` | Runtimes with the Fetch API (Deno, Bun, edge runtimes) |

If neither fits (custom proxy, special auth headers, retry logic), implement the `THttpClient` interface:

```typescript
import type { TModuleApiFactoryOptions } from "@botbye/node-core";

const myHttpClient: TModuleApiFactoryOptions["httpClient"] = {
  type: "my-client",
  call(url, init) {
    const controller = new AbortController();
    const result = fetch(url, {
      method: init.method,
      headers: init.headers,
      body: JSON.stringify(init.body),
      signal: controller.signal,
    }).then((r) => r.text());
    return { result, abort: () => controller.abort() };
  },
};
```

### `init` options

| Option | Type | Required | Description |
|---|---|---|---|
| `serverKey` | `string` | Yes | Server key from your BotBye project |
| `url` | `string` | No | Override BotBye API endpoint (default: `https://verify.botbye.com`) |
| `logger.level` | `"error"` `"warn"` `"info"` `"debug"` `"log"` | No | Log level (default: `"info"`) |
| `logger.logger` | `TLogger` | No | Custom logger instance implementing `{ error, warn, info, debug, log }` |
| `timeouts.evaluate` | `number` | No | Timeout in milliseconds for each `evaluate` call |

## Building a custom integration

`requestInfoExtractor` lets you build a first-class integration for any framework not yet covered by an official package. It bridges the gap between a framework's native request object and the `TRequestInfo` shape that `evaluate` needs internally.

**What it does:**

When `requestInfoExtractor` is provided, `evaluate` gains a second calling form: instead of passing fields explicitly, you can pass `{ request: YourRequestObject }`. The extractor is called automatically to derive `ip`, `headers`, `requestMethod`, and `requestUri` from it.

```typescript
// Without requestInfoExtractor — explicit fields only
evaluate({ type: "validate", request: { ip, headers, requestMethod, requestUri, token } });

// With requestInfoExtractor — framework request object accepted directly
evaluate({ type: "validate", request: { request: req, token } });
```

Both forms remain valid side by side. Routes that already pass fields explicitly continue to work.

**Token handling:**

The extractor may also return a `token` field (e.g. extracted from a known header). If the caller also passes `token` in the event, the event's value takes precedence. This lets the extractor provide a sensible default while allowing per-call overrides.

**TypeScript:**

The generic parameter `R` on `moduleApiFactory<R>` flows through to the `evaluate` signature. Providing `requestInfoExtractor` is what makes the typed `{ request: R }` form valid:

```typescript
import { moduleApiFactory } from "@botbye/node-core";
import { nodeHttpClient } from "@botbye/node-core/node-http-client";
import type { Request } from "express";

const { init, evaluate, dev } = moduleApiFactory<Request>({
  httpClient: nodeHttpClient,
  requestInfoExtractor: (req, global) => {
    try {
      return {
        ip: req.ip ?? req.socket.remoteAddress ?? "0.0.0.0",
        headers: req.headers as Record<string, string>,
        requestMethod: req.method,
        requestUri: req.url,
        token: req.headers["x-botbye-token"] as string ?? null,
      };
    } catch {
      global.logger.warn("Failed to extract request info from Express request");
      return { ip: "0.0.0.0", headers: {} };
    }
  },
});

init({
  // Use your project server-key
  serverKey: "00000000-0000-0000-0000-000000000000",
});

// Now evaluate accepts Express Request directly
app.use(async (req, res, next) => {
  const result = await evaluate({
    type: "validate",
    request: {
      request: req,
      // "x-botbye-token" is an example — pass the token from wherever you store it
      token: req.headers["x-botbye-token"] as string,
    },
  });

  if (result.decision === "BLOCK") {
    return res.status(403).json({ error: "Forbidden" });
  }

  next();
});
```

The `global` argument passed to the extractor exposes `global.logger` — use it for warnings when the request object is malformed or unexpected.

## Usage


Call `evaluate` with an event object describing what you know about the request. It returns a promise that resolves to a decision.



```javascript
async function handleRequest(ip, headers, method, url, token) {
  const result = await evaluate({
    type: "validate",
    request: {
      ip,
      headers,
      requestMethod: method,
      requestUri: url,
      token,
    },
  });

  if (result.decision === "BLOCK") {
    return { status: 403 };
  }

  // proceed normally
}
```




Without `requestInfoExtractor`, all request fields must be provided explicitly — there is no framework request object to pass.

There are three event types — `validate`, `risk`, and `full` — each suited for a different layer of your application.

------

### `validate` — edge-level bot check

Use at the edge — API gateway, route handler, middleware — when you just want to know: **was this request made by a bot?** No user or domain context needed.

**Event fields:**

```typescript
{
  type: "validate";
  request:
    // Option A: custom request object — only available when requestInfoExtractor is configured
    | { request: R; token?: string | null }
    // Option B: explicit fields
    | { ip: string; headers: Record<string, string>; requestMethod?: string | null; requestUri?: string | null; token?: string | null };
  customFields?: Record<string, string>;
}
```

Pass IP and headers extracted from your runtime's request object.

Option A — passing a framework request object directly — requires `requestInfoExtractor` to be configured in `moduleApiFactory`.

The `token` is a one-time token generated by the [BotBye client-side SDK]( /docs/client-side/npm-module ) that contains information about the user's device.

Pass whatever the client sent; if no token is received, the decision will be `"BLOCK"` due to an invalid token.

```javascript
async function handleRequest(ip, headers, method, url, token) {
  const result = await evaluate({
    type: "validate",
    request: {
      ip,
      headers,
      requestMethod: method,
      requestUri: url,
      token,
    },
  });

  if (result.decision === "BLOCK") {
    return { status: 403 };
  }

  // proceed normally
}
```

------

### `risk` — domain-level risk scoring


Use inside services that already know the user: auth, payments, account management, etc. The purpose shifts from "is this a bot?" to **"is something suspicious happening for this user?"** — credential stuffing, account takeover, account sharing, logins from a new geo.

**Event fields:**

```typescript
{
  type: "risk";
  request:
    // Option A: custom request object — only available when requestInfoExtractor is configured
    | { request: R }
    // Option B: explicit fields
    | { ip: string; headers?: Record<string, string>; requestMethod?: string | null; requestUri?: string | null; token?: string | null };
  event: {
    type: string;
    status: "ATTEMPTED" | "SUCCESSFUL" | "FAILED" | "UNKNOWN";
  };
  user: {
    accountId: string;
    username?: string | null;
    email?: string | null;
    phone?: string | null;
  };
  customFields?: Record<string, string>;
  botbyeResult?: string; // if a validate call was made earlier, pass its result.botbyeResult here to link the requests; omit if there was no prior validate
}
```

`event` and `user` are the key fields here — they define what action is being performed and who is performing it, which is what drives the risk score.

`ip` is equally important: BotBye tracks which IPs access the account to detect patterns like account sharing, credential stuffing, and suspicious geo logins.

Pass it directly as `{ ip }`, or pass a request object via Option A if `requestInfoExtractor` is configured.

```javascript
// Inside an auth service, after a login attempt
async function onLoginAttempt({ ip, userId, email, loginSucceeded }) {
  const result = await evaluate({
    type: "risk",
    request: { ip },
    event: {
      type: "login",
      status: loginSucceeded ? "SUCCESSFUL" : "FAILED",
      // "SUCCESSFUL" | "FAILED" | "ATTEMPTED" | "UNKNOWN"
    },
    user: {
      accountId: userId,
      email,
    },
  });

  if (result.decision === "BLOCK") {
    // Lock account, trigger MFA, send alert, etc.
  }
}
```

#### Linking `validate` and `risk` events

When the same request is evaluated at two layers — for example, once at the edge (`type: "validate"`) and then again inside a domain service (`type: "risk"`) — BotBye can link both events and display them as a single event in the dashboard.

**Step 1 — edge layer** (gateway, middleware, or entry point): run `validate` and capture `botbye_result`:

```javascript
const edgeResult = await evaluate({
  type: "validate",
  request: {
    ip,
    headers,
    requestMethod: method,
    requestUri: url,
    token,
  },
});
const edgeBotbyeResult = edgeResult.botbye_result;
// Pass edgeBotbyeResult downstream — a request header, function argument, shared context, etc.
```

**Step 2 — domain service** (auth, payment, account management): pass it as `botbyeResult` in the `risk` call:

```javascript
const riskResult = await evaluate({
  type: "risk",
  request: { ip },
  event: {
    type: "login",
    status: loginSucceeded ? "SUCCESSFUL" : "FAILED",
  },
  user: {
    accountId: userId,
    email,
  },
  botbyeResult: edgeBotbyeResult,
});
```

`botbye_result` is optional in the response — if it is absent, omit `botbyeResult` and the events will be recorded independently.


------

### `full` — edge check and domain scoring in one call

Use when you have all context at once: raw request, token, user, and event. A login endpoint is a typical example — it receives the HTTP request and immediately knows the user and outcome.

**Event fields:**

```typescript
{
  type: "full";
  request:
    // Option A: custom request object — only available when requestInfoExtractor is configured
    | { request: R; token?: string | null }
    // Option B: explicit fields
    | { ip: string; headers: Record<string, string>; requestMethod?: string | null; requestUri?: string | null; token?: string | null };
  event: {
    type: string;
    status: "ATTEMPTED" | "SUCCESSFUL" | "FAILED" | "UNKNOWN";
  };
  user: {
    accountId: string;
    username?: string | null;
    email?: string | null;
    phone?: string | null;
  };
  customFields?: Record<string, string>;
}
```

Equivalent to running `validate` and `risk` in a single call.

Option A requires `requestInfoExtractor`.

```javascript
async function handleLogin({ ip, headers, method, url, token, email, password }) {
  const user = await findUser(email);
  const loginSucceeded = user && (await checkPassword(user, password));

  const result = await evaluate({
    type: "full",
    request: {
      ip,
      headers,
      requestMethod: method,
      requestUri: url,
      token,
    },
    event: {
      type: "login",
      status: loginSucceeded ? "SUCCESSFUL" : "FAILED",
    },
    user: {
      accountId: user?.id ?? "",
      email,
    },
  });

  if (result.decision === "BLOCK") {
    return { status: 403 };
  }

  // proceed normally
}
```

------

## Response

`evaluate` always returns a `Promise<TEvaluationResult>`:

```typescript
type TEvaluationResult =
  | {
      decision: "ALLOW" | "BLOCK" | "CHALLENGE";
      request_id: string;
      risk_score: number;
      scores: Record<string, number>;
      signals: string[];
      // Present when `decision` is "CHALLENGE": the kind of additional verification to put in
      // front of the visitor.
      challenge?: { type: "CAPTCHA" | "MFA" };
      botbye_result?: string;
      // Present when BotBye received the request but could not fully evaluate it. An integration
      // error (malformed payload, a key resolving to no project) is "BLOCK" with "evaluation_error"
      // in signals; a failure on the BotBye side is "ALLOW".
      error?: { message: string };
    }
  | {
      decision: "ALLOW";
      botbye_result?: string;
      // SDK fail-open: no decision was ever received — request timeout, connection error, or an
      // unparseable response. There is no request_id in this case.
      error: { message: string };
    };
```

Check `result.decision` to decide how to handle the request:

- `"ALLOW"` — request appears legitimate, proceed normally
- `"BLOCK"` — bot or suspicious activity detected, block the request
- `"CHALLENGE"` — uncertain, consider issuing a CAPTCHA, MFA, or additional verification step

When the response contains an `error` field, the request was not fully evaluated:

- An **integration error** fails **closed** with `decision: "BLOCK"` and `signals: ["evaluation_error"]`: a malformed payload or invalid JSON, or a key that resolves to no project. `error.message` names the cause — the offending field, `Invalid server key`, or `Invalid site key` for a visit-call. These are surfaced rather than passed silently, so a misconfigured integration cannot sit behind a stream of `ALLOW` with protection effectively off. Keys are UUIDs, so a wrong one parses cleanly and only fails project lookup — pasting a site key into `server-key` reaches this case.
- A **BotBye-side failure** fails **open** with `decision: "ALLOW"`: the request was accepted but evaluation could not be completed, and our own fault must not block real users.
- The **SDK** fails **open** with `decision: "ALLOW"` and no `request_id` when it never received a decision at all — request timeout, connection error, or an unparseable response.

In every case `error.message` describes the underlying problem — monitor it and fix the root cause.

## Examples of BotBye API responses

Blocked (bot detected):

```javascript
{
  "request_id": "f77b2abd-c5d7-44f0-be4f-174b04876583",
  "decision": "BLOCK",
  "risk_score": 0.95,
  "scores": { "bot": 0.95 },
  "signals": ["AutomationTool"]
}
```

Allowed:

```javascript
{
  "request_id": "f77b2abd-c5d7-44f0-be4f-174b04876583",
  "decision": "ALLOW",
  "risk_score": 0.05,
  "scores": { "bot": 0.05, "ato": 0.02 },
  "signals": []
}
```

Challenge:

```javascript
{
  "request_id": "f77b2abd-c5d7-44f0-be4f-174b04876583",
  "decision": "CHALLENGE",
  "risk_score": 0.65,
  "scores": { "bot": 0.65 },
  "signals": ["SuspiciousFingerprint"],
  "challenge": { "type": "CAPTCHA" }
}
```

Invalid `server-key` — not a known project (fail-closed):

```javascript
{
  "request_id": "f77b2abd-c5d7-44f0-be4f-174b04876583",
  "decision": "BLOCK",
  "risk_score": 1.0,
  "scores": {},
  "signals": ["evaluation_error"],
  "error": { "message": "Invalid server key" }
}
```

Evaluation error — request could not be processed (fail-closed):

```javascript
{
  "request_id": "f77b2abd-c5d7-44f0-be4f-174b04876583",
  "decision": "BLOCK",
  "risk_score": 1.0,
  "scores": {},
  "signals": ["evaluation_error"],
  "error": { "message": "Invalid request body: missing or invalid field 'user.account_id'" }
}
```

SDK never received a decision (fail-open):

```javascript
{
  "decision": "ALLOW",
  "error": { "message": "connection error" }
}
```

## What your plan is told

The examples above are what a paid plan receives. `risk_score`, `scores` and the detector `signals` are
filled in from the **Starter** plan up; on **Free** the verdict is the same and `request_id` is still there,
but those three come back blanked — `risk_score` is `0.0`, `scores` is `{}`, and `signals` keeps only the
reasons that name something on your side rather than a detector of ours: `token_expected`,
`script_loading_error`, `token_generation_error`, `raw_token_changed`, `banned_by_rule`, the verdict of
a rule you wrote yourself, and the challenge signals: `challenge_captcha` or `challenge_mfa`, the challenge the
scores asked for, and the `challenge_granted_by_*` that answered it on a request that came back `ALLOW`.

The protection behaves identically on every plan; only the explanation of a decision is withheld. So branch
on `decision`, never on `risk_score` — a threshold over the score reads every Free-plan request as low risk.


## Advanced: multiple instances

Use `moduleApiFactory` to create independent SDK instances (useful when protecting multiple projects from one service):

```javascript
import { moduleApiFactory } from "@botbye/node-core";
import { nodeHttpClient } from "@botbye/node-core/node-http-client";

const sdkA = moduleApiFactory({ httpClient: nodeHttpClient });
const sdkB = moduleApiFactory({ httpClient: nodeHttpClient });

sdkA.init({
  // Use your project server-key
  serverKey: "00000000-0000-0000-0000-000000000000",
});

sdkB.init({
  // Use your project server-key
  serverKey: "11111111-1111-1111-1111-111111111111",
});
```

## Dev utilities

```javascript
import { moduleApiFactory } from "@botbye/node-core";
import { nodeHttpClient } from "@botbye/node-core/node-http-client";

const { dev } = moduleApiFactory({ httpClient: nodeHttpClient });

// Change log verbosity at runtime
dev.setLoggerLevel("debug"); // "error" | "warn" | "info" | "debug" | "log"
```

There is a prompt for an LLM model that describes how to integrate BotBye! protection into a project:

```plaintext
Integrate BotBye bot protection into an application using the @botbye/node-core package.

1. Install:
   npm i @botbye/node-core   or   yarn add @botbye/node-core

2. Create an SDK instance with moduleApiFactory and call init once at startup:
   import { moduleApiFactory } from "@botbye/node-core";
   import { nodeHttpClient } from "@botbye/node-core/node-http-client";

   const { init, evaluate } = moduleApiFactory({ httpClient: nodeHttpClient });

   init({ serverKey: "00000000-0000-0000-0000-000000000000" });

   Use "@botbye/node-core/fetch-http-client" instead of nodeHttpClient for edge runtimes (Deno, Bun, Cloudflare Workers).

3. For each request call evaluate with one of three event types:

   validate — edge-level bot check (API gateway, middleware):
     const result = await evaluate({
       type: "validate",
       request: { ip, headers, requestMethod: method, requestUri: url, token },
     });

   risk — domain-level risk scoring (auth, payments) — requires user context:
     const result = await evaluate({
       type: "risk",
       request: { ip },
       event: { type: "login", status: "SUCCESSFUL" | "FAILED" | "ATTEMPTED" | "UNKNOWN" },
       user: { accountId, email },
     });

   full — edge check and risk scoring in one call (login endpoints with all context at once):
     const result = await evaluate({
       type: "full",
       request: { ip, headers, requestMethod: method, requestUri: url, token },
       event: { type: "login", status: "SUCCESSFUL" | "FAILED" | "ATTEMPTED" | "UNKNOWN" },
       user: { accountId, email },
     });

   token — one-time token from the BotBye client-side SDK sent by the browser. If missing, decision will be "BLOCK".

4. Handle the response by checking result.decision:
   "ALLOW"     — legitimate request, proceed normally
   "BLOCK"     — bot or suspicious activity detected, block (return 403)
   "CHALLENGE" — uncertain, issue CAPTCHA or MFA

   If result contains an error field, BotBye could not fully evaluate: the SDK fails open with "ALLOW" on connectivity/config errors (e.g. invalid serverKey), while the backend fails closed with "BLOCK" + signals ["evaluation_error"] on an unprocessable request (e.g. invalid JSON / missing required fields).

Full integration https://botbye.com/docs/server-side/node-js/core.md

  When the integration is complete, ask for the keys and replace the placeholder in the appropriate places.```
