> ## Documentation Index
> Fetch the complete documentation index at: https://pegana.xyz/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook signing — Ed25519

> Every webhook payload is signed with Ed25519 over a canonical timestamp.body string. Receivers verify against the active key list.

We sign every **Generic-channel** 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.

<Note>
  **Channel types.** Pegana auto-detects the delivery channel from the destination
  host. **Generic** endpoints (your own URL) receive Ed25519-signed payloads — the
  scheme on this page. **Discord** and **Slack** endpoints are delivered in each
  platform's native format and are intentionally **unsigned** (those platforms do
  not verify a Pegana signature). The signing below applies only to the Generic
  channel.
</Note>

## The public key

The authoritative key list is published at:

```text theme={"theme":"github-dark"}
GET https://api.pegana.xyz/v1/meta/webhook-keys
```

The response is:

```json theme={"theme":"github-dark"}
{
  "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.

<Warning>
  During a rotation, `/v1/meta/webhook-keys` returns both the current primary and
  the next key before the signing cutover. Verify against both.
</Warning>

## 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

```http theme={"theme":"github-dark"}
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

```text theme={"theme":"github-dark"}
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**.

```typescript theme={"theme":"github-dark"}
// 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:

```python theme={"theme":"github-dark"}
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

<CardGroup cols={3}>
  <Card title="TypeScript example" icon="js" href="/docs/guides/webhooks-examples-typescript">
    Web Crypto API verification.
  </Card>

  <Card title="Rust example" icon="rust" href="/docs/guides/webhooks-examples-rust">
    `ed25519-dalek` strict mode.
  </Card>

  <Card title="Python example" icon="python" href="/docs/guides/webhooks-examples-python">
    FastAPI + cryptography.
  </Card>
</CardGroup>
