The public key
The authoritative key list is published at:pubkeys_b64. You can cache the list or
pin it through an env var, but do not read a public key from each delivery.
Signing input
We sign the canonical UTF-8 string:timestamp— UNIX seconds, as a base-10 string (matchesx-pegana-timestamp)body— the raw HTTP body bytes (do not re-serialize JSON — match exactly what was sent)- Joined with a literal
.character
Headers
ed25519: — your verifier strips it
before base64-decoding. We use the prefix to allow algorithm negotiation in
future versions without breaking existing receivers (e.g., a future ed25519-strict:
or different scheme).
Verification algorithm
Replay window
We reject deliveries older than 300 seconds (5 minutes). This protects against an attacker who captures a single delivery and replays it later. Your verifier should check the timestamp before doing the signature verification — no point doing expensive crypto on a stale request.Common mistakes
Re-serializing the body. If you parse the JSON, then re-stringify it for signing, the bytes will differ from what we signed. Always sign over the raw bytes you received. In every framework, that means getting the body before any middleware parses it.ed25519: prefix. Your base64 decode will fail with
“invalid character” errors. Strip the prefix first, then decode.
Skipping the timestamp check. A valid signature never expires on its own —
without the timestamp + replay window, an attacker who intercepted a single
delivery could replay it years later. Always reject deliveries older than 5
minutes.
Reading the public key from headers. Some webhook docs (not ours) put the key
in a header per request. Don’t do that — your verifier trusts
/v1/meta/webhook-keys or a pinned env value. The header model lets an attacker
substitute their own key+signature pair.
Key rotation procedure
When we rotate:- A new key is published as the secondary key at
/v1/meta/webhook-keys. - Receivers refresh their trust list and start accepting both keys.
- After the overlap, Pegana promotes the secondary to primary and removes the old key.
Why not HMAC
HMAC requires a shared secret. Webhooks would have to distribute that secret to every subscriber, which means a leaked secret on any subscriber’s side compromises authenticity for every other consumer. Ed25519 lets us hold the private key, publish only the public key, and rotate without redistributing secrets. Verifiers don’t need any secret material.Why verify_strict (Rust)
The Rust receiver uses verify_strict from ed25519-dalek, which enforces
RFC-8032 strict-mode rules: no point-at-infinity public keys, no malleable
signatures. This rejects some technically-valid Ed25519 signatures that other
libraries accept.
We sign with libsodium-style strict semantics, so strict verification round-trips
correctly. Your TypeScript / Python verifiers don’t need an equivalent flag —
both crypto.subtle.verify("Ed25519", ...) and cryptography.Ed25519PublicKey.verify
do the right thing by default.
Next
TypeScript example
Web Crypto API verification.
Rust example
ed25519-dalek strict mode.Python example
FastAPI + cryptography.