Skip to main content
We sign every delivery with Ed25519. Signatures are short (64 bytes base64-encoded = 88 chars), verification is trivial in every language, and the algorithm is constant-time by construction.

The public key

The authoritative key list is published at:
GET https://api.pegana.xyz/v1/meta/webhook-keys
The response is:
{
  "pubkeys_b64": ["primary_base64", "secondary_base64_during_rotation"]
}
Your verifier should trust every key in pubkeys_b64. You can cache the list or pin it through an env var, but do not read a public key from each delivery.
During a rotation, /v1/meta/webhook-keys returns both the current primary and the next key before the signing cutover. Verify against both.

Signing input

We sign the canonical UTF-8 string:
<timestamp>.<body>
  • timestamp — UNIX seconds, as a base-10 string (matches x-pegana-timestamp)
  • body — the raw HTTP body bytes (do not re-serialize JSON — match exactly what was sent)
  • Joined with a literal . character
No newlines, no whitespace normalization, no JSON canonicalization.

Headers

x-pegana-timestamp: 1779889253
x-pegana-signature: ed25519:<base64 64-byte sig>
The signature value carries the literal prefix 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

1. Read x-pegana-timestamp, x-pegana-signature
2. Verify |now - timestamp| < 300 seconds   (replay window)
3. signature_b64 = strip_prefix(sig_header, "ed25519:")
4. signature = base64_decode(signature_b64)
5. assert len(signature) == 64
6. message = "<timestamp>.<body>" as UTF-8 bytes
7. ed25519_verify(message, signature, ANY_ACTIVE_PUBLIC_KEY) == true
8. Idempotency: check x-pegana-event-id against local store

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.
// Express — wrong
app.use(express.json());                  // body parsed before your handler
app.post("/hook", (req, res) => {
  const body = JSON.stringify(req.body);  // ❌ re-serialized; bytes differ
});

// Express — right
app.post("/hook", express.raw({ type: "*/*" }), (req, res) => {
  const body = req.body;                  // ✓ Buffer, original bytes
});
Forgetting to strip the 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:
  1. A new key is published as the secondary key at /v1/meta/webhook-keys.
  2. Receivers refresh their trust list and start accepting both keys.
  3. After the overlap, Pegana promotes the secondary to primary and removes the old key.
In your verifier, support both keys during the window:
TRUSTED_KEYS = [
  Ed25519PublicKey.from_public_bytes(base64.b64decode("<primary>")),
  Ed25519PublicKey.from_public_bytes(base64.b64decode("<secondary during rotation>")),
]

for key in TRUSTED_KEYS:
    try:
        key.verify(sig_bytes, message)
        return True
    except InvalidSignature:
        continue
return False

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.