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 keypair. Now let’s use it. On Nostr, everything you do is an event: a post, a like, your profile, the list of people you follow. Different jobs, one shape. Learn that shape once and you can read the whole protocol.

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 id, and the wax seal is the signature.

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.

Who are you?

Every edit is re-signed automatically, so the event stays valid.

Tamper with one byte

Alice's note is signed and valid.

Valid: signed by this pubkey and untouched.

Edit something and watch the id scramble.

Verification pipeline

What every client and relay does before trusting an event: serialize, hash, compare the id, check the signature.

  1. Line up [0, pubkey, created_at, kind, tags, content] as compact JSON.

  2. Hash the UTF-8 bytes. The result is the id the event should have.

  3. Does the computed id equal the id the event claims?

  4. Does the Schnorr signature match this id and pubkey?

Not started yet.

View

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 public key)
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 kind tells apps how to read the rest. Kind 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

Tags are a list of little lists. The first item is the label’s name, the rest are its values:

  • ["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.

Why the timestamp is inside the fingerprint

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 NIP-01:

  1. Serialize. Put the fields in a fixed order inside a JSON array, with no extra spaces: [0, pubkey, created_at, kind, tags, content]. The leading 0 is a version marker.
  2. Hash. Encode that text as UTF-8 bytes and run them through SHA-256. Out come 32 bytes, written as 64 hex characters. That’s the id.
  3. Sign. Alice signs the id with her secret key using a Schnorr signature. That’s the sig.

A hash has a wonderful property called the avalanche effect: change one character of the input and roughly half of the output bits flip. Watch the “id digits changed” counter in the lab: a single letter usually scrambles about 60 of the 64 hex digits.

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 relay, it doesn’t trust it just because it arrived. It verifies it, in two steps:

  1. Recompute the id from the fields. Does it match the id the event claims? If not, something was changed after signing.
  2. Check the sig against the id and the pubkey. 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.

Try to break it

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 secp256k1 curve: 64 bytes (128 hex characters), signing the 32-byte id. 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 matches
Gotcha: nostr-tools caches verification

finalizeEvent 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 filter like {"#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

Bob edits one word in Alice's note but keeps her id and sig. What happens when your app verifies it?

Choose one answer

Which fields go into the id hash?

Choose one answer

An event says created_at = 1231006505 (3 January 2009). What does that prove?

Choose one answer

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.