Cal.diy Onboarding LabChapter VI — Notifying the outside world and guarding the front door0/4Contents
Chapter VI

Notifying the outside world and guarding the front door

This module follows two cross-cutting concerns that surround the booking flow rather than living inside it: the outbound side, where getWebhooks() resolves 'who wants to hear about this event' independently of any single feature, and the inbound side, where BotDetectionService and checkRateLimitAndThrowError run as pluggable guards in front of /api/book/event before any booking logic executes. Both are built the same way — small, independently callable services that a route or service layer awaits and that signal failure by throwing, rather than logic inlined into the handler. By the end you can trace an outbound trigger from any feature back to its subscriber lookup, and recognize the guard-then-delegate shape wherever else it appears in the codebase.


walkthrough

getWebhooks(): resolving subscribers by trigger name

packages/features/webhooks/lib/getWebhooks.ts · lines 1889
lines 22–27

userId, eventTypeId, orgId and oAuthClientId default to 0 (or empty string), never undefined. That matters because Prisma drops an undefined value from a where clause entirely — where: { userId } with userId undefined would match every row. Defaulting to 0 keeps each OR branch a real, checkable equality that simply never matches when the caller didn't supply that scope.

lines 29–41

A 'managed event type' is one cloned from a parent template across a team's members. If the eventTypeId passed in is a managed child, this looks up its parentId so a webhook registered against the parent event type still fires for events created on the child — otherwise a team-wide integration would silently miss every member's booking.

lines 44–74

The OR list expresses every reason a webhook row could be 'in scope' for this call: it's a platform-wide webhook, or owned by this userId, or tied to this eventTypeId (or its managed parent), or scoped to one of these teamIds/orgId, or registered to this OAuth client. The AND clause then filters that candidate set down to rows whose eventTriggers array has the requested triggerEvent and whose active flag is true — one query answering 'which registered listeners care about this event, right now, for this scope'.

lines 75–89

Only the columns needed to actually deliver a payload are selected, and the raw Prisma rows are mapped through WebhookOutputMapper.toSubscriberPartial into WebhookSubscriber objects — the DTO every caller of getWebhooks() works with, decoupled from the Webhook table's full shape.

