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:
| Layer | What it proves | Who can verify |
|---|---|---|
| Webhook HMAC | This HTTP POST reached you intact and is not a replay. | The destination holding the shared whsec_ secret. |
| Event Ed25519 signature | This 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:
| Field | Meaning |
|---|---|
signature | The Ed25519 signature, base64url-unpadded, of the 64 raw signature bytes. |
key_id | The kid of the signing key. Resolve the matching public key in the JWKS. |
signed_at | When the signature was produced (RFC 3339, UTC). Covered by the signature. |
A signed event looks like this (trimmed):
{
"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:
curl https://api.hypeline.io/.well-known/jwks.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:
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.
livestrom verify --event event.json --jwks-url https://api.hypeline.io/.well-known/jwks.jsonNode
Any language with an RFC 8785 library and stdlib Ed25519 works. In Node, npm i canonicalize:
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:
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 FalseKey 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.