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.
getWebhooks(): resolving subscribers by trigger name
packages/features/webhooks/lib/getWebhooks.ts · lines 18–89userId, 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.
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.
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'.
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);};
// 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 160–167BotDetectionService.checkBotDetection: a guard that mostly says nothing
packages/features/bot-detection/BotDetectionService.ts · lines 28–89Two 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.
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.
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.
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" }); } }
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 1–23createBooking:${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.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?