payable payable Docs
Coming soon

NestJS Adapter

@akira-io/payable/nest exposes a NestJS dynamic module, a controller, an exception filter, and DI tokens. Import the module with PayableModule.forRoot(payable, options?).

Purpose

Expose the Payable facade as a single NestJS controller, mapping route handlers to facade calls and PayableError instances to HTTP responses through an exception filter. The module provides the Payable instance and adapter options through DI tokens.

Module

@Module({})
export class PayableModule {
  static forRoot(payable: Payable, options: NestPayableOptions = {}): DynamicModule;
}

forRoot returns a DynamicModule that registers:

  • controllers: [PayableController]
  • providers:
    • { provide: PAYABLE_INSTANCE, useValue: payable }
    • { provide: PAYABLE_OPTIONS, useValue: options }
    • PayableExceptionFilter
interface NestPayableOptions {
  webhookSignatureHeader?: string; // default: 'stripe-signature'
}

DI tokens

The injection tokens and the request shape:

export const PAYABLE_INSTANCE = Symbol('payable.instance');
export const PAYABLE_OPTIONS = Symbol('payable.options');

export interface PayableHttpRequest {
  headers: IncomingHttpHeaders;
  body?: unknown;
  rawBody?: Buffer;
}

PayableController injects both tokens:

constructor(
  @Inject(PAYABLE_INSTANCE) private readonly payable: Payable,
  @Inject(PAYABLE_OPTIONS) private readonly options: NestPayableOptions,
) {}

Controller routes

PayableController is decorated with @Controller() (no path prefix) and @UseFilters(PayableExceptionFilter). Routes are defined with method decorators and @HttpCode:

MethodPathStatusHandlerBehavior
POSTwebhooks200webhookDefault-provider webhook receipt
POSTwebhooks/:provider200webhookForProviderProvider-scoped webhook receipt
POSTcheckout201checkoutCreate a subscription checkout session
POSTsubscriptions/:name/cancel200cancelCancel at period end
POSTsubscriptions/:name/cancel-now200cancelNowCancel immediately
POSTsubscriptions/:name/resume200resumeResume a canceled subscription
POSTsubscriptions/:name/swap200swapSwap to a new price
POSTcustomers501customersReserved; throws NOT_IMPLEMENTED
GETinvoices501invoicesReserved; throws NOT_IMPLEMENTED
GETpayments501paymentsReserved; throws NOT_IMPLEMENTED
POSTrefunds501refundsReserved; throws NOT_IMPLEMENTED

Scope and parity vs Express

The NestJS adapter is a single controller. Its scope matches Fastify, not Express:

  • Implemented: webhooks, checkout, subscription management (cancel, cancel-now, resume, swap).
  • Reserved (throw PayableError.notImplemented(...), mapped to 501): customers, invoices, payments, and refunds.
@Post('refunds')
refunds(): never {
  throw PayableError.notImplemented('POST /refunds');
}

Only Express implements POST /refunds. In NestJS (as in Fastify) /refunds returns 501. To process refunds, use the Express adapter or call payable.refund(...) directly.

Like Fastify, the controller casts request bodies to TypeScript interfaces rather than validating with the shared Zod schemas, so malformed bodies are not rejected with VALIDATION_FAILED.

Raw body requirement

Webhook signature verification needs the raw request body. The controller reads it from request.rawBody, falling back to a string body, then an empty string:

private extractPayload(request: PayableHttpRequest): string {
  if (Buffer.isBuffer(request.rawBody)) {
    return request.rawBody.toString('utf8');
  }
  if (typeof request.body === 'string') {
    return request.body;
  }
  return '';
}

request.rawBody is populated by NestJS only when the application is bootstrapped with rawBody: true. Without it, signature verification receives an empty payload and fails. Bootstrap the app like this:

import { NestFactory } from '@nestjs/core';

const app = await NestFactory.create(AppModule, { rawBody: true });

The signature is read from options.webhookSignatureHeader (default stripe-signature); headers are flattened with flattenHeaders before reaching payable.receiveWebhook(...).

Exception filter and error mapping

PayableExceptionFilter is @Catch()-all and delegates to the shared mappers:

@Catch()
export class PayableExceptionFilter implements ExceptionFilter {
  catch(error: unknown, host: ArgumentsHost): void {
    const response = host.switchToHttp().getResponse<HttpResponse>();
    response.status(payableErrorStatus(error)).json(payableErrorBody(error));
  }
}

It uses the same STATUS_BY_CODE table and { error, message } body shape documented in docs/adapters/22-express.md. An InvalidWebhookSignatureError maps to 400 with error: 'INVALID_WEBHOOK_SIGNATURE', and a plain TypeError maps to 500 with error: 'INTERNAL_ERROR'.

No built-in authentication

The controller installs no guards. Checkout and subscription routes are unprotected; webhook routes are protected only by provider signature verification. Add NestJS guards and verify ownership of the billable yourself. See docs/26-security.md.

Module example

import 'reflect-metadata';
import { Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { createPayable } from '@akira-io/payable';
import { PayableModule } from '@akira-io/payable/nest';

const payable = createPayable({ providers: { stripe: stripeProvider }, storage });

@Module({
  imports: [PayableModule.forRoot(payable)],
})
export class AppModule {}

async function bootstrap() {
  const app = await NestFactory.create(AppModule, { rawBody: true });
  await app.listen(3000);
}

With a custom signature header:

PayableModule.forRoot(payable, { webhookSignatureHeader: 'paddle-signature' });

@nestjs/common and reflect-metadata are optional peer dependencies; install both to use this adapter.

Edge cases

  • Forgetting rawBody: true yields an empty webhook payload and a verification failure.
  • Multiple registered providers with no :provider route param surface WEBHOOK_PROVIDER_AMBIGUOUS (400) from the facade.
  • The reserved 501 routes are intentional placeholders.