Postel

Get started

Install @postel/edge and verify a signed webhook in under a minute. Works in Node, Bun, Deno, and on the edge.

This page gets you from zero to a verified webhook on your laptop. The receiver-side build is @postel/edge — small enough to run on Cloudflare Workers (≤ 50 KB), portable to Node, Bun, and Deno.

1. Install

pnpm add @postel/edge
# or: npm install @postel/edge
# or: yarn add @postel/edge
# or: bun add @postel/edge
# or (Deno): import { verify } from "npm:@postel/edge";

@postel/edge has zero runtime dependencies. The bundle uses Web Crypto only; nothing imports from node:*.

Plannedpostel-go ships alongside the Go port. The API below reflects the target design.

go get github.com/postel-sh/postel-go

Plannedpostel for Python ships alongside the Python port. The API below reflects the target design.

pip install postel

Plannedpostel for Rust ships alongside the Rust port. The API below reflects the target design.

cargo add postel

2. Verify a webhook

The minimum viable receiver: pass the raw request bytes, the headers, and your shared secret to verify. On success you get the parsed Standard Webhooks event; on failure a structured error tells you which step failed.

app/api/webhooks/route.ts
import {
  verify,
  SignatureInvalid,
  TimestampTooOld,
  MalformedHeader,
} from "@postel/edge";

export async function POST(req: Request) {
  // IMPORTANT: pass the raw bytes — never JSON.stringify(await req.json()).
  // The signature is over the exact bytes the producer sent.
  const body = new Uint8Array(await req.arrayBuffer());
  const headers = Object.fromEntries(req.headers);

  try {
    const { event } = await verify(body, headers, process.env.WEBHOOK_SECRET!);

    console.log("received:", event.type, event.data);
    return new Response("ok", { status: 200 });
  } catch (err) {
    if (err instanceof SignatureInvalid) {
      return new Response("bad signature", { status: 401 });
    }
    if (err instanceof TimestampTooOld) {
      return new Response("timestamp out of window", { status: 401 });
    }
    if (err instanceof MalformedHeader) {
      return new Response("malformed headers", { status: 400 });
    }
    throw err;
  }
}

That's it. The route handler above works unmodified on Cloudflare Workers, Vercel Edge, Next.js, Bun, and Deno. See Cloudflare Workers for a complete deployment walkthrough.

Planned — the API below reflects the target design for the Go port.

main.go
package main

import (
    "errors"
    "io"
    "log"
    "net/http"
    "os"

    postel "github.com/postel-sh/postel-go"
)

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)

    event, err := postel.Verify(body, r.Header, os.Getenv("WEBHOOK_SECRET"))
    if err != nil {
        var sigErr *postel.SignatureInvalid
        if errors.As(err, &sigErr) {
            http.Error(w, "bad signature", http.StatusUnauthorized)
            return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    log.Printf("received: %s %v", event.Type, event.Data)
    w.WriteHeader(http.StatusOK)
}

Planned — the API below reflects the target design for the Python port.

app.py
import os
from flask import Flask, request, abort
from postel import verify, SignatureInvalid, TimestampTooOld, MalformedHeader

app = Flask(__name__)

@app.post("/webhooks")
def handle_webhook():
    body = request.get_data()
    headers = dict(request.headers)

    try:
        event = verify(body, headers, os.environ["WEBHOOK_SECRET"])
        print(f"received: {event.type} {event.data}")
        return "ok", 200
    except SignatureInvalid:
        abort(401, "bad signature")
    except TimestampTooOld:
        abort(401, "timestamp out of window")
    except MalformedHeader:
        abort(400, "malformed headers")

Planned — the API below reflects the target design for the Rust port.

src/main.rs
use axum::{body::Bytes, http::{HeaderMap, StatusCode}, response::IntoResponse};
use postel::{verify, PostelError};

async fn handle_webhook(headers: HeaderMap, body: Bytes) -> impl IntoResponse {
    let secret = std::env::var("WEBHOOK_SECRET").unwrap();

    match verify(&body, &headers, &secret).await {
        Ok(event) => {
            println!("received: {} {:?}", event.r#type, event.data);
            (StatusCode::OK, "ok")
        }
        Err(PostelError::SignatureInvalid(_)) => {
            (StatusCode::UNAUTHORIZED, "bad signature")
        }
        Err(PostelError::TimestampTooOld(_)) => {
            (StatusCode::UNAUTHORIZED, "timestamp out of window")
        }
        Err(PostelError::MalformedHeader(_)) => {
            (StatusCode::BAD_REQUEST, "malformed headers")
        }
        Err(e) => {
            eprintln!("unexpected: {e}");
            (StatusCode::INTERNAL_SERVER_ERROR, "error")
        }
    }
}

3. Try it locally

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

import { signFixture } from "@postel/edge";

const secret = "whsec_aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ5";
const fixture = await signFixture({
  secret,
  payload: { type: "order.created", data: { id: "ord_123" } },
});

// fixture.headers + fixture.body are byte-for-byte what a producer would send.
const res = await fetch("http://localhost:3000/api/webhooks", {
  method: "POST",
  headers: fixture.headers,
  body: fixture.body,
});

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

signFixture exists so tests don't need to stand up a real producer. It is not for production signing — that's the sender's job, which lands in v0.2.0.

Planned — the API below reflects the target design for the Go port.

secret := "whsec_aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ5"
fixture, _ := postel.SignFixture(postel.FixtureOpts{
    Secret:  secret,
    Payload: map[string]any{"type": "order.created", "data": map[string]any{"id": "ord_123"}},
})

req, _ := http.NewRequest("POST", "http://localhost:8080/webhooks", bytes.NewReader(fixture.Body))
for k, v := range fixture.Headers {
    req.Header.Set(k, v)
}

resp, _ := http.DefaultClient.Do(req)
fmt.Println(resp.StatusCode) // 200

Planned — the API below reflects the target design for the Python port.

from postel import sign_fixture

secret = "whsec_aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ5"
fixture = sign_fixture(
    secret=secret,
    payload={"type": "order.created", "data": {"id": "ord_123"}},
)

resp = requests.post(
    "http://localhost:5000/webhooks",
    headers=fixture.headers,
    data=fixture.body,
)
print(resp.status_code)  # 200

Planned — the API below reflects the target design for the Rust port.

let secret = "whsec_aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ5";
let fixture = postel::sign_fixture(SignFixtureOpts {
    secret,
    payload: serde_json::json!({"type": "order.created", "data": {"id": "ord_123"}}),
})?;

let client = reqwest::Client::new();
let resp = client
    .post("http://localhost:3000/webhooks")
    .headers(fixture.headers)
    .body(fixture.body)
    .send()
    .await?;
println!("{}", resp.status()); // 200

4. Pick a framework adapter

Calling verify directly works everywhere. If you're on a framework, the matching adapter takes care of plumbing the raw bytes and surfacing the parsed event:

  • Hono@postel/hono. Middleware exposes c.get("postel") on every route.
  • Express, Fastify, Bun.serve, Deno.serve, Next.js Route Handlers, SvelteKit, Astro, Nitro — adapters land alongside the v0.1.0 release. Until then, the framework-agnostic verify(bytes, headers, secret) call works.

What's next

On this page