Skip to content
Get started

Typed events ​

By default, send() takes any event type and any payload. Give Hookgres your event types and TypeScript checks every send(): a misspelled event type or a missing field fails to compile, instead of reaching your customers.

There are two ways to do it. Both work without adding a dependency to Hookgres.

You wantUse
Compile-time checks onlyAn event map: Hookgres<Events>
Compile-time and runtime validationschemas (Zod, Valibot, ArkType)

The code on this page runs in Hookgres's CI.

With schemas ​

Write one schema per event type:

ts
import { z } from "zod";

// One schema per event type. Hookgres infers send()'s types from these.
export const schemas = {
  "invoice.paid": z.object({ invoiceId: z.string(), amount: z.number() }),
  "invoice.voided": z.object({ invoiceId: z.string() }),
  "customer.created": z.object({ customerId: z.string() }),
};

Pass them to new Hookgres(). Hookgres infers the types from them, so there's nothing else to declare:

ts
import { Hookgres } from "hookgres";
import type pg from "pg";
import { schemas } from "./schemas.js";

// Create one instance per process and share it.
export function createHookgres(pool: pg.Pool) {
  return new Hookgres({ pool, schemas });
}

export type AppHookgres = ReturnType<typeof createHookgres>;

Then send() is typed and validated:

ts
import { HookgresValidationError } from "hookgres";
import type { AppHookgres } from "./hookgres.js";

export async function markInvoicePaid(
  hookgres: AppHookgres,
  tenantId: string,
  invoiceId: string,
  amount: number,
) {
  const client = await hookgres.pool.connect();
  try {
    await client.query("BEGIN");
    await client.query("UPDATE invoices SET status = 'paid' WHERE id = $1", [
      invoiceId,
    ]);
    // Typed and validated: a wrong field fails to compile, and an invalid
    // value throws before anything is written.
    await hookgres.send(
      { tenantId, eventType: "invoice.paid", payload: { invoiceId, amount } },
      { client },
    );
    await client.query("COMMIT");
  } catch (error) {
    await client.query("ROLLBACK");
    if (error instanceof HookgresValidationError && error.issues) {
      // The message names the failing fields. `issues` has the details and
      // can quote payload values, so log it like a payload.
      console.warn(error.message, error.issues);
    }
    throw error;
  } finally {
    client.release();
  }
}

A payload that doesn't match its event type fails to compile:

ts
await hookgres.send({
  tenantId,
  eventType: "invoice.paid",
  payload: { invoiceId }, // ✗ compile error: amount is missing
});

At runtime, send() validates the payload before writing anything. If it's invalid, the event type has no schema, or the schema throws, send() throws a HookgresValidationError and nothing is queued. Your transaction is still usable, because validation runs no SQL.

For an invalid payload, the error's message names the failing paths, such as Invalid payload for "invoice.paid": payload.amount failed the schema, and issues holds the schema library's issues, each with a message and a path. Some libraries quote the received value in their issue messages, so treat issues like the payload itself: fine for your logs if payloads are, but don't return it to API callers. The message never includes payload values.

Hookgres stores and delivers the schema's output, so transforms run before the payload is stored. A Zod or Valibot object drops keys it doesn't declare, so a field you didn't mean to send never reaches a customer. Loose objects (z.looseObject, v.looseObject) and ArkType objects keep them by default. Each payload is validated once per send().

Any library that implements Standard Schema works: Zod 3.24+ and 4, Valibot 1, ArkType 2 and others. You can mix them in one schemas object.

Endpoint filters ​

With schemas, createEndpoint() and updateEndpoint() also reject eventTypes that match no event type you declared, such as "invoice.piad", or "order.*" when there's no order. event. These usually come from your customers, so return the error as a 400. The message doesn't list your event types. null (every event type) is always allowed.

With an event map ​

If you only want compile-time checks, pass a type instead. Nothing changes at runtime:

ts
import { Hookgres } from "hookgres";
import type pg from "pg";

// Compile-time checks only: nothing is validated at runtime.
export type Events = {
  "invoice.paid": { invoiceId: string; amount: number };
  "invoice.voided": { invoiceId: string };
};

export function createHookgres(pool: pg.Pool) {
  return new Hookgres<Events>({ pool });
}

On the receiver ​

Your customers' receivers often aren't TypeScript, so this part is for your own receivers and tests. WebhookEvent turns an event map into a union you can narrow on type, and InferEvents gives the schemas' output types, which is what Hookgres delivers:

ts
import { type InferEvents, verifyWebhook, type WebhookEvent } from "hookgres";
import type { schemas } from "./schemas.js";

// What Hookgres delivers: each schema's output. With an event map instead of
// schemas, use WebhookEvent<Events>.
type Events = InferEvents<typeof schemas>;

export async function POST(
  request: Request,
  secret: string,
): Promise<Response> {
  const event = verifyWebhook<WebhookEvent<Events>>({
    secret,
    headers: request.headers,
    body: await request.text(),
  });
  if (event.type === "invoice.paid") {
    // event.data is { invoiceId: string; amount: number }
    console.log(`Invoice ${event.data.invoiceId} paid: ${event.data.amount}`);
  }
  return new Response(null, { status: 204 });
}

verifyWebhook checks the signature, not the shape. To check the shape on the receiver too, parse event.data with the same schema.

What isn't typed ​

  • Message.payload from getMessage() stays unknown. Messages stored before you added a schema, or redacted ones, may not match it.
  • Event types in the onAttempt hook and the delivery log are plain strings.

Released under the MIT License.