const getWebhooks = async (  options: GetSubscriberOptions,  prisma: PrismaClient = defaultPrisma): Promise<WebhookSubscriber[]> => {  const teamId = options.teamId;  const userId = options.userId ?? 0;  const eventTypeId = options.eventTypeId ?? 0;  const teamIds = Array.isArray(teamId) ? teamId : [teamId ?? 0];  const orgId = options.orgId ?? 0;  const oAuthClientId = options.oAuthClientId ?? "";  const managedChildEventType = await prisma.eventType.findFirst({    where: {      id: eventTypeId,      parentId: {        not: null,      },    },    select: {      parentId: true,    },  });  const managedParentEventTypeId = managedChildEventType?.parentId ?? 0;  // if we have userId and teamId it is a managed event type and should trigger for team and user  const allWebhooks = await prisma.webhook.findMany({    where: {      OR: [        {          platform: true,        },        {          userId,        },        {          eventTypeId,        },        {          eventTypeId: managedParentEventTypeId,        },        {          teamId: {            in: [...teamIds, orgId],          },        },        { platformOAuthClientId: oAuthClientId },      ],      AND: {        eventTriggers: {          has: options.triggerEvent,        },        active: {          equals: true,        },      },    },    select: {      id: true,      subscriberUrl: true,      payloadTemplate: true,      appId: true,      secret: true,      time: true,      timeUnit: true,      eventTriggers: true,      version: true,    },  });  return allWebhooks.map(WebhookOutputMapper.toSubscriberPartial);};
step 1 of 4
    // Trigger BOOKING_PAID webhooks    const subscriberMeetingPaid = await getWebhooks({      userId,      eventTypeId: booking.eventTypeId,      triggerEvent: WebhookTriggerEvents.BOOKING_PAID,      teamId: booking.eventType?.teamId,      oAuthClientId: platformClientParams?.platformClientId,    });
packages/app-store/_utils/payments/handlePaymentSuccess.ts · lines 160167
Figure I · handlePaymentSuccess belongs to the payments feature, not the booking-creation path, yet it calls the exact same getWebhooks() function with triggerEvent: WebhookTriggerEvents.BOOKING_PAID and its own subscriber options. It doesn't go through handleWebhookTrigger's dry-run/tracing wrapper at all — it sends the resulting payload itself. This is the proof that getWebhooks() is a general-purpose lookup keyed by trigger name, usable by any feature, not a booking-flow internal.
walkthrough

BotDetectionService.checkBotDetection: a guard that mostly says nothing

packages/features/bot-detection/BotDetectionService.ts · lines 2889
lines 28–36

Two fast exits before any real work: instanceHasBotIdEnabled() checks a Vercel env flag so the whole check is a no-op unless this deployment has opted in, and eventTypeId being absent (some callers of this class might not supply one) also skips the check outright.

lines 38–53

eventTypeId comes straight from req.body, so it's validated as a positive integer before being trusted, throwing an ErrorWithCode(BadRequest) otherwise. Then eventTypeRepository.getTeamIdByEventTypeId fetches only the teamId column — and if the event type has no team, the function returns early: bot detection here is scoped to team events, not personal ones.

lines 55–63

featuresRepository.checkIfTeamHasFeature looks up a per-team feature flag named 'booker-botid'. This is the rollout lever: bot detection can be turned on for one team, or globally, without a deploy.

lines 65–89

Only once every earlier gate has passed does the service call checkBotId (from the botid/server package) with the request's headers, log the full verification result, and — if verification.isBot is true — throw an HttpError with statusCode 403. That thrown error, not a return value, is how this guard tells the caller to stop.

  async checkBotDetection(config: BotDetectionConfig): Promise<void> {    if (!this.instanceHasBotIdEnabled()) return;    const { eventTypeId, headers } = config;    // If no eventTypeId provided, skip bot detection    if (!eventTypeId) {      return;    }    if (!Number.isInteger(eventTypeId) || eventTypeId <= 0) {      throw new ErrorWithCode(        ErrorCode.BadRequest,        `Invalid eventTypeId: ${eventTypeId}. Must be a positive integer.`      );    }    // Fetch only the teamId from the event type    const eventType = await this.eventTypeRepository.getTeamIdByEventTypeId({      id: eventTypeId,    });    // Only check for team events    if (!eventType?.teamId) {      return;    }    // Check if BotID feature is enabled for this team (also checks global scope - enabling on all teams)    const isBotIDEnabled = await this.featuresRepository.checkIfTeamHasFeature(      eventType.teamId,      "booker-botid"    );    if (!isBotIDEnabled) {      return;    }    // Perform bot detection    const verification = await checkBotId({      advancedOptions: {        headers,      },    });    // Log verification results with detailed information    const verificationDetails = {      isBot: verification.isBot,      isHuman: verification.isHuman,      isVerifiedBot: verification.isVerifiedBot,      verifiedBotName: verification.verifiedBotName,      verifiedBotCategory: verification.verifiedBotCategory,      bypassed: verification.bypassed,      classificationReason: verification.classificationReason,      teamId: eventType.teamId,      eventTypeId,    };    if (verification.isBot) {      log.warn("Bot detected - blocking request", verificationDetails);      throw new HttpError({ statusCode: 403, message: "Access denied" });    }  }
step 1 of 4
import { HttpError } from "./http-error";import type { RateLimitHelper } from "./rateLimit";import { rateLimiter } from "./rateLimit";export async function checkRateLimitAndThrowError({  rateLimitingType = "core",  identifier,  onRateLimiterResponse,  opts,}: RateLimitHelper) {  const response = await rateLimiter()({ rateLimitingType, identifier, opts });  if (onRateLimiterResponse) onRateLimiterResponse(response);  const { success, reset } = response;  if (!success) {    const convertToSeconds = (ms: number) => Math.floor(ms / 1000);    const secondsToWait = Math.max(0, convertToSeconds(reset - Date.now()));    throw new HttpError({      statusCode: 429,      message: `Rate limit exceeded. Try again in ${secondsToWait} seconds.`,    });  }  return response;}
packages/lib/checkRateLimitAndThrowError.ts · lines 123
Figure II · The identifier passed in from /api/book/event.ts is createBooking:${piiHasher.hash(userIp)} — a hashed IP, namespaced per rateLimitingType ('core' here) so different call sites can't collide on the same bucket. On failure it computes secondsToWait from the limiter's reset timestamp and throws an HttpError with statusCode 429 — the same failure convention BotDetectionService uses with 403: signal failure by throwing, not by returning a boolean the caller has to remember to check.
checkpoint

Check your understanding

Answered in place — nothing is graded, everything is explained. 0 / 2 passed

A new feature wants to notify external systems whenever a form is submitted (FORM_SUBMITTED). What's the minimal way to reuse the existing webhook delivery mechanism?

Why does checkRateLimitAndThrowError throw an HttpError with statusCode 429 instead of returning a boolean for the caller to check?