Nostr is often described as a decentralized social network. That description is useful for users, but incomplete for developers.
Nostr is a small signed-event protocol. Clients publish events to relays, relays store and forward those events, and other clients query relays for events matching filters. There is no global database, no required identity provider, and no single server that must remain online for the network to work.
For Bitcoin developers, Nostr is interesting because it combines a simple data model with public-key identity and Lightning payments. A Nostr client can be a social application, a monitoring dashboard, an alerting system, or a Bitcoin service interface.
This post starts at the protocol level.
The Nostr Data Model
The basic unit in Nostr is an event. NIP-01 defines the core event structure:
{
"id": "32-byte-event-id-as-hex",
"pubkey": "32-byte-x-only-public-key-as-hex",
"created_at": 1710000000,
"kind": 1,
"tags": [],
"content": "Hello from a Nostr client",
"sig": "64-byte-schnorr-signature-as-hex"
}
The event ID is the SHA-256 hash of a serialized array:
[0, "<pubkey>", <created_at>, <kind>, <tags>, "<content>"]
The 0 is the protocol version. The event is then signed with a Schnorr signature using the private key corresponding to pubkey.
The important design decision is that identity is a key pair, not an account in a central database. A user can publish from many clients without creating separate accounts, as long as those clients use the same key.
Common event kinds include:
| Kind | Meaning |
|---|---|
0 |
User metadata |
1 |
Short text note |
3 |
Follow list |
4 |
Encrypted direct message, legacy |
5 |
Event deletion request |
6 |
Repost |
7 |
Reaction |
9734 |
Zap request |
9735 |
Zap receipt |
NIPs define additional event kinds and conventions. A kind is not a database table enforced by a central operator; it is a convention that clients and relays agree to interpret.
Creating and Signing an Event
The following TypeScript shows the conceptual signing flow. In production, use a maintained library such as nostr-tools and keep private keys outside browser application code whenever possible.
import { finalizeEvent, getPublicKey, nip19 } from "nostr-tools";
import { hexToBytes } from "@noble/hashes/utils";
const secretKeyHex = process.env.NOSTR_SECRET_KEY;
if (!secretKeyHex) {
throw new Error("NOSTR_SECRET_KEY is required");
}
const secretKey = hexToBytes(secretKeyHex);
const publicKey = getPublicKey(secretKey);
const event = finalizeEvent(
{
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "bitcoin"],
["t", "lightning"]
],
content: "Studying signed events as an application protocol."
},
secretKey
);
console.log({
id: event.id,
pubkey: event.pubkey,
signature: event.sig,
npub: nip19.npubEncode(publicKey)
});
The npub... string is a bech32 representation of a public key for display. It is not the key itself and should not be used as the protocol-level identifier in application data. The hex public key is the canonical value inside an event.
Private keys are commonly represented as nsec... strings for humans. Treat an nsec exactly like a Bitcoin private key: never place it in a URL, log it, or send it to a relay.
Relays Are Independent Servers
Clients communicate with relays over WebSockets. A client can connect to multiple relays and publish the same event to each one.
There is no requirement that all relays contain the same data. A relay may:
- Accept events only from selected users
- Limit stored history
- Reject events above a size limit
- Require payment
- Apply rate limits
- Delete old events
- Index only certain event kinds
The client is responsible for selecting relays and handling partial availability. This is closer to a federation of event stores than to a replicated database with global consensus.
The wire protocol is deliberately small:
Client → Relay:
["EVENT", <event>]
["REQ", <subscription_id>, <filter>]
["CLOSE", <subscription_id>]
Relay → Client:
["EVENT", <subscription_id>, <event>]
["EOSE", <subscription_id>]
["OK", <event_id>, <accepted>, <message>]
["NOTICE", <message>]
The REQ message creates a subscription. The relay sends matching events and then sends EOSE, end of stored events before continuing with new events.
type RelayMessage =
| ["EVENT", NostrEvent]
| ["EOSE", string]
| ["OK", string, boolean, string]
| ["NOTICE", string];
function subscribeToNotes(ws: WebSocket, authors: string[]) {
const filter = {
kinds: [1],
authors,
limit: 50
};
ws.send(JSON.stringify(["REQ", "notes", filter]));
}
function publish(ws: WebSocket, event: NostrEvent) {
ws.send(JSON.stringify(["EVENT", event]));
}
A subscription is not a guarantee that the client has found every matching event. It is a query against one relay's view of the data. Robust clients deduplicate events by ID, reconnect, and query more than one relay.
Filters and Event References
NIP-01 filters support:
{
"ids": ["event-id-prefix"],
"authors": ["pubkey-prefix"],
"kinds": [1, 7],
"#e": ["event-id"],
"#p": ["pubkey"],
"since": 1710000000,
"until": 1710100000,
"limit": 100
}
The #e and #p keys refer to tags. A client can subscribe to reactions to a post, mentions of a user, or replies in a thread without a special endpoint.
NIP-19 defines human-readable encodings for common references:
-
npub: public key -
note: event ID -
nevent: event ID plus relay hints and author -
nprofile: profile plus relay hints -
naddr: addressable event reference
Relay hints matter because an event ID alone does not tell a client where to find the event. A reference that includes one or more relay URLs gives the receiving client a useful starting point.
NIP-05 Is Discovery, Not Identity
NIP-05 maps a human-readable identifier such as dev@example.com to a public key through a well-known HTTP endpoint:
https://example.com/.well-known/nostr.json?name=dev
The response contains a mapping:
{
"names": {
"dev": "32-byte-pubkey-in-hex"
},
"relays": {
"32-byte-pubkey-in-hex": [
"wss://relay.example.com"
]
}
}
This is useful for discovery and relay hints. It does not replace cryptographic verification. A client should verify that events are signed by the resolved public key rather than trusting the display name.
Lightning Zaps
Zaps are Lightning payments associated with Nostr events or profiles. NIP-57 defines the zap request and zap receipt flow.
At a high level:
- The client discovers a recipient's Lightning address or LNURL endpoint.
- The client creates a zap request event.
- The client sends that event to the recipient's LNURL endpoint.
- The endpoint returns a Lightning invoice.
- The client pays the invoice.
- The recipient's service publishes a zap receipt event.
The zap request contains the event being zapped, the recipient's public key, the amount, and a relay list where the receipt can be found.
{
"kind": 9734,
"content": "",
"tags": [
["relays", "wss://relay.damus.io", "wss://relay.example.com"],
["amount", "21000"],
["lnurl", "https://example.com/.well-known/lnurlp/alice"],
["p", "recipient-pubkey"],
["e", "event-being-zapped"]
]
}
The payment itself is not stored on Nostr. The Lightning settlement happens on the Lightning Network. The zap receipt is an authenticated event that links the payment metadata to the Nostr identity and event.
This separation is useful: Nostr carries signed social context; Lightning carries value.
Building a Bitcoin Monitoring Service on Nostr
The protocol is not limited to social posts. A Bitcoin service could publish signed alerts:
const alert = finalizeEvent(
{
kind: 7001,
created_at: Math.floor(Date.now() / 1000),
tags: [
["network", "mainnet"],
["txid", txid],
["severity", "warning"]
],
content: JSON.stringify({
type: "mempool-fee-spike",
fastest_fee_rate: 75,
observed_at_height: 850000
})
},
serviceSecretKey
);
Consumers can verify the signature before acting:
import { verifyEvent } from "nostr-tools";
function acceptAlert(event: NostrEvent, trustedPubkeys: Set<string>) {
if (!trustedPubkeys.has(event.pubkey)) {
return false;
}
if (!verifyEvent(event)) {
return false;
}
const severity = event.tags.find(([key]) => key === "severity")?.[1];
return severity === "warning" || severity === "critical";
}
The application still needs replay protection, rate limiting, freshness checks, and a trusted key-management process. A valid signature proves control of a key; it does not prove that the message is true.
Operational Tradeoffs
Nostr's simplicity creates responsibilities:
- Availability: A client must use several relays and reconnect gracefully.
- Moderation: Relays and clients need spam, abuse, and content filtering policies.
- Key recovery: Losing a private key can mean losing identity continuity.
- Privacy: Public events are broadly replicable; encrypted events still expose metadata.
-
Ordering:
created_atis supplied by the author and should not be treated as a globally trusted clock. - Deletion: A deletion request is advisory. Other relays or clients may retain copies.
Nostr is not a replacement for Bitcoin consensus, and it is not a globally consistent database. It is a signed publication and discovery layer.
That is precisely why it is useful for applications that need portable identity, relay diversity, and native Lightning payment context.
Further Reading
- NIP-01: Basic protocol flow — https://github.com/nostr-protocol/nips/blob/master/01.md
- NIP-05: Mapping keys to DNS identifiers — https://github.com/nostr-protocol/nips/blob/master/05.md
- NIP-19: bech32-encoded entities — https://github.com/nostr-protocol/nips/blob/master/19.md
- NIP-57: Lightning zaps — https://github.com/nostr-protocol/nips/blob/master/57.md
- NIP-65: Relay list metadata — https://github.com/nostr-protocol/nips/blob/master/65.md
- nostr-tools — https://github.com/nbd-wtf/nostr-tools
Top comments (0)