NestJS Fastify
NestJS Fastify

BotBye! integration for NestJS with Fastify applications.

Install

1
npm i @botbye/nest-fastify
1
yarn add @botbye/nest-fastify

Requires @nestjs/common >= 10 and fastify >= 4 as peer dependencies.

Configuration

Register BotByeModule once in your root module:

1
2
3
4
5
6
7
8
9
10
11
12
import { Module } from "@nestjs/common";
import { BotByeModule } from "@botbye/nest-fastify";

@Module({
  imports: [
    BotByeModule.register({
      // Use your project server-key
      serverKey: "00000000-0000-0000-0000-000000000000",
    }),
  ],
})
export class AppModule {}

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
tokenExtractor (request: FastifyRequest) => Nullable<string> No* Extracts the BotBye token from the Fastify request. *Required when using BotByeMiddleware — without it the middleware skips evaluation and returns an error result.

Usage

After registering BotByeModule, inject TBotByeService using BOTBYE_SERVICE_DI_TOKEN to call evaluate from guards, controllers, or services.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { Controller, Post, Inject, Req, ForbiddenException } from "@nestjs/common";
import { FastifyRequest } from "fastify";
import { BOTBYE_SERVICE_DI_TOKEN, TBotByeService } from "@botbye/nest-fastify";

@Controller()
export class AppController {
  constructor(
    @Inject(BOTBYE_SERVICE_DI_TOKEN) private readonly botbye: TBotByeService
  ) {}

  @Post("/api/submit")
  async submit(@Req() req: FastifyRequest) {
    const result = await this.botbye.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 | null,
      },
    });

    if (result.decision === "BLOCK") {
      throw new ForbiddenException();
    }

    // proceed normally
  }
}

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 — guard, route handler, middleware — when you just want to know: was this request made by a bot? No user or domain context needed.

Event fields:

1
2
3
4
5
6
7
8
9
10
11
12
{
  type: "validate";

  request:
    // Option A: pass the Fastify request object directly — SDK extracts everything automatically
    | { request: FastifyRequest; token?: string | null }
    // Option B: construct request info manually
    | { ip: string; headers: Record<string, string>; requestMethod?: string | null; requestUri?: string | null; token?: string | null };

  customFields?: Record<string, string>;

}

The SDK extracts IP, headers, method, and URI from the Fastify request automatically. You can also pass request info manually — see Option B above. The token is a one-time token generated by the BotBye client-side SDK that contains information about the user's device. Pass whatever the client sent; if no token is received, the decision will be "BLOCK".

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { Injectable, CanActivate, ExecutionContext, Inject } from "@nestjs/common";
import { FastifyRequest } from "fastify";
import { BOTBYE_SERVICE_DI_TOKEN, TBotByeService } from "@botbye/nest-fastify";

