Verifying events

Every event is signed at the source with an Ed25519 signature over its exact content. Verify authenticity with only a public key, on any transport, with no shared secret.

Every event Hypeline emits carries a content-layer Ed25519 signature over its exact content. The signature is created once, when the event is born, and it travels with the event unchanged across every transport: a webhook delivery, the live SSE stream, or a cursor backfill from history. You verify it with nothing but a public key you fetch from us, so there is no shared secret to store or leak, and altering a single character of the event fails the check.

How this differs from webhook HMAC

This is a second, independent trust layer that sits alongside the per-destination webhook HMAC. They answer different questions:

LayerWhat it provesWho can verify
Webhook HMACThis HTTP POST reached you intact and is not a replay.The destination holding the shared whsec_ secret.
Event Ed25519 signatureThis event content is authentic Hypeline, on any transport.Anyone, with only the public key.

An HMAC secures a single delivery to a single destination. The Ed25519 signature is attached to the event itself, so the same proof rides along whether the event arrives by webhook, is read live over SSE, or is pulled later from a backfill. Anyone holding the public key can confirm an event is genuine, with no need to trust how it was delivered.

Anatomy of a signed event

Three fields on every StreamEvent carry the attestation:

FieldMeaning
signatureThe Ed25519 signature, base64url-unpadded, of the 64 raw signature bytes.
key_idThe kid of the signing key. Resolve the matching public key in the JWKS.
signed_atWhen the signature was produced (RFC 3339, UTC). Covered by the signature.

A signed event looks like this (trimmed):

json
{
  "id": "0192a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
  "schema_version": 1,
  "source_id": "9f8e7d6c-5b4a-4938-2716-05f4e3d2c1b0",
  "source_type": "feed",
  "url": "https://example.se/story",
  "title": "hello world",
  "tags": ["topic:test", "region:se"],
  "emitted_at": "2023-11-14T22:13:21Z",
  "signature": "DSBACwOa7kW0qvroH5oT_63EoLdyTRmx9Td6h1pPM8cQq072JvOxQ-hO36tMKCFi_Kb_0uf2-OkZIf8Ls84nBw",
  "key_id": "k1",
  "signed_at": "2026-07-10T00:00:00Z"
}

What bytes are signed

The signed input is the RFC 8785 JSON Canonicalization Scheme (JCS) of the event, with the signature field removed and every other field kept, including key_id and signed_at. Because those two are inside the signed bytes, tampering with either one fails verification too.

Canonicalize, do not re-serialize

You must reproduce the exact canonical bytes that were signed. Run the event through a JCS (RFC 8785) implementation after dropping signature. Do not hand-roll JSON: JCS fixes key ordering, number formatting, and string escaping so the bytes are identical in every language. Two details make Hypeline's wire bytes reproduce the signed bytes exactly: timestamps are UTC, and simhash is a decimal string (a 64-bit fingerprint exceeds JavaScript's Number precision as an integer).

Get the keys

Public keys are served, unauthenticated and CORS-open, as an Ed25519 OKP JWK set:

bash
curl https://api.hypeline.io/.well-known/jwks.json
json
{
  "keys": [
    {
      "kty": "OKP",
      "use": "sig",
      "alg": "EdDSA",
      "kid": "k1",
      "crv": "Ed25519",
      "x": "JZGKVwnP3NOeEnVLUCf4dgD1lNxPUkYKZnfNGIKtUJs"
    }
  ]
}

Match an event's key_id against a JWK's kid, then verify with that key's public material (x). Cache the JWKS (the endpoint sets Cache-Control: max-age=300); you do not need to refetch it per event.

Verify in your language

Go

The engine ships an importable verifier, so a Go consumer verifies in one call. It fetches the JWKS, parses it, and checks the signature:

go
import "github.com/livestrom/livestrom/internal/eventsign"

// se is your decoded api.StreamEvent (from SSE, a webhook body, or /v1/events).
err := eventsign.VerifyFromJWKSURL(ctx, se, "https://api.hypeline.io/.well-known/jwks.json")
if err != nil {
    // signature is missing, tampered, or signed by an unknown key: reject.
}

If you already hold the parsed JWKS, call eventsign.Verify(se, jwks) directly and skip the fetch.

You can also verify from the command line without writing code. Pipe an event to the verify subcommand: it exits 0 on a valid signature and non-zero on a tampered one.

bash
livestrom verify --event event.json --jwks-url https://api.hypeline.io/.well-known/jwks.json

Node

Any language with an RFC 8785 library and stdlib Ed25519 works. In Node, npm i canonicalize:

javascript
import { createPublicKey, verify } from "node:crypto";
import canonicalize from "canonicalize"; // npm i canonicalize (RFC 8785)

function b64urlToBuf(s) {
  return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64");
}

// event: the decoded StreamEvent. jwks: the fetched /.well-known/jwks.json.
function verifyEvent(event, jwks) {
  const jwk = jwks.keys.find((k) => k.kid === event.key_id);
  if (!jwk) throw new Error(`no JWK for kid ${event.key_id}`);
  const pub = createPublicKey({ key: jwk, format: "jwk" });

  // Reproduce the signed bytes: drop `signature`, canonicalize the rest.
  const { signature, ...covered } = event;
  const canonical = Buffer.from(canonicalize(covered), "utf8");

  // algorithm null selects EdDSA for an OKP key.
  return verify(null, canonical, pub, b64urlToBuf(signature));
}

Python

pip install rfc8785 cryptography:

python
import base64

import rfc8785  # pip install rfc8785 (RFC 8785 JCS)
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey


def b64url(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))


# event: the decoded StreamEvent. jwks: the fetched /.well-known/jwks.json.
def verify_event(event: dict, jwks: dict) -> bool:
    jwk = next((k for k in jwks["keys"] if k["kid"] == event["key_id"]), None)
    if jwk is None:
        raise ValueError(f"no JWK for kid {event['key_id']}")
    pub = Ed25519PublicKey.from_public_bytes(b64url(jwk["x"]))

    covered = {k: v for k, v in event.items() if k != "signature"}
    canonical = rfc8785.dumps(covered)

    try:
        pub.verify(b64url(event["signature"]), canonical)
        return True
    except InvalidSignature:
        return False

Key rotation

The JWKS can hold more than one key. Exactly one is active for signing at any time, but every public key stays published, so an event signed by a retired key still verifies. Always resolve the key by the event's key_id rather than assuming a single key, and your verifier keeps working across a rotation with no change.

Two layers, when each applies

  • Receiving webhooks? Verify the HMAC to trust the delivery, and optionally verify the Ed25519 signature on the event body for end-to-end content authenticity. They compose.
  • Reading the SSE stream or a cursor backfill? There is no HMAC (that is a per-delivery webhook mechanism), so the Ed25519 signature is how you confirm each event is genuine.

See the API Reference for the full StreamEvent schema.