BirrJS
Concepts

Plugins

Extend BirrJS with event handlers, endpoints, and lifecycle hooks.

Plugins let you add functionality to BirrJS without modifying its core. Each plugin has an id, optional endpoints, optional event handlers, and optional lifecycle hooks.

Plugin interface

import type { BirrJSContext } from "@birrjs/core";

interface BirrJSPlugin {
  id: string;

  /** HTTP endpoints merged into the BirrJS router */
  endpoints?: Record<string, unknown>;

  /** Called before a subscription is created */
  onBeforeSubscribe?: (hookCtx: {
    customerId: string;
    plan: NormalizedPlan;
    customerEmail?: string;
    ip?: string;
  }) => Promise<void>;

  /** Called after checkout is ready (before redirect) */
  onCheckoutReady?: (hookCtx: {
    customerId: string;
    planId: string;
    subscriptionId: string;
    checkoutUrl: string;
    txRef: string;
  }) => Promise<void>;

  /** Event handlers for subscription lifecycle events */
  onEvent?: {
    "subscription.activated"?:
      (payload: { customerId: string; subscriptionId: string; planId: string; startedAt: Date | null; expiresAt: Date | null },
      ctx: BirrJSContext) => Promise<void> | void;
    "subscription.cancelled"?:
      (payload: { customerId: string; subscriptionId: string; planId: string; canceledAt: Date | null; endedAt: Date | null },
      ctx: BirrJSContext) => Promise<void> | void;
    "subscription.expired"?:
      (payload: { customerId: string; subscriptionId: string; planId: string; expiredAt: Date },
      ctx: BirrJSContext) => Promise<void> | void;
    "subscription.reminder"?:
      (payload: { customerId: string; subscriptionId: string; planId: string; planName: string; customerEmail: string | null; customerPhone: string | null; expiresAt: Date; daysUntilExpiry: number },
      ctx: BirrJSContext) => Promise<void> | void;
    /** Catch-all wildcard — fires for every event */
    "*"?: (event: {
      name: "subscription.activated" | "subscription.cancelled" | "subscription.expired" | "subscription.reminder";
      payload: unknown;
      ctx: BirrJSContext;
    }) => Promise<void> | void;
  };
}

Lifecycle hooks

Plugins can intercept the subscription flow at two points:

HookWhenCan abort?
onBeforeSubscribeBefore a subscription record is createdYes (throw to abort)
onCheckoutReadyAfter checkout URL is generated, before redirectNo (errors logged)
const myPlugin: BirrJSPlugin = {
  id: "validate-email",
  onBeforeSubscribe: async (ctx) => {
    if (ctx.customerEmail?.endsWith("@spam.com")) {
      throw new Error("Blocked email domain");
    }
  },
};

Event handlers

Fired automatically when BirrJS processes webhooks or cron sweeps. Plugin event handlers receive the full BirrJSContext — access to the database, logger, and queries.

EventWhenFired from
subscription.activatedPayment confirmed, subscription becomes activeWebhook endpoint, callback handler
subscription.cancelledSubscription is cancelledWebhook endpoint
subscription.expiredSubscription period endsCron sweep
subscription.reminderSubscription approaching expiryCron sweep (daily)
"*"Every event (catch-all)All of the above

Wildcard handler

Use "*" to handle every event in one function:

const myPlugin: BirrJSPlugin = {
  id: "logger",
  onEvent: {
    "*": async (event) => {
      console.log(`Event: ${event.name}`, event.payload);
    },
  },
};

Using the on option (user-level)

For simple reactions without building a full plugin, pass handlers directly to createBirr():

createBirr({
  on: {
    "subscription.activated": async (payload) => {
      await myEmailService.send(payload.customerId);
    },
    "*": async (event) => {
      console.log(event.name, event.payload);
    },
  },
});

The difference between plugin onEvent and the user on option:

Plugin onEventUser on option
Signature(payload, ctx) => void(payload) => void
Has access to ctxYes (DB, logger, queries)No
Best forPlugins that need BirrJS internalsExternal API calls (email, SMS)
Wildcard signature(event) => void with ctx(event) => void without ctx

Both are fired together from the same call sites — you can use on for simple handlers and plugins for complex ones simultaneously.

Endpoints

Plugins can add HTTP endpoints to the BirrJS router. These are standard better-call endpoints merged at the same base path.

import type { BirrJSPlugin } from "@birrjs/core";

const myPlugin: BirrJSPlugin = {
  id: "my-plugin",
  endpoints: {
    "/health": myHealthEndpoint,
  },
};

Registering plugins

import { createBirr } from "@birrjs/core";

createBirr({
  plugins: [myPlugin],
});

Building a plugin

Create a function that returns a BirrJSPlugin:

plugins/slack-notifier.ts
import type { BirrJSPlugin, BirrJSContext } from "@birrjs/core";

interface SlackConfig {
  webhookUrl: string;
}

export function slackNotifier(config: SlackConfig): BirrJSPlugin {
  return {
    id: "slack-notifier",
    onEvent: {
      "subscription.activated": async (payload, ctx) => {
        const customer = await ctx.queries.getCustomer(payload.customerId);
        await fetch(config.webhookUrl, {
          method: "POST",
          body: JSON.stringify({
            text: `New subscription: ${customer?.name ?? "Unknown"} activated ${payload.planId}`,
          }),
        });
      },
    },
  };
}

For a complete walkthrough, see the Your First Plugin guide.