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:*.
Planned —
postel-goships alongside the Go port. The API below reflects the target design.
go get github.com/postel-sh/postel-goPlanned —
postelfor Python ships alongside the Python port. The API below reflects the target design.
pip install postelPlanned —
postelfor Rust ships alongside the Rust port. The API below reflects the target design.
cargo add postel2. 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.
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.
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.
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.
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); // 200signFixture 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) // 200Planned — 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) # 200Planned — 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()); // 2004. 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 exposesc.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
- Concepts → Signing and verification — how the pipeline works under the hood.
- Concepts → Key rotation — multi-secret windows and JWKS.
- Concepts → Idempotency — at-least-once delivery + the dedup helper.
- Guides → Cloudflare Workers — production deployment.