Sponsored Content

DEV Community

Aturo Phil
Aturo Phil

Posted on

Nostr for Bitcoin Developers: Events, Relays, Keys, and Zaps

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"
}
Enter fullscreen mode Exit fullscreen mode

The event ID is the SHA-256 hash of a serialized array:

[0, "<pubkey>", <created_at>, <kind>, <tags>, "<content>"]
Enter fullscreen mode Exit fullscreen mode

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)
});
Enter fullscreen mode Exit fullscreen mode

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>]
Enter fullscreen mode Exit fullscreen mode

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]));
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The response contains a mapping:

{
  "names": {
    "dev": "32-byte-pubkey-in-hex"
  },
  "relays": {
    "32-byte-pubkey-in-hex": [
      "wss://relay.example.com"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. The client discovers a recipient's Lightning address or LNURL endpoint.
  2. The client creates a zap request event.
  3. The client sends that event to the recipient's LNURL endpoint.
  4. The endpoint returns a Lightning invoice.
  5. The client pays the invoice.
  6. 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"]
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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";
}
Enter fullscreen mode Exit fullscreen mode

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_at is 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

Top comments (0)