Chapter 3
Anatomy of an event
Every post, like and profile is a signed JSON event. Take one apart.
Time for surgery! Let's open up an event and see what makes it tick. Don't worry, it can't feel a thing.
In the last chapter you got a
A sealed envelope with a fingerprint
Think of an event as a postcard you put in a clear plastic sleeve:
- The postcard is what you want to say: the text, when you wrote it, what kind of message it is, and a few sticky labels.
- The fingerprint stamped on the sleeve is calculated from the postcard. Change one letter on the card and the fingerprint no longer matches.
- Your wax seal goes on top of the fingerprint. Only you can press it, but anyone can check it’s yours.
That’s it. That’s an event. The fingerprint is the
Here is a real one, written by Alice. Click any field to learn what it does.
The event lab
An event signed by Alice. Edit it and watch the id and signature get recomputed, or switch sides and try to sneak in a change without her key.
Alice's note is signed and valid.
Edit something and watch the id scramble.
What every client and relay does before trusting an event: serialize, hash, compare the id, check the signature.
-
Line up [0, pubkey, created_at, kind, tags, content] as compact JSON.
-
Hash the UTF-8 bytes. The result is the id the event should have.
-
Does the computed id equal the id the event claims?
-
Does the Schnorr signature match this id and pubkey?
Not started yet.
id
Take pubkey, created_at, kind, tags and content, line them up in a fixed JSON array, hash the bytes with SHA-256 and write the 32-byte result as 64 hex characters. Change anything, even one letter, and the id changes completely. That is why it doubles as a tamper seal and a unique name.
58ccbba361073cdbb930748d028238328a0bf1d287c487828b5546eef17448ac Try it now: switch to A forger (no key) and change the text. The content updates, but the old id and signature are still stuck on it, and the verification pipeline catches you at the very first check. Switch back to Alice and the same edit is perfectly fine, because she can re-sign.
The seven fields
Every event has exactly these seven fields. Nothing more is required, nothing less is allowed.
| Field | In plain words |
|---|---|
id |
The fingerprint: a hash of everything else |
pubkey |
Who wrote it (the author’s |
created_at |
When the author says they wrote it, in seconds since 1970 |
kind |
What sort of event it is: a number |
tags |
Sticky labels: links to other events, people, hashtags |
content |
The actual message (or data) |
sig |
The wax seal: a signature only the author can make |
kind: one number, many jobs
The 1 is a short
text note, kind 0 is your profile, kind 3 your follow list, kind 7 a reaction (a “like”).
The number also tells relays how to store it: most events are kept forever, but some are
replaceable (only your latest profile matters), and some are ephemeral (pass it on, don’t keep it).
We’ll tour the whole zoo in chapter 6.
tags: sticky labels
["e", "<event id>"]points at another event. That’s how replies and quotes work.["p", "<pubkey>"]mentions a person, so their app can notify them.["t", "nostr"]is a hashtag.
Labels with a single letter are special: relays index them, so a client can ask
“give me every note tagged #nostr” or “every reply to this event”.
created_at: a claim, not a clock
created_at is a Unix timestamp, chosen by the author. Nothing stops Alice from writing
“January 2009”. Clients sort feeds by it, and many relays reject events from too far in the future,
but don’t treat it as proof of when something happened.
Because created_at is hashed into the id, nobody can quietly re-date Alice’s note after she signs
it. Try created_at +1 s in the lab while playing the forger: the id breaks.
How the fingerprint is made
To compute the id, every client follows the same recipe from
- Serialize. Put the fields in a fixed order inside a JSON array, with no extra spaces:
[0, pubkey, created_at, kind, tags, content]. The leading0is a version marker. - Hash. Encode that text as UTF-8 bytes and run them through
. Out come 32 bytes, written as 64 hex characters. That’s theSHA-256 SHA-256: The hash function Nostr uses: it turns any input into 32 bytes (64 hex characters).id. - Sign. Alice signs the id with her secret key using a
signature. That’s theSchnorr Schnorr signature: The signature scheme Nostr uses (BIP-340 on secp256k1): 64 bytes that prove the holder of a private key approved an event id.sig.
A
Under the hood: the exact bytes Show details
This is the string that actually gets hashed for a tiny note. Note: no spaces, no newlines, and the fields in exactly this order.
[0,"a73883912a48c3551c1548fd454876d577126a61c76b5486c807bfec5869e183",1735686600,1,[],"testing testing is this thing on"]Inside content (and tag values), JSON escaping applies: line breaks become \n, double quotes
\", backslashes \\, plus \r, \t, \b and \f. Everything else, emoji included, is written
as raw UTF-8. Two clients that disagree on a single byte here will compute different ids, which is why
NIP-01 spells the rules out so carefully.
For ordinary text, JSON.stringify on the array produces exactly this form in JavaScript. It differs only for
rare control characters (such as U+0001, which it writes as \u0001) and lone surrogates, which NIP-01 says to
emit verbatim.
import { sha256 } from "@noble/hashes/sha2.js";
import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
const serialized = JSON.stringify([0, pubkey, created_at, kind, tags, content]);
const id = bytesToHex(sha256(utf8ToBytes(serialized)));Checking the seal
When your app receives an event from a
- Recompute the id from the fields. Does it match the
idthe event claims? If not, something was changed after signing. - Check the
sigagainst the id and thepubkey. If it fails, the event wasn’t signed by that key.
Both checks are pure math, and they run on your own device. That’s the big idea: you don’t need to trust the relay. A relay can refuse to deliver Alice’s notes, but it cannot change a single word of them, or invent new ones in her name, without getting caught.
In the lab, hit Flip one digit of the sig. The id still matches (the content didn’t change), so the pipeline gets all the way to the last stage before it fails. Now try Flip a letter in content: it fails earlier, at “Compare id”.
Under the hood: signing and verifying with nostr-tools Show details
Signatures are BIP-340 Schnorr
signatures on the pubkey is the 32-byte “x-only” public key, always in
hex on the wire (the npub… form from chapter 2 is only for humans).
import { finalizeEvent, generateSecretKey, verifyEvent } from "nostr-tools/pure";
const sk = generateSecretKey(); // Uint8Array(32)
const event = finalizeEvent(
{
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [["t", "nostr"]],
content: "Hello Nostr!",
},
sk,
); // adds pubkey, id and sig
verifyEvent(event); // true
// A tampered copy, as it would arrive from the network (plain JSON):
const tampered = JSON.parse(JSON.stringify({ ...event, content: "Hello Nostr?" }));
verifyEvent(tampered); // false: the id no longer matchesfinalizeEvent and verifyEvent remember their result on the event object under a hidden symbol.
verifyEvent({ ...event, content: "changed" }) copies that cached true along and still says
true! Events parsed from JSON (what you get from relays) carry no cache, so real verification works,
but beware when you mutate objects in your own code.
finalizeEvent computes the id with getEventHash (the serialize + SHA-256 recipe above) and signs
it. verifyEvent does the reverse: recompute the hash, compare it with id, then check sig.
Relays run the same checks before accepting an event (NIP-01 tells them to reject invalid ones).
Under the hood: the tag formats Show details
Per NIP-01:
["e", "<32-byte hex event id>", "<recommended relay URL, optional>", "<author pubkey, optional>"]
["p", "<32-byte hex pubkey>", "<recommended relay URL, optional>"]
["a", "<kind>:<pubkey hex>:<d tag value>", "<recommended relay URL, optional>"]All single-letter tags (a–z, A–Z) are expected to be indexed by relays, so a
{"#t": ["nostr"]} or {"#e": ["<id>"]} works.
Threading conventions (the root / reply markers you’ll see in the 4th position of e tags on
replies) live in NIP-10. Tags with
longer names (imeta, client, …) are fine too, they just aren’t indexed.
Inspect any event
Got an event from somewhere else? Paste it into the event inspector tool: it checks the id and signature right in your browser and explains every field.
Quick check
Takeaways
- Everything on Nostr (notes, likes, profiles, follow lists) is one shape: a JSON event with seven fields.
- The id is the SHA-256 hash of the serialized event, so changing a single byte changes the id.
- The sig is a Schnorr signature over that id: only the secret key holder can make it, anyone can check it.
- Tags are labelled lists that link events to other events, people and topics; single-letter tags are searchable on relays.
- created_at is a claim made by the author, not a proof of time.