Be conservative in what you send · liberal in what you accept

Webhooks as a feature of your product.

Postel is a polyglot webhooks library backed by executable specs. Standard Webhooks-compliant. Sender plus receiver. Runs inside your application against your Postgres or SQLite database.

$pnpm add @postel/edge

Verify a webhook

Five lines. Standard Webhooks. Edge-runtime native.

verify returns the parsed event on success, or throws a structured error naming the failing step: SignatureInvalid, TimestampTooOld, MalformedHeader, and friends.

import { verify, SignatureInvalid } from "@postel/edge";

export async function POST(req: Request) {
  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(event.type, event.data);
    return new Response("ok");
  } catch (err) {
    if (err instanceof SignatureInvalid) return new Response("bad signature", { status: 401 });
    throw err;
  }
}

Planned — this API reflects the target design. The package is not published yet.

import 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", event.Type)
    w.WriteHeader(http.StatusOK)
}

Planned — this API reflects the target design. The package is not published yet.

from postel import verify, SignatureInvalid

@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}")
        return "ok", 200
    except SignatureInvalid:
        abort(401, "bad signature")

Planned — this API reflects the target design. The package is not published yet.

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);
            (StatusCode::OK, "ok")
        }
        Err(PostelError::SignatureInvalid(_)) => {
            (StatusCode::UNAUTHORIZED, "bad signature")
        }
        Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "error"),
    }
}

Standard Webhooks

Compliant by default

Headers, signature schemes (HMAC v1 + Ed25519 v1a), payload envelope, and prefixes follow the Standard Webhooks spec. JWKS publication is a one-liner.

Edge-first

Runs in 50 KB on the edge

@postel/edge ships unmodified on Cloudflare Workers, Vercel Edge, Deno Deploy, and Bun. Web Crypto only — no node:* imports, no polyfills.

Library, not service

Uses your database

Outbox inserts join your existing transaction. No separate dispatcher, no Redis, no broker. The library you embed; not the service you stand up.

Status

Pre-alpha. Receiver first.

@postel/edge ships today: verify, JWKS consumer, dedup helper, multi-secret rotation, raw-bytes preservation. Sender (postel.send, outbox, retries, fanout, replay) lands in v0.2.0. Go, Python, and Rust ports follow.

Verify your first webhook