NestJS
This page adds Hookgres to a NestJS app: a global module that provides one instance, runs migrations on startup and starts the worker, a transactional send from a controller, and endpoint management routes for your customers.
npm install hookgres pgThe Hookgres module
Provide the pool and the Hookgres instance once, from a global module, and inject them where you need them. If your app already provides a pg.Pool, inject that instead of creating a second one.
The module also ties Hookgres to Nest's lifecycle:
onModuleInitrunsmigrate()before the app starts listening.onApplicationBootstrapstarts the worker.onModuleDestroystops it and waits for in-flight webhook requests.onApplicationShutdownends the pool, after Nest has closed the HTTP server.
// src/hookgres.module.ts
import {
Global,
Inject,
Logger,
Module,
type OnApplicationBootstrap,
type OnApplicationShutdown,
type OnModuleDestroy,
type OnModuleInit,
} from "@nestjs/common";
import { Hookgres } from "hookgres";
import pg from "pg";
export const PG_POOL = "PG_POOL";
const logger = new Logger("Hookgres");
@Global()
@Module({
providers: [
{
provide: PG_POOL,
useFactory: () => {
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
});
// A dropped idle connection emits "error" on the pool. Without a
// listener, it crashes the process. Hookgres only adds one to pools
// it creates.
pool.on("error", (error) => logger.error(error));
return pool;
},
},
{
provide: Hookgres,
inject: [PG_POOL],
useFactory: (pool: pg.Pool) =>
new Hookgres({
pool,
blockPrivateAddresses: true, // customers choose the URLs; keep them off your network
onError: (error) => logger.error(error),
}),
},
],
exports: [PG_POOL, Hookgres],
})
export class HookgresModule
implements
OnModuleInit,
OnApplicationBootstrap,
OnModuleDestroy,
OnApplicationShutdown
{
constructor(
@Inject(PG_POOL) private readonly pool: pg.Pool,
private readonly hookgres: Hookgres,
) {}
async onModuleInit() {
await this.hookgres.migrate();
}
onApplicationBootstrap() {
this.hookgres.start();
}
async onModuleDestroy() {
await this.hookgres.stop();
}
async onApplicationShutdown() {
await this.pool.end();
}
}Nest only runs the shutdown hooks on SIGTERM if you enable them:
// src/main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module.js";
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(process.env.PORT ?? 3000);// src/app.module.ts
import { Module } from "@nestjs/common";
import { HookgresModule } from "./hookgres.module.js";
import { InvoicesController } from "./invoices.controller.js";
import { WebhookEndpointsController } from "./webhook-endpoints.controller.js";
@Module({
imports: [HookgresModule],
controllers: [InvoicesController, WebhookEndpointsController],
})
export class AppModule {}migrate() is idempotent and takes an advisory lock, so every instance can run it on startup. If the app's database role can't CREATE, run it from a deploy step instead; see Migrations.
To run the worker in a separate process, start it from a second entry point with NestFactory.createApplicationContext(AppModule), and skip start() in the web process, for example behind an environment variable. See Running workers.
The tenant
The controllers below take the tenant from the request with a small parameter decorator. It assumes your auth guard has already put the signed-in user on the request; adjust the property to wherever your auth keeps the tenant.
// src/tenant-id.decorator.ts
import { createParamDecorator, type ExecutionContext } from "@nestjs/common";
/** The signed-in user's tenant, set on the request by your auth guard. */
export const TenantId = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): string =>
ctx.switchToHttp().getRequest().user.organizationId,
);Send inside a transaction
Queue the webhook in the same transaction as the change it describes. If the transaction rolls back, no webhook is sent.
// src/transaction.ts
import type pg from "pg";
/** Runs `fn` in a transaction and commits only if it doesn't throw. */
export async function transaction<T>(
pool: pg.Pool,
fn: (client: pg.PoolClient) => Promise<T>,
): Promise<T> {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK").catch(() => {}); // keep the original error
throw error;
} finally {
client.release();
}
}// src/invoices.controller.ts
import {
Controller,
HttpCode,
Inject,
NotFoundException,
Param,
Post,
} from "@nestjs/common";
import { Hookgres } from "hookgres";
import pg from "pg";
import { PG_POOL } from "./hookgres.module.js";
import { TenantId } from "./tenant-id.decorator.js";
import { transaction } from "./transaction.js";
@Controller("api/invoices")
export class InvoicesController {
constructor(
@Inject(PG_POOL) private readonly pool: pg.Pool,
private readonly hookgres: Hookgres,
) {}
@Post(":id/pay")
@HttpCode(200)
async pay(@Param("id") id: string, @TenantId() tenantId: string) {
const invoice = await transaction(this.pool, async (client) => {
const { rows } = await client.query(
`UPDATE invoices SET status = 'paid'
WHERE id = $1 AND tenant_id = $2
RETURNING id, amount`,
[id, tenantId],
);
const invoice = rows[0];
if (!invoice) return null;
await this.hookgres.send(
{
tenantId,
eventType: "invoice.paid",
payload: { invoiceId: invoice.id, amount: invoice.amount },
idempotencyKey: `invoice.paid:${invoice.id}`,
},
{ client },
);
return invoice;
});
if (!invoice) throw new NotFoundException();
return { id: invoice.id, status: "paid" };
}
}The idempotencyKey means paying the same invoice twice queues one webhook. With TypeORM, Prisma or another ORM, pass the transaction's underlying pg client; see Sending webhooks.
Endpoint management routes
These routes let a customer manage their own endpoints from your settings page. Two rules keep tenants apart:
tenantIdcomes from the session (@TenantId()), never from the request body or URL.- Hookgres looks endpoints up by id alone, so every route that takes an id loads the endpoint with
getEndpoint()and checks itstenantIdfirst. A mismatch answers404, the same as a missing endpoint.
The secret is returned once, when the endpoint is created or its secret is rotated, and never in lists.
// src/webhook-endpoints.controller.ts
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
NotFoundException,
Param,
Patch,
Post,
} from "@nestjs/common";
import { type Endpoint, Hookgres } from "hookgres";
import { TenantId } from "./tenant-id.decorator.js";
interface CreateEndpointBody {
url: string;
description?: string;
eventTypes?: string[] | null;
}
interface UpdateEndpointBody {
url?: string;
description?: string | null;
eventTypes?: string[] | null;
disabled?: boolean;
}
/** The endpoint's tenant-facing fields. Leaves out the secret. */
function view(endpoint: Endpoint) {
return {
id: endpoint.id,
url: endpoint.url,
description: endpoint.description,
eventTypes: endpoint.eventTypes,
disabled: endpoint.disabled,
disabledReason: endpoint.disabledReason,
createdAt: endpoint.createdAt,
};
}
@Controller("api/webhook-endpoints")
export class WebhookEndpointsController {
constructor(private readonly hookgres: Hookgres) {}
/** The endpoint if it belongs to the tenant. Throws 404 otherwise. */
private async ownEndpoint(id: string, tenantId: string) {
const endpoint = await this.hookgres.getEndpoint(id);
if (endpoint?.tenantId !== tenantId) throw new NotFoundException();
return endpoint;
}
@Get()
async list(@TenantId() tenantId: string) {
const endpoints = await this.hookgres.listEndpoints(tenantId);
return endpoints.map(view);
}
@Post()
async create(
@TenantId() tenantId: string,
@Body() body: CreateEndpointBody,
) {
let endpoint: Endpoint;
try {
endpoint = await this.hookgres.createEndpoint({
tenantId,
url: body.url,
description: body.description,
eventTypes: body.eventTypes ?? null,
});
} catch (error) {
// Validation errors are plain Errors; database errors carry a `code`.
if (!(error instanceof Error) || "code" in error) throw error;
throw new BadRequestException(error.message);
}
return { ...view(endpoint), secret: endpoint.secret };
}
@Patch(":id")
async update(
@Param("id") id: string,
@TenantId() tenantId: string,
@Body() body: UpdateEndpointBody,
) {
const endpoint = await this.ownEndpoint(id, tenantId);
try {
const updated = await this.hookgres.updateEndpoint(endpoint.id, {
url: body.url,
description: body.description,
eventTypes: body.eventTypes,
disabled: body.disabled,
});
return updated && view(updated);
} catch (error) {
// Validation errors are plain Errors; database errors carry a `code`.
if (!(error instanceof Error) || "code" in error) throw error;
throw new BadRequestException(error.message);
}
}
@Delete(":id")
@HttpCode(204)
async remove(@Param("id") id: string, @TenantId() tenantId: string) {
const endpoint = await this.ownEndpoint(id, tenantId);
await this.hookgres.deleteEndpoint(endpoint.id);
}
@Post(":id/rotate-secret")
@HttpCode(200)
async rotateSecret(@Param("id") id: string, @TenantId() tenantId: string) {
const endpoint = await this.ownEndpoint(id, tenantId);
const rotated = await this.hookgres.rotateEndpointSecret(endpoint.id);
return { secret: rotated?.secret };
}
}createEndpoint() and updateEndpoint() throw for an invalid or blocked URL and for an empty eventTypes array, which these routes return as 400 with the message. Database errors, which carry a code, are rethrown and become 500s. Validate the bodies with ValidationPipe and DTO classes as usual; the interfaces here only give them types. Only the fields you pass to updateEndpoint() change; undefined ones are left alone. After a rotation, Hookgres signs with both the old and new secret for 24 hours; see Secret rotation.
Receiving webhooks
Verification needs the raw request body. Create the app with NestFactory.create(AppModule, { rawBody: true }), then pass req.rawBody to verifyWebhook():
@Post("webhooks")
@HttpCode(204)
receive(@Req() req: RawBodyRequest<Request>) {
try {
const event = verifyWebhook({
secret,
headers: req.headers,
body: req.rawBody!,
});
// handle event.type and event.data
} catch {
throw new BadRequestException("invalid signature");
}
}Request here is Express's. See Verifying webhooks for deduplication and how quickly to respond.