# Quickstart





Postel has two independent halves. This page gets each one working from scratch — do the one you need now, or both. Everything below is TypeScript you can run today; [other languages](/docs/project/polyglot) are on the roadmap.

## Receive a webhook [#receive-a-webhook]

Verify a Standard Webhooks-signed request from `acme` — a stand-in for any producer that signs with the Standard Webhooks header scheme (`webhook-id` / `webhook-timestamp` / `webhook-signature`). Producers with their own scheme — GitHub's `X-Hub-Signature-256`, Stripe's `Stripe-Signature` — don't match those headers; verify them with a small [custom `Verifier`](/docs/inbound/custom-verifiers) instead, the built-ins only speak Standard Webhooks.

<Steps>
  <Step>
    ### Install [#install]

    <Install packages="@postel/core" />

    <Callout title="Nothing is on npm yet — run from source" type="warn">
      Every `@postel/*` package sits at `0.0.0` with no release tag, so this install fails until the first release ships. Until then, build from source and link the workspace packages:

      ```bash
      git clone https://github.com/postel-sh/postel
      cd postel/typescript
      pnpm install && pnpm build
      ```

      Then depend on the built packages from your app — e.g. `"@postel/core": "file:../postel/typescript/packages/core"` (or `pnpm link`). The runnable [`examples/nextjs-prisma`](https://github.com/postel-sh/postel/tree/main/typescript/examples/nextjs-prisma) reference app wires everything this way already.
    </Callout>

    Zero runtime dependencies. If you use a web framework, add its adapter too — `@postel/hono`, `@postel/express`, `@postel/fastify`, `@postel/nestjs`, `@postel/nextjs`, or `@postel/http` for any Fetch runtime.
  </Step>

  <Step>
    ### Configure a source [#configure-a-source]

    Once per process, at module scope. One entry per webhook *source*, keyed by the producer's name.

    ```ts title="lib/postel.ts"
    import { Postel, Secret } from "@postel/core";
    import { config } from "./config.js";

    export const postel = Postel({
      inbound: {
        acme: { verify: Secret(config.acmeWebhookSecret) },
      },
    });
    ```

    `Secret(...)` is a *verifier strategy* — swap in `Keyset({ jwksUri })` for JWKS, or pass an array for [key rotation](/docs/inbound/key-rotation). `postel.inbound.acme` is fully typed; sources you didn't configure don't exist on the type.

    <Callout title="Declaring the config separately? Keep the literal.">
      `postel.inbound` / `postel.outbound` exist on the instance type only because the factory reads the config's **literal** shape. Annotating a config with `: PostelConfig` widens that literal and the slots vanish from the type. Inline the object (as above), or — if you keep it in its own `const` — wrap it in `definePostelConfig(...)` (or write `as const satisfies PostelConfig`) so the literal is preserved:

      ```ts
      import { definePostelConfig, Postel, Secret } from "@postel/core";

      const config = definePostelConfig({
        inbound: { acme: { verify: Secret(process.env.ACME_SECRET!) } },
      });

      export const postel = Postel(config); // postel.inbound.acme is still typed
      ```
    </Callout>
  </Step>

  <Step>
    ### Verify a request [#verify-a-request]

    The verifier runs over the **exact received bytes** — never re-parse and re-serialize the body first ([here's why](/docs/concepts/raw-bytes)). Pick your framework; each adapter is a thin gate over the same verifier, so your handler only runs once the signature checks out.

    <Tabs items="[&#x22;Framework-agnostic&#x22;, &#x22;Hono&#x22;, &#x22;Express&#x22;, &#x22;Fastify&#x22;, &#x22;NestJS&#x22;]">
      <Tab value="Framework-agnostic">
        Any Fetch runtime — Next.js Route Handlers, Bun, Deno:

        ```ts title="app/api/webhooks/acme/route.ts"
        import { fetchWebhook } from "@postel/http";
        import { postel } from "@/lib/postel";

        const handler = fetchWebhook(postel.inbound.acme, {
          onVerified: async ({ event }) => {
            console.log("received:", event.type, event.data);
          },
        });

        export const POST = (req: Request) => handler(req);
        ```
      </Tab>

      <Tab value="Hono">
        ```ts title="app.ts"
        import { Hono } from "hono";
        import { verifyWebhook, getVerified } from "@postel/hono";
        import { postel } from "./lib/postel";

        const app = new Hono();

        app.post("/webhooks/acme", verifyWebhook(postel.inbound.acme), (c) => {
          const { event } = getVerified(c); // verified · raw bytes intact
          return c.json({ ok: true, type: event.type });
        });
        ```
      </Tab>

      <Tab value="Express">
        ```ts title="app.ts"
        import express from "express";
        import { verifyWebhook, getVerified } from "@postel/express";
        import { postel } from "./lib/postel";

        const app = express();

        // verifyWebhook mounts express.raw() + the gate — don't put express.json() ahead of it
        app.post("/webhooks/acme", verifyWebhook(postel.inbound.acme), (req, res) => {
          res.json({ ok: true, type: getVerified(req).event.type });
        });
        ```
      </Tab>

      <Tab value="Fastify">
        ```ts title="app.ts"
        import Fastify from "fastify";
        import { fastifyPostel, verifyWebhook, getVerified } from "@postel/fastify";
        import { postel } from "./lib/postel";

        const app = Fastify();
        await app.register(fastifyPostel); // raw-body parser; register on a webhook-only scope

        app.post(
          "/webhooks/acme",
          { preHandler: verifyWebhook(postel.inbound.acme) },
          async (req) => ({ ok: true, type: getVerified(req).event.type }),
        );
        ```
      </Tab>

      <Tab value="NestJS">
        Boot with `rawBody: true`, register `PostelModule.forRoot(postel)`, then guard the route:

        ```ts title="webhooks.controller.ts"
        import { Controller, Post, UseGuards } from "@nestjs/common";
        import { WebhookGuard, Event } from "@postel/nestjs";
        import type { WebhookEvent } from "@postel/core";

        @Controller("webhooks")
        export class WebhooksController {
          @Post("acme")
          @UseGuards(WebhookGuard("acme"))
          handle(@Event() event: WebhookEvent) {
            return { ok: true, type: event.type };
          }
        }
        ```
      </Tab>
    </Tabs>

    On a verification failure the gate short-circuits with the right HTTP status and your handler never runs. Each adapter is covered in full under [Web adapters](/docs/web-adapters).
  </Step>

  <Step>
    ### Try it locally [#try-it-locally]

    You need a signed request to verify. The library ships a fixture signer for exactly this:

    ```ts title="scripts/send-fixture.ts"
    import { signFixture } from "@postel/core";
    import { config } from "../lib/config.js";

    const fixture = await signFixture({
      secret: config.acmeWebhookSecret,
      payload: { type: "issue.opened", data: { id: 42 } },
    });

    const res = await fetch("http://localhost:3000/webhooks/acme", {
      method: "POST",
      headers: fixture.headers,
      body: fixture.body,
    });

    console.log(res.status); // 200
    ```

    `signFixture` is **for tests** — production signing is the sender's job, next.
  </Step>
</Steps>

## Send a webhook [#send-a-webhook]

Deliver Standard Webhooks to *your* customers' endpoints, with a transactional outbox, retries, and signing handled for you.

<Steps>
  <Step>
    ### Configure the sender [#configure-the-sender]

    Pass an `outbound` block to the same factory. `InMemoryStorage` gets you running immediately; point it at a database for production.

    ```ts title="lib/postel.ts"
    import { Postel, InMemoryStorage, HmacV1, ExponentialBackoff } from "@postel/core";

    export const postel = Postel({
      outbound: {
        storage: InMemoryStorage(),                 // swap for a DB adapter in production
        signing: HmacV1(),                          // or Ed25519V1a() for asymmetric + JWKS
        retryPolicy: ExponentialBackoff({ maxAttempts: 8 }),
      },
    });

    await postel.start(); // starts the worker pool — nothing is delivered until this runs
    ```

    For durable delivery, swap `InMemoryStorage()` for a [storage adapter](/docs/storage) — standalone `@postel/pg` / `@postel/sqlite` / `@postel/mysql`, or your existing Kysely / Drizzle / Prisma / TypeORM / MikroORM instance.

    <Callout title="send() queues, start() delivers">
      `send()` only writes an outbox row; a worker delivers it. That worker doesn't exist until `postel.start()` runs — call it once, right after configuring, or messages sit in the outbox forever. Call `await postel.stop()` on graceful shutdown to let in-flight attempts finish.
    </Callout>
  </Step>

  <Step>
    ### Register an endpoint [#register-an-endpoint]

    An endpoint is a receiver URL plus the event types it should get. Postel mints its signing secret automatically.

    ```ts
    await postel.outbound.endpoints.create({
      url: "https://customer.example.com/webhooks",
      types: ["order.*"],
    });
    ```

    Fan-out is automatic: one `send()` reaches every endpoint whose `types` match the event.
  </Step>

  <Step>
    ### Send inside your transaction [#send-inside-your-transaction]

    `send()` is an outbox INSERT — pass it your transaction handle and the webhook is queued only if your business write commits. A worker delivers it after commit, so you never block on the network inside the transaction.

    ```ts title="orders.ts"
    await db.tx(async (tx) => {
      await db.orders.insert({ id: "ord_123", status: "paid" }, { tx });
      await postel.outbound.send(
        { type: "order.created", data: { id: "ord_123" } },
        { tx },                                     // commits or rolls back atomically
      );
    });
    ```

    That's the [transactional outbox](/docs/outbound/send) — no broker, no dispatcher process, no dual-write race.
  </Step>
</Steps>

## Where to next [#where-to-next]

**Receiving** — the things that bite every integration eventually:

* [Raw bytes](/docs/concepts/raw-bytes) — the single most common silent failure. Read this before you ship.
* [Testing webhooks](/docs/inbound/testing) — `signFixture` recipes for every framework, plus the negative tests worth having.
* [Key rotation](/docs/inbound/key-rotation) — rotate secrets with zero downtime.
* [Deduplication](/docs/inbound/deduplication) — webhooks are at-least-once; here's the atomic helper.

**Sending** — on the way to production:

* [Storage](/docs/storage) — pick a durable adapter, or bring your own.
* [Retries & backoff](/docs/outbound/retries) — circuit breaker, dead-letter, auto-disable.
* [Replay](/docs/outbound/replay) — re-emit historical messages by id, endpoint, or filter.

Prefer to read working code? The [nextjs-prisma example](/docs/get-started/examples) does everything above in one runnable app, crash demo included. Not sure Postel fits your stack? Run the [six-line filter](/docs/get-started/is-postel-for-me).