@Injectable()
export class BotByeGuard implements CanActivate {
  constructor(
    @Inject(BOTBYE_SERVICE_DI_TOKEN) private readonly botbye: TBotByeService
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest<FastifyRequest>();

    const result = await this.botbye.evaluate({
      type: "validate",
      request: {
        request,
        // "x-botbye-token" is an example — pass the token from wherever you store it
        token: request.headers["x-botbye-token"] as string | null,
      },
    });

    return result.decision !== "BLOCK";
  }
}

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
  type: "risk";

  request:
    // Only ip is needed at this level
    | { ip: string; headers?: Record<string, string>; requestMethod?: string | null; requestUri?: string | null; token?: string | null }
    // Fastify request object also accepted if convenient
    | { request: FastifyRequest };

  event: {
    type: string;   // e.g. "login", "password_change", "checkout"
    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 the Fastify request object if that's more convenient.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { Injectable, Inject } from "@nestjs/common";
import { BOTBYE_SERVICE_DI_TOKEN, TBotByeService } from "@botbye/nest-fastify";

@Injectable()
export class AuthService {
  constructor(
    @Inject(BOTBYE_SERVICE_DI_TOKEN) private readonly botbye: TBotByeService
  ) {}

  async onLoginAttempt(ip: string, userId: string, email: string, loginSucceeded: boolean) {
    const result = await this.botbye.evaluate({
      type: "risk",
      request: { ip },
      event: {
        type: "login",
        status: loginSucceeded ? "SUCCESSFUL" : "FAILED",
      },
      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 in a guard (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 — guard (edge layer): run validate and capture botbye_result:

1
2
3
4
5
6
7
8
9
10
11
// e.g. in BotByeGuard.canActivate()
const edgeResult = await this.botbye.evaluate({
  type: "validate",
  request: {
    request,
    // "x-botbye-token" is an example — pass the token from wherever you store it
    token: request.headers["x-botbye-token"] as string | null,
  },
});
const edgeBotbyeResult = edgeResult.botbye_result;
// Pass edgeBotbyeResult downstream — request object, function argument, shared context, etc.

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// e.g. in AuthService.onLoginAttempt()
const riskResult = await this.botbye.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. Equivalent to running validate and risk in a single call. A login endpoint is a typical example — it receives the HTTP request and immediately knows the user and outcome.

Event fields:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
  type: "full";

  request:
    | { request: FastifyRequest; token?: string | null }
    | { 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>;

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { Injectable, Inject, ForbiddenException } from "@nestjs/common";
import { FastifyRequest } from "fastify";
import { BOTBYE_SERVICE_DI_TOKEN, TBotByeService } from "@botbye/nest-fastify";

@Injectable()
export class AuthService {
  constructor(
    @Inject(BOTBYE_SERVICE_DI_TOKEN) private readonly botbye: TBotByeService
  ) {}

  async login(request: FastifyRequest, email: string, password: string) {
    const user = await this.findUser(email);
    const loginSucceeded = user && (await this.checkPassword(user, password));

    const result = await this.botbye.evaluate({
      type: "full",
      request: {
        request,
        // "x-botbye-token" is an example — pass the token from wherever you store it
        token: request.headers["x-botbye-token"] as string | null,
      },
      event: {
        type: "login",
        status: loginSucceeded ? "SUCCESSFUL" : "FAILED",
      },
      user: {
        accountId: user?.id ?? "unknown",
        email,
      },
    });

    if (result.decision === "BLOCK") {
      throw new ForbiddenException();
    }

    // proceed normally
  }
}

Response

evaluate always returns a Promise<TEvaluationResult>:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type TEvaluationResult =
  | {
      decision: "ALLOW" | "BLOCK" | "CHALLENGE";
      request_id: string;
      risk_score: number;
      scores: Record<string, number>;
      signals: string[];
      botbye_result?: string;
    }
  | {
      decision: "ALLOW";
      botbye_result?: string;
      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, BotBye could not evaluate the request (e.g. invalid server key). In that case decision defaults to "ALLOW" so that a misconfiguration does not block real users — but you should monitor and fix the underlying error.

Examples of BotBye API responses

Blocked (bot detected):

1
2
3
4
5
6
7
{
  "request_id": "f77b2abd-c5d7-44f0-be4f-174b04876583",
  "decision": "BLOCK",
  "risk_score": 0.95,
  "scores": { "bot": 0.95 },
  "signals": ["AutomationTool"]
}

Allowed:

1
2
3
4
5
6
7
{
  "request_id": "f77b2abd-c5d7-44f0-be4f-174b04876583",
  "decision": "ALLOW",
  "risk_score": 0.05,
  "scores": { "bot": 0.05, "ato": 0.02 },
  "signals": []
}

Challenge:

1
2
3
4
5
6
7
8
{
  "request_id": "f77b2abd-c5d7-44f0-be4f-174b04876583",
  "decision": "CHALLENGE",
  "risk_score": 0.65,
  "scores": { "bot": 0.65 },
  "signals": ["SuspiciousFingerprint"],
  "challenge": { "type": "captcha", "token": "..." }
}

Invalid server-key:

1
2
3
4
{
  "decision": "ALLOW",
  "error": { "message": "[BotBye] Bad Request: Invalid Server Key" }
}

Middleware usage

BotByeMiddleware evaluates every incoming request automatically and stores the result on the request object. Use the @BotByeResponse() parameter decorator to access the result in a controller without calling evaluate manually.

Provide a tokenExtractor in module options so the middleware knows where to find the BotBye token:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// app.module.ts
import { Module, NestModule, MiddlewareConsumer } from "@nestjs/common";
import { BotByeModule, BotByeMiddleware } from "@botbye/nest-fastify";

@Module({
  imports: [
    BotByeModule.register({
      // Use your project server-key
      serverKey: "00000000-0000-0000-0000-000000000000",
      // "x-botbye-token" is an example — pass the token from wherever you store it
      tokenExtractor: (req) => req.headers["x-botbye-token"] as string | undefined,
    }),
  ],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    // Global: protect all routes
    consumer.apply(BotByeMiddleware).forRoutes("*");
  }
}

Access the result in a controller with @BotByeResponse():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// app.controller.ts
import { Controller, Post, ForbiddenException } from "@nestjs/common";
import { BotByeResponse } from "@botbye/nest-fastify";
import type { TEvaluationResult } from "@botbye/nest-fastify";

@Controller()
export class AppController {
  @Post("/api/submit")
  async submit(@BotByeResponse() result: TEvaluationResult) {
    if (result.decision === "BLOCK") {
      throw new ForbiddenException();
    }
    // proceed normally
  }
}

To apply middleware only to specific routes, replace .forRoutes("*") with a path or route descriptor:

1
2
// Scoped: protect only routes registered under "protected"
consumer.apply(BotByeMiddleware).forRoutes("protected");