chargebee
Version:
A library for integrating with Chargebee.
473 lines (472 loc) • 17.9 kB
JavaScript
import { EventEmitter } from 'node:events';
import { basicAuthValidator } from './auth.js';
import { WebhookEventType, WebhookContentType } from './eventType.js';
import { WebhookError, WebhookAuthenticationError, WebhookPayloadValidationError, WebhookPayloadParseError, } from './errors.js';
export { WebhookEventType, WebhookContentType };
export { WebhookError, WebhookAuthenticationError, WebhookPayloadValidationError, WebhookPayloadParseError, };
/**
* Webhook handler for processing Chargebee webhook events.
*
* Extends Node.js `EventEmitter` to provide a familiar, event-driven API for
* handling webhooks. Supports type-safe event listeners with full TypeScript
* autocomplete for all Chargebee event types.
*
* @typeParam ReqT - Framework-specific request type (e.g., `express.Request`)
* @typeParam ResT - Framework-specific response type (e.g., `express.Response`)
*
* @remarks
* **Lifecycle Warning:** Event listeners persist for the lifetime of the handler
* instance. Register handlers once at application startup, not per-request.
*
* @example Basic Usage with Express
* ```typescript
* import express from 'express';
* import { createHandler, basicAuthValidator } from 'chargebee/webhook';
*
* const app = express();
* app.use(express.json());
*
* // Create handler with Basic Auth
* const webhookHandler = createHandler({
* requestValidator: basicAuthValidator((u, p) => u === 'admin' && p === 'secret'),
* });
*
* // Register event listeners ONCE at startup (not per-request!)
* webhookHandler.on('subscription_created', async ({ event, response }) => {
* console.log('New subscription:', event.content.subscription.id);
* response?.status(200).send('OK');
* });
*
* webhookHandler.on('error', (error, { response }) => {
* if (error instanceof WebhookAuthenticationError) {
* response?.status(401).send('Unauthorized');
* } else if (error instanceof WebhookPayloadValidationError || error instanceof WebhookPayloadParseError) {
* response?.status(400).send('Bad Request');
* } else {
* response?.status(500).send('Internal Server Error');
* }
* });
*
* // Route handler
* app.post('/webhooks', async (req, res) => {
* await webhookHandler.handle({
* body: req.body,
* headers: req.headers,
* request: req,
* response: res,
* });
* });
* ```
*
* @example Available Event Types
* ```typescript
* // Subscription events
* handler.on('subscription_created', ({ event }) => { ... });
* handler.on('subscription_changed', ({ event }) => { ... });
* handler.on('subscription_cancelled', ({ event }) => { ... });
*
* // Customer events
* handler.on('customer_created', ({ event }) => { ... });
* handler.on('customer_changed', ({ event }) => { ... });
*
* // Payment events
* handler.on('payment_succeeded', ({ event }) => { ... });
* handler.on('payment_failed', ({ event }) => { ... });
*
* // Special events
* handler.on('unhandled_event', ({ event }) => {
* console.log('Unhandled event type:', event.event_type);
* });
*
* handler.on('error', (error, { response }) => {
* if (error instanceof WebhookAuthenticationError) {
* response?.status(401).send('Unauthorized');
* } else if (error instanceof WebhookPayloadValidationError || error instanceof WebhookPayloadParseError) {
* response?.status(400).send('Bad Request');
* } else {
* response?.status(500).send('Internal Server Error');
* }
* });
* ```
*
* @example Async Handlers
* ```typescript
* // Handlers can be async - errors are captured and emitted to 'error' event
* handler.on('subscription_created', async ({ event, response }) => {
* await saveToDatabase(event.content.subscription);
* await sendWelcomeEmail(event.content.customer);
* response?.status(200).send('OK');
* });
* ```
*/
export class WebhookHandler extends EventEmitter {
/**
* Creates a new WebhookHandler instance.
*
* @param options - Optional configuration options
*
* @example
* ```typescript
* // Without authentication (not recommended for production)
* const handler = new WebhookHandler();
*
* // With Basic Auth
* const handler = new WebhookHandler({
* requestValidator: basicAuthValidator((u, p) => u === 'user' && p === 'pass'),
* });
* ```
*/
constructor(options) {
super({ captureRejections: true });
this._noAuthWarningShown = false;
this._requestValidator = options === null || options === void 0 ? void 0 : options.requestValidator;
}
/**
* Gets the current request validator function.
*
* @returns The configured validator or `undefined` if no authentication is set
*
* @example
* ```typescript
* if (handler.requestValidator) {
* console.log('Authentication is configured');
* }
* ```
*/
get requestValidator() {
return this._requestValidator;
}
/**
* Sets or updates the request validator function.
*
* Use this to configure authentication after handler creation,
* or to change validators at runtime.
*
* @param validator - The validator function, or `undefined` to disable authentication
*
* @example
* ```typescript
* // Set up Basic Auth after creation
* handler.requestValidator = basicAuthValidator((u, p) => u === 'admin' && p === 'secret');
*
* // Custom header validation
* handler.requestValidator = (headers) => {
* if (headers['x-webhook-secret'] !== process.env.WEBHOOK_SECRET) {
* throw new Error('Invalid webhook secret');
* }
* };
*
* // Disable authentication (not recommended)
* handler.requestValidator = undefined;
* ```
*/
set requestValidator(validator) {
this._requestValidator = validator;
}
/**
* Registers an event listener for a specific webhook event type.
*
* This method is inherited from Node.js `EventEmitter` but is documented here
* for clarity on available Chargebee webhook events.
*
* @param eventName - The Chargebee event type to listen for (e.g., `'subscription_created'`)
* @param listener - Callback function invoked when the event occurs
* @returns This handler instance for method chaining
*
* @remarks
* **Memory Leak Warning:** Listeners persist for the handler's lifetime.
* Always register listeners once at application startup, never inside
* request handlers or loops.
*
* @example Available Events
* ```typescript
* // Chargebee business events
* handler.on('subscription_created', ({ event, response }) => { ... });
* handler.on('subscription_changed', ({ event, response }) => { ... });
* handler.on('subscription_cancelled', ({ event, response }) => { ... });
* handler.on('customer_created', ({ event, response }) => { ... });
* handler.on('payment_succeeded', ({ event, response }) => { ... });
* handler.on('invoice_generated', ({ event, response }) => { ... });
* // ... and many more - see WebhookEventType enum for full list
*
* // Special events
* handler.on('unhandled_event', ({ event }) => {
* // Called when no listener exists for the event type
* console.log('Unhandled:', event.event_type);
* });
*
* handler.on('error', (error, { response }) => {
* // Called on validation errors, parse errors, or handler errors
* if (error instanceof WebhookAuthenticationError) {
* response?.status(401).send('Unauthorized');
* } else if (error instanceof WebhookPayloadValidationError || error instanceof WebhookPayloadParseError) {
* response?.status(400).send('Bad Request');
* } else {
* response?.status(500).send('Internal Server Error');
* }
* });
* ```
*
* @example Correct Usage
* ```typescript
* // ✅ GOOD: Register once at startup
* const handler = createHandler();
* handler.on('subscription_created', handleSubscription);
* handler.on('error', handleError);
*
* app.post('/webhooks', async (req, res) => {
* await handler.handle({ body: req.body, headers: req.headers, response: res });
* });
* ```
*
* @example Incorrect Usage
* ```typescript
* // ❌ BAD: Don't register inside request handlers - causes memory leak!
* app.post('/webhooks', async (req, res) => {
* handler.on('subscription_created', async () => { ... }); // Memory leak!
* await handler.handle({ ... });
* });
* ```
*/
// Note: on() is inherited from EventEmitter with proper typing via WebhookEventMap
/**
* Handles an incoming webhook request from Chargebee.
*
* This method:
* 1. Validates the request using the configured `requestValidator` (if any)
* 2. Parses the request body (if it's a string)
* 3. Validates required fields (`event_type`, `id`)
* 4. Emits the appropriate event to registered listeners
*
* @param options - The webhook request options
* @returns A promise that resolves when the event has been emitted
*
* @throws Error if no `error` listener is registered and an error occurs
*
* @remarks
* **Async Behavior:** This method emits events but does not wait for async
* listeners to complete. Errors in async listeners are captured via
* `captureRejections` and emitted to the `error` event.
*
* **Response Handling:** The handler does NOT automatically send HTTP responses.
* Your event listeners must call `response.status(200).send('OK')` or similar.
*
* @example Express Integration
* ```typescript
* app.post('/webhooks', async (req, res) => {
* try {
* await handler.handle({
* body: req.body,
* headers: req.headers,
* request: req,
* response: res,
* });
* } catch (error) {
* // Only reached if no 'error' listener is registered
* res.status(500).send('Internal error');
* }
* });
* ```
*
* @example Fastify Integration
* ```typescript
* fastify.post('/webhooks', async (request, reply) => {
* await handler.handle({
* body: request.body,
* headers: request.headers,
* request,
* response: reply,
* });
* });
* ```
*
* @example Raw Node.js HTTP
* ```typescript
* http.createServer(async (req, res) => {
* if (req.method === 'POST' && req.url === '/webhooks') {
* const body = await getBody(req);
* await handler.handle({ body, headers: req.headers, response: res });
* }
* });
* ```
*/
async handle(options) {
const { body, headers, request, response } = options;
try {
if (this._requestValidator) {
if (!headers) {
console.warn('[chargebee] Warning: Request validator is configured but no headers were passed. ' +
'Authentication check skipped. If this is intentional (no-auth webhook), ' +
'you can remove the requestValidator or ignore this warning.');
}
else {
await this._requestValidator(headers);
}
}
else if (!this._noAuthWarningShown) {
this._noAuthWarningShown = true;
console.warn('[chargebee] Warning: No webhook authentication configured. ' +
'Consider using basicAuthValidator() or a custom requestValidator for production. ' +
'See: https://www.chargebee.com/docs/billing/2.0/site-configuration/webhook_settings#basic-authentication');
}
let event;
try {
event =
typeof body === 'string' ? JSON.parse(body) : body;
}
catch (parseErr) {
const parseError = parseErr instanceof Error ? parseErr : new Error(String(parseErr));
throw new WebhookPayloadParseError(`Failed to parse webhook body: ${parseError.message}`, typeof body === 'string' ? body : undefined);
}
// Validate required fields
if (!event || typeof event !== 'object' || Array.isArray(event)) {
throw new WebhookPayloadValidationError('Invalid webhook payload: body must be a JSON object');
}
if (!event.event_type || typeof event.event_type !== 'string') {
throw new WebhookPayloadValidationError('Invalid webhook payload: missing or invalid event_type');
}
if (!event.id) {
throw new WebhookPayloadValidationError('Invalid webhook payload: missing event id');
}
const context = {
event,
request,
response,
};
const eventType = event.event_type;
if (this.listenerCount(eventType) > 0) {
this.emit(eventType, context);
}
else {
this.emit('unhandled_event', context);
}
}
catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
if (this.listenerCount('error') === 0) {
console.warn('[chargebee] Webhook error with no handler:', error.message);
throw error;
}
this.emit('error', error, { request, response });
}
}
}
/**
* Creates a new WebhookHandler with custom configuration.
*
* This is the recommended factory function for creating webhook handlers.
* Use this when you need explicit control over authentication configuration.
*
* @typeParam ReqT - Framework-specific request type (e.g., `express.Request`)
* @typeParam ResT - Framework-specific response type (e.g., `express.Response`)
*
* @param options - Optional configuration for the handler
* @returns A new WebhookHandler instance
*
* @remarks
* For multi-route or multi-tenant scenarios, create separate handler instances
* to maintain isolation and avoid event listener conflicts.
*
* @example Basic Auth Configuration
* ```typescript
* import { createHandler, basicAuthValidator } from 'chargebee/webhook';
*
* const handler = createHandler({
* requestValidator: basicAuthValidator((username, password) => {
* return username === 'admin' && password === 'secret';
* }),
* });
* ```
*
* @example Custom Authentication
* ```typescript
* const handler = createHandler({
* requestValidator: async (headers) => {
* const signature = headers['x-chargebee-signature'];
* const isValid = await verifySignature(signature);
* if (!isValid) throw new Error('Invalid signature');
* },
* });
* ```
*
* @example Multi-Route Setup
* ```typescript
* // Separate handlers for different webhook endpoints
* const billingHandler = createHandler({ ... });
* const notificationHandler = createHandler({ ... });
*
* billingHandler.on('subscription_created', handleBillingEvent);
* notificationHandler.on('subscription_created', sendNotification);
*
* app.post('/webhooks/billing', (req, res) => billingHandler.handle({ ... }));
* app.post('/webhooks/notifications', (req, res) => notificationHandler.handle({ ... }));
* ```
*
* @example Without Authentication (Development Only)
* ```typescript
* // ⚠️ Not recommended for production
* const handler = createHandler();
* ```
*/
export function createHandler(options) {
return new WebhookHandler(options);
}
/**
* Creates a WebhookHandler with auto-configured Basic Auth from environment variables.
*
* This is a convenience function that automatically configures Basic Auth
* when the following environment variables are set:
* - `CHARGEBEE_WEBHOOK_USERNAME`
* - `CHARGEBEE_WEBHOOK_PASSWORD`
*
* If these environment variables are not set, the handler is created without
* authentication (a warning will be logged on first webhook handling).
*
* @typeParam ReqT - Framework-specific request type (e.g., `express.Request`)
* @typeParam ResT - Framework-specific response type (e.g., `express.Response`)
*
* @returns A new WebhookHandler instance with optional auto-configured auth
*
* @remarks
* This function is used internally by `chargebee.webhooks` to provide a
* pre-configured handler on the Chargebee instance.
*
* @example Environment-Based Setup
* ```bash
* # .env file
* CHARGEBEE_WEBHOOK_USERNAME=webhook_user
* CHARGEBEE_WEBHOOK_PASSWORD=webhook_secret
* ```
*
* ```typescript
* import { createDefaultHandler } from 'chargebee/webhook';
*
* // Auth is automatically configured from env vars
* const handler = createDefaultHandler();
*
* handler.on('subscription_created', ({ event, response }) => {
* console.log('Subscription:', event.content.subscription.id);
* response?.status(200).send('OK');
* });
* ```
*
* @example Overriding Auto-Configured Auth
* ```typescript
* const handler = createDefaultHandler();
*
* // Override with custom validator if needed
* handler.requestValidator = myCustomValidator;
* ```
*
* @see {@link createHandler} for explicit configuration without environment variables
*/
export function createDefaultHandler() {
const handler = new WebhookHandler();
const username = process.env.CHARGEBEE_WEBHOOK_USERNAME;
const password = process.env.CHARGEBEE_WEBHOOK_PASSWORD;
if (username && password) {
handler.requestValidator = basicAuthValidator((u, p) => u === username && p === password);
}
return handler;
}
export { basicAuthValidator } from './auth.js';