Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Nostr Dev Kit

Nostr Dev Kit is a set of libraries for building Nostr clients, services, and relays. It provides protocol types, signing, relay communication, storage integrations, and an embeddable relay implementation through a consistent API.

This book is a short path from installation to a working application. It introduces the core types, publishes an event, then adds queries, subscriptions, persistent storage, Negentropy sync, and an embedded relay. It is not an API catalog or a guide to the Nostr protocol.

What this book assumes

You should already know what relays, events, kinds, tags, public keys, and NIPs are. The book does not reintroduce the protocol. No previous Nostr Dev Kit experience is required.

How to use this book

Read Getting started through Hello, Nostr! with one language selected. The next chapters explain the same program’s building blocks before introducing additional networking behavior.

Code in the language tabs comes from the snippet projects and is checked in CI against the pinned release. Network examples use public relays, whose availability and policies remain outside the SDK’s control.

Start with Choose a library. If the SDK is already installed, go directly to Hello, Nostr!.

Choose a library

Nostr Dev Kit includes several Rust crates. This book focuses on the two most commonly used directly: nostr and nostr-sdk. The native bindings package their protocol and higher-level SDK APIs together as nostr-sdk.

nostr

The Rust nostr crate contains protocol types and cryptographic operations. It builds and verifies events, parses keys and identifiers, constructs filters and messages, and implements supported NIPs. It does not connect to relays.

Use it for protocol-only libraries, offline tools, or applications with their own transport.

nostr-sdk

The Rust nostr-sdk crate re-exports nostr and adds:

  • the client and relay pool;
  • network requests and subscriptions;
  • gossip-based relay discovery and routing;
  • databases and Negentropy sync;
  • the embedded relay, behind the local-relay feature.

Use it for clients, bots, services, and relays.

Native bindings

Python, JavaScript, Kotlin, Swift, and C# each publish one nostr-sdk package containing protocol and SDK APIs. Names, durations, iterators, nullable values, and errors follow the host language; the language tabs show those differences.

JavaScript has separate packages for Node.js, Web, and React Native. Web uses WebAssembly and requires explicit initialization. It also cannot host an embedded relay or use the native LMDB backend.

The next chapter installs the selected package. The rest of the book uses the same concepts across all language tabs.

Installation

Rust

The Rust crates are published on crates.io as nostr and nostr-sdk. For protocol-only code:

[dependencies]
nostr = "0.45"

For a networked application:

[dependencies]
nostr-sdk = "0.45"

Both crates require Rust 1.85 or later. Start with default features; chapters that require another crate or an opt-in feature show it where it is used. See nostr and nostr-sdk feature lists.

Python

Python 3.9 or later is required. Install the package from PyPI:

pip install nostr-sdk==0.45.1

Supported platforms

Prebuilt wheels contain the native library for these targets:

OSx86_64aarch64armv7i686riscv64
Android
Linux (GLIBC)
Linux (MUSL)
FreeBSD
macOS
Windows

No running event loop

Normal client calls work inside asyncio.run. A custom database, websocket implementation, or admission policy can be called from an SDK-owned thread instead. If an asynchronous callback fails with no running event loop, register the application loop before constructing that callback:

import asyncio
from typing import cast

from nostr_sdk import uniffi_set_event_loop


async def configure_event_loop() -> None:
    loop = cast(asyncio.BaseEventLoop, asyncio.get_running_loop())
    uniffi_set_event_loop(loop)

Call this from inside the application’s running async entry point, not at module import time.

JavaScript

Install exactly one package for the target runtime from the NostrDevKit npm organization.

Node.js
npm install @nostrdevkit/nostr-sdk-node@0.45.1

Node.js 20 or later is required.

OSx86_64aarch64armv7i686riscv64
Android
iOS
Linux (GLIBC)
Linux (MUSL)
FreeBSD
macOS
Windows
Web
npm install @nostrdevkit/nostr-sdk-web@0.45.1

Use a bundler that emits the package’s WebAssembly asset. Call await uniffiInitAsync() once before constructing any generated SDK type.

RuntimeSupported
Web browsers
Node.js
React Native
React Native
npm install @nostrdevkit/nostr-sdk-react-native@0.45.1

React Native 0.76 or later with the New Architecture enabled is required.

Platformx86_64aarch64armv7i686
Android
iOS device
iOS simulator
Kotlin

Select one artifact for the application target:

dependencies {
    implementation("org.nostrdevkit:nostr-sdk:0.45.1")     // Android, minSdk 21
    implementation("org.nostrdevkit:nostr-sdk-jvm:0.45.1") // JVM 11+
    implementation("org.nostrdevkit:nostr-sdk-kmp:0.45.1") // Kotlin Multiplatform
}

Types are imported from org.nostrdevkit.sdk. The Android artifact requires API 21 or later, the standalone JVM artifact requires Java 11 or later, and the KMP JVM target requires Java 17.

Supported platforms

OSx86_64aarch64armv7i686riscv64Package
Androidnostr-sdk, nostr-sdk-kmp
iOSnostr-sdk-kmp
Linux (GLIBC)nostr-sdk-jvm, nostr-sdk-kmp
Linux (MUSL)nostr-sdk-jvm, nostr-sdk-kmp
FreeBSDnostr-sdk-jvm, nostr-sdk-kmp
macOSnostr-sdk-jvm, nostr-sdk-kmp
Windowsnostr-sdk-jvm, nostr-sdk-kmp

JNA dependency

Some Gradle configurations do not expose the binding’s JNA dependency to application code. If compilation fails with class file for com.sun.jna.Pointer not found, add JNA explicitly for the selected target:

dependencies {
    implementation("net.java.dev.jna:jna:5.17.0@aar") // Android
    implementation("net.java.dev.jna:jna:5.15.0")     // JVM
}

Use only the line matching the target. Kotlin Multiplatform projects should add the JVM dependency to jvmMain and the AAR variant to androidMain.

Swift

Add the nostr-sdk-swift repository in Xcode, or declare the package directly:

.package(url: "https://github.com/nostrdevkit/nostr-sdk-swift", exact: "0.45.1")

Supported platforms

OSx86_64aarch64armv7i686
iOS 14+ device
iOS Simulator
Mac Catalyst
macOS 12+
visionOS
watchOS
tvOS
C#

.NET 6 or later is required. Install the package from NuGet:

dotnet add package Nostr.Sdk --version 0.45.1

Supported platforms

OSx86_64aarch64armv7i686riscv64
Android
iOS device
iOS Simulator
Linux (GLIBC)
Linux (MUSL)
FreeBSD
macOS
Windows

Continue with Hello, Nostr! once the package is available to the project.

Hello, Nostr!

The first program publishes a signed text note. It covers identity, event construction, relay connection, and the publication result.

Rust
use nostr_sdk::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let keys = Keys::generate();
    let client = Client::default();

    client.add_relay("wss://relay.damus.io").await?;
    client.connect().await;

    let event = EventBuilder::new(Kind::TextNote, "Hello from Nostr Dev Kit!").finalize(&keys)?;
    let output = client.send_event(&event).await?;
    println!("Published {} to {:?}", output.id(), output.success);

    Ok(())
}
Python
import asyncio

from nostr_sdk import (
    Client,
    EventBuilder,
    Kind,
    KindStandard,
    Keys,
    RelayUrl,
)


async def main() -> None:
    keys = Keys.generate()
    client = Client()

    relay = RelayUrl.parse("wss://relay.damus.io")
    await client.add_relay(relay)
    await client.connect()

    event = EventBuilder(
        Kind.from_std(KindStandard.TEXT_NOTE), "Hello from Nostr Dev Kit!"
    ).finalize(keys)
    output = await client.send_event(event)
    print(f"Published {output.id.to_bech32()} to {output.success}")

if __name__ == "__main__":
    asyncio.run(main())
JavaScript
Node.js
import {
    Client,
    EventBuilder,
    Kind,
    KindStandard,
    Keys,
    RelayUrl,
} from "@nostrdevkit/nostr-sdk-node";

async function main() {
    const keys = Keys.generate();
    const client = new Client();

    const relay = RelayUrl.parse("wss://relay.damus.io");
    await client.addRelay(relay);
    await client.connect();

    const event = new EventBuilder(
        Kind.fromStd(KindStandard.TextNote),
        "Hello from Nostr Dev Kit!",
    ).finalize(keys);
    const output = await client.sendEvent(event);
    console.log(`Published ${output.id.toBech32()} to`, output.success);

}

await main();
Web
import {
    Client,
    EventBuilder,
    Kind,
    KindStandard,
    Keys,
    RelayUrl,
    uniffiInitAsync,
} from "@nostrdevkit/nostr-sdk-web";

async function main() {
    await uniffiInitAsync();

    const keys = Keys.generate();
    const client = new Client();
    await client.addRelay(RelayUrl.parse("wss://relay.damus.io"));
    await client.connect();

    const event = new EventBuilder(
        Kind.fromStd(KindStandard.TextNote),
        "Hello from Nostr Dev Kit!",
    ).finalize(keys);
    const output = await client.sendEvent(event);
    console.log(`Published ${output.id.toBech32()} to`, output.success);

}

await main();

The Web package must finish uniffiInitAsync() before any generated SDK type is used.

React Native
import {
    Client,
    EventBuilder,
    Kind,
    KindStandard,
    Keys,
    RelayUrl,
} from "@nostrdevkit/nostr-sdk-react-native";

export async function publishHello() {
    const keys = Keys.generate();
    const client = new Client();
    await client.addRelay(RelayUrl.parse("wss://relay.damus.io"));
    await client.connect();

    const event = new EventBuilder(
        Kind.fromStd(KindStandard.TextNote),
        "Hello from Nostr Dev Kit!",
    ).finalize(keys);
    const output = await client.sendEvent(event);
    console.log(`Published ${output.id.toBech32()} to`, output.success);

}

Call publishHello from an application task; React Native applications do not use top-level await.

Kotlin
package rust.nostr.snippets

import kotlinx.coroutines.runBlocking
import org.nostrdevkit.sdk.*

suspend fun publishHello() {
    val keys = Keys.generate()
    val client = Client()

    val relay = RelayUrl.parse("wss://relay.damus.io")
    client.addRelay(relay)
    client.connect()

    val event = EventBuilder(
        Kind.fromStd(KindStandard.TEXT_NOTE),
        "Hello from Nostr Dev Kit!",
    ).finalize(keys)
    val output = client.sendEvent(event)
    println("Published ${output.id.toBech32()} to ${output.success}")

}

fun main() = runBlocking {
    publishHello()
}
Swift
import Foundation
import NostrSDK

func publishHello() async throws {
    let keys = Keys.generate()
    let client = Client()

    let relay = try RelayUrl.parse(url: "wss://relay.damus.io")
    _ = try await client.addRelay(url: relay)
    await client.connect()

    let event = try EventBuilder(
        kind: Kind.fromStd(e: .textNote),
        content: "Hello from Nostr Dev Kit!"
    ).finalize(signer: keys)
    let output = try await client.sendEvent(event: event)
    print("Published \(try output.id.toBech32()) to \(output.success)")

}

Call publishHello() from an asynchronous application entry point.

C#
namespace Snippets;

using Nostr.Sdk;

public static class ClientExample
{
    public static async Task PublishHello()
    {
        var keys = Keys.Generate();
        var client = new Client();

        var relay = RelayUrl.Parse("wss://relay.damus.io");
        await client.AddRelay(relay);
        await client.Connect();

        var @event = new EventBuilder(
            Kind.FromStd(KindStandard.TextNote),
            "Hello from Nostr Dev Kit!"
        ).Finalize(keys);
        var output = await client.SendEvent(@event);
        Console.WriteLine($"Published {output.Id.ToBech32()} to {string.Join(", ", output.Success)}");

    }
}

Call ClientExample.PublishHello() from an asynchronous entry point.

Read the program

Every version performs the same four operations.

1. Create a temporary identity

Keys.generate creates a fresh key pair. Running the example again produces another author.

2. Construct the client

Client creates a relay pool with in-memory storage. The same instance is used to connect and publish.

3. Register and connect the relay

Adding the URL registers the relay; connect starts its managed connection work.

The example uses wss://relay.damus.io only as an accessible tutorial endpoint. Public relay uptime and write policy are outside the SDK’s control, so a rejection or timeout does not necessarily mean the program is assembled incorrectly.

4. Build, sign, and publish

The builder creates a NIP-01 text note.

send_event and its language equivalents wait for relay acknowledgements and return both the event ID and the relays that accepted it. Inspect this output when delivery guarantees matter; a successful call does not mean that every configured relay accepted the event.

The output has this shape; the actual ID and relay set vary on every run:

Published note1... to [wss://relay.damus.io/]

No explicit shutdown call is required. When the client is dropped, its relay connections and background tasks are stopped automatically.

The next three chapters take apart the values used here: keys and signers, events, and filters.

Keys and signers

Every event is attributed to a public key and authorized by a signature. The SDK separates the value that identifies an author from the mechanism that produces that signature:

  • PublicKey identifies an author and is safe to distribute.
  • SecretKey is private key material.
  • Keys contains a matching pair and signs directly.
  • a signer provides a public key and signatures without exposing its secret key.

The examples use Keys to keep signing visible. Use the signer interface when another component or device owns the secret.

Generate and parse an identity

The following code creates a temporary identity, encodes its public key as bech32, and parses it again.

Rust
use nostr::prelude::*;

fn keys_example() -> Result<(), Box<dyn std::error::Error>> {
    let keys = Keys::generate();
    let public_key = keys.public_key();
    let encoded = public_key.to_bech32()?;
    let parsed = PublicKey::parse(&encoded)?;

    assert_eq!(parsed, public_key);
    println!("Public key: {encoded}");
    Ok(())
}
Python
from nostr_sdk import Event, EventBuilder, Filter, Kind, KindStandard, Keys, PublicKey, Timestamp

def keys_example() -> None:
    keys = Keys.generate()
    public_key = keys.public_key()
    encoded = public_key.to_bech32()
    parsed = PublicKey.parse(encoded)

    assert parsed == public_key
    print(f"Public key: {encoded}")
JavaScript
Node.js
import {
    Event,
    EventBuilder,
    Filter,
    Kind,
    KindStandard,
    Keys,
    PublicKey,
    Timestamp,
} from "@nostrdevkit/nostr-sdk-node";

function keysExample() {
    const keys = Keys.generate();
    const publicKey = keys.publicKey();
    const encoded = publicKey.toBech32();
    const parsed = PublicKey.parse(encoded);

    console.log("Public key:", parsed.toBech32());
}
Web
import {
    Event,
    EventBuilder,
    Filter,
    Kind,
    KindStandard,
    Keys,
    PublicKey,
    Timestamp,
    uniffiInitAsync,
} from "@nostrdevkit/nostr-sdk-web";

await uniffiInitAsync();

function keysExample() {
    const keys = Keys.generate();
    const publicKey = keys.publicKey();
    const encoded = publicKey.toBech32();
    const parsed = PublicKey.parse(encoded);

    console.log("Public key:", parsed.toBech32());
}
React Native
import {
    Event,
    EventBuilder,
    Filter,
    Kind,
    KindStandard,
    Keys,
    PublicKey,
    Timestamp,
} from "@nostrdevkit/nostr-sdk-react-native";

export function keysExample() {
    const keys = Keys.generate();
    const publicKey = keys.publicKey();
    const encoded = publicKey.toBech32();
    const parsed = PublicKey.parse(encoded);

    console.log("Public key:", parsed.toBech32());
}
Kotlin
import org.nostrdevkit.sdk.*

fun keysExample() {
    val keys = Keys.generate()
    val publicKey = keys.publicKey()
    val encoded = publicKey.toBech32()
    val parsed = PublicKey.parse(encoded)

    check(parsed == publicKey)
    println("Public key: $encoded")
}
Swift
import NostrSDK

func keysExample() throws {
    let keys = Keys.generate()
    let publicKey = keys.publicKey()
    let encoded = try publicKey.toBech32()
    let parsed = try PublicKey.parse(publicKey: encoded)

    print("Public key: \(try parsed.toBech32())")
}
C#
using Nostr.Sdk;

public static class CoreExample
{

    public static void KeysExample()
    {
        var keys = Keys.Generate();
        var publicKey = keys.PublicKey();
        var encoded = publicKey.ToBech32();
        var parsed = PublicKey.Parse(encoded);

        Console.WriteLine($"Public key: {parsed.ToBech32()}");
    }
}

Keys.generate creates a new identity every time. Use it for tests or account creation, not when an existing identity is expected.

Public-key parsing accepts the supported hex and bech32 forms. Keep the parsed PublicKey as a typed value inside the application and encode it only at input or output boundaries.

Keys or signer

Keys signs locally. The signer interface covers hardware, remote signers, browser extensions, and other external implementations. In both cases the SDK flow is the same:

EventBuilder --> unsigned event --> signer --> Event --> Client

External signing can be asynchronous, rejected, or cancelled. Once signed, the resulting Event is independent of the signer and can be passed to the client.

With an identity available, the next chapter constructs the value it signs: an event.

Events

An event is the immutable, signed unit exchanged by Nostr applications. Its ID commits to the author’s public key, creation time, kind, tags, and content; changing any of those fields produces a different event and requires a new signature.

EventBuilder collects kind, content, tags, and an optional timestamp. Finalization creates the canonical unsigned representation; signing produces the Event that can be verified, serialized, stored, or published.

Because its ID and signature cover its fields, a finished Event is immutable.

Build, verify, and decode

This example creates a text note, verifies it, round-trips its protocol JSON, and verifies the decoded value again:

Rust
use nostr::prelude::*;

fn event_example() -> Result<(), Box<dyn std::error::Error>> {
    let keys = Keys::generate();
    let event = EventBuilder::new(Kind::TextNote, "Hello, Nostr!").finalize(&keys)?;
    event.verify()?;

    let decoded = Event::from_json(event.as_json())?;
    decoded.verify()?;
    println!("Event ID: {}", decoded.id.to_bech32()?);
    Ok(())
}
Python
from nostr_sdk import Event, EventBuilder, Filter, Kind, KindStandard, Keys, PublicKey, Timestamp

def event_example() -> None:
    keys = Keys.generate()
    event = EventBuilder(
        Kind.from_std(KindStandard.TEXT_NOTE), "Hello, Nostr!"
    ).finalize(keys)
    assert event.verify()

    decoded = Event.from_json(event.as_json())
    assert decoded.verify()
    print(f"Event ID: {decoded.id().to_bech32()}")
JavaScript
Node.js
import {
    Event,
    EventBuilder,
    Filter,
    Kind,
    KindStandard,
    Keys,
    PublicKey,
    Timestamp,
} from "@nostrdevkit/nostr-sdk-node";

function eventExample() {
    const keys = Keys.generate();
    const event = new EventBuilder(
        Kind.fromStd(KindStandard.TextNote),
        "Hello, Nostr!",
    ).finalize(keys);
    if (!event.verify()) throw new Error("invalid event");

    const decoded = Event.fromJson(event.asJson());
    if (!decoded.verify()) throw new Error("invalid decoded event");
    console.log("Event ID:", decoded.id().toBech32());
}
Web
import {
    Event,
    EventBuilder,
    Filter,
    Kind,
    KindStandard,
    Keys,
    PublicKey,
    Timestamp,
    uniffiInitAsync,
} from "@nostrdevkit/nostr-sdk-web";

await uniffiInitAsync();

function eventExample() {
    const keys = Keys.generate();
    const event = new EventBuilder(
        Kind.fromStd(KindStandard.TextNote),
        "Hello, Nostr!",
    ).finalize(keys);
    if (!event.verify()) throw new Error("invalid event");

    const decoded = Event.fromJson(event.asJson());
    if (!decoded.verify()) throw new Error("invalid decoded event");
    console.log("Event ID:", decoded.id().toBech32());
}
React Native
import {
    Event,
    EventBuilder,
    Filter,
    Kind,
    KindStandard,
    Keys,
    PublicKey,
    Timestamp,
} from "@nostrdevkit/nostr-sdk-react-native";

export function eventExample() {
    const keys = Keys.generate();
    const event = new EventBuilder(
        Kind.fromStd(KindStandard.TextNote),
        "Hello, Nostr!",
    ).finalize(keys);
    if (!event.verify()) throw new Error("invalid event");

    const decoded = Event.fromJson(event.asJson());
    if (!decoded.verify()) throw new Error("invalid decoded event");
    console.log("Event ID:", decoded.id().toBech32());
}
Kotlin
import org.nostrdevkit.sdk.*

fun eventExample() {
    val keys = Keys.generate()
    val event = EventBuilder(
        Kind.fromStd(KindStandard.TEXT_NOTE),
        "Hello, Nostr!",
    ).finalize(keys)
    check(event.verify())

    val decoded = Event.fromJson(event.asJson())
    check(decoded.verify())
    println("Event ID: ${decoded.id().toBech32()}")
}
Swift
import NostrSDK

func eventExample() throws {
    let keys = Keys.generate()
    let event = try EventBuilder(
        kind: Kind.fromStd(e: .textNote),
        content: "Hello, Nostr!"
    ).finalize(signer: keys)
    precondition(event.verify())

    let decoded = try Event.fromJson(json: event.asJson())
    precondition(decoded.verify())
    print("Event ID: \(try decoded.id().toBech32())")
}
C#
using Nostr.Sdk;

public static class CoreExample
{

    public static void EventExample()
    {
        var keys = Keys.Generate();
        var @event = new EventBuilder(
            Kind.FromStd(KindStandard.TextNote),
            "Hello, Nostr!"
        ).Finalize(keys);
        if (!@event.Verify()) throw new InvalidOperationException("Invalid event");

        var decoded = Event.FromJson(@event.AsJson());
        if (!decoded.Verify()) throw new InvalidOperationException("Invalid decoded event");
        Console.WriteLine($"Event ID: {decoded.Id().ToBech32()}");
    }
}

Event identity and retries

Changing any signed input produces a different event ID and requires a new signature. Retain the signed Event and resend it when retrying publication.

Rebuilding from an EventBuilder is a new creation attempt. With the default timestamp it creates a new event, even if the displayed content is identical. That may be intentional for a new note, but it is not an idempotent retry.

The next chapter uses typed event fields to describe a relay query with a filter.

Filters

A Filter describes a set of events without prescribing which relay should answer the query. Keeping those concerns separate lets the same filter target the whole pool, one relay, or a manually selected group.

Filter composition follows two rules:

  • constraints inside one filter are combined with AND;
  • multiple filters in one request are combined with OR.

For example, one filter containing an author and a text-note kind asks for text notes by that author. Two separate filters, one for the author and one for the kind, also match every event by that author and every text note by anyone.

A filter can constrain IDs, authors, kinds, timestamps, tags, and the number of stored results requested from each relay.

Construct a bounded filter

The example limits the author, kind, lower time boundary, and number of stored results requested from each relay:

Rust
use nostr::prelude::*;

fn filter_example() {
    let public_key = Keys::generate().public_key();
    let filter = Filter::new()
        .author(public_key)
        .kind(Kind::TextNote)
        .since(Timestamp::now())
        .limit(20);

    println!("Filter: {}", filter.as_json());
}
Python
from nostr_sdk import Event, EventBuilder, Filter, Kind, KindStandard, Keys, PublicKey, Timestamp

def filter_example() -> None:
    public_key = Keys.generate().public_key()
    filter = (
        Filter()
        .author(public_key)
        .kind(Kind.from_std(KindStandard.TEXT_NOTE))
        .since(Timestamp.now())
        .limit(20)
    )

    print(f"Filter: {filter.as_json()}")
JavaScript
Node.js
import {
    Event,
    EventBuilder,
    Filter,
    Kind,
    KindStandard,
    Keys,
    PublicKey,
    Timestamp,
} from "@nostrdevkit/nostr-sdk-node";

function filterExample() {
    const publicKey = Keys.generate().publicKey();
    const filter = new Filter()
        .author(publicKey)
        .kind(Kind.fromStd(KindStandard.TextNote))
        .since(Timestamp.now())
        .limit(20n);

    console.log("Filter:", filter.asJson());
}
Web
import {
    Event,
    EventBuilder,
    Filter,
    Kind,
    KindStandard,
    Keys,
    PublicKey,
    Timestamp,
    uniffiInitAsync,
} from "@nostrdevkit/nostr-sdk-web";

await uniffiInitAsync();

function filterExample() {
    const publicKey = Keys.generate().publicKey();
    const filter = new Filter()
        .author(publicKey)
        .kind(Kind.fromStd(KindStandard.TextNote))
        .since(Timestamp.now())
        .limit(20n);

    console.log("Filter:", filter.asJson());
}
React Native
import {
    Event,
    EventBuilder,
    Filter,
    Kind,
    KindStandard,
    Keys,
    PublicKey,
    Timestamp,
} from "@nostrdevkit/nostr-sdk-react-native";

export function filterExample() {
    const publicKey = Keys.generate().publicKey();
    const filter = new Filter()
        .author(publicKey)
        .kind(Kind.fromStd(KindStandard.TextNote))
        .since(Timestamp.now())
        .limit(20n);

    console.log("Filter:", filter.asJson());
}
Kotlin
import org.nostrdevkit.sdk.*

fun filterExample() {
    val publicKey = Keys.generate().publicKey()
    val filter = Filter()
        .author(publicKey)
        .kind(Kind.fromStd(KindStandard.TEXT_NOTE))
        .since(Timestamp.now())
        .limit(20u)

    println("Filter: ${filter.asJson()}")
}
Swift
import NostrSDK

func filterExample() throws {
    let publicKey = Keys.generate().publicKey()
    let filter = Filter()
        .author(author: publicKey)
        .kind(kind: Kind.fromStd(e: .textNote))
        .since(timestamp: Timestamp.now())
        .limit(limit: 20)

    print("Filter: \(try filter.asJson())")
}
C#
using Nostr.Sdk;

public static class CoreExample
{

    public static void FilterExample()
    {
        var publicKey = Keys.Generate().PublicKey();
        var filter = new Filter()
            .Author(publicKey)
            .Kind(Kind.FromStd(KindStandard.TextNote))
            .Since(Timestamp.Now())
            .Limit(20);

        Console.WriteLine($"Filter: {filter.AsJson()}");
    }
}

This asks each relay for at most 20 text notes by the selected key at or after the timestamp. All four constraints are in one filter, so they are combined with AND.

Compose AND and OR deliberately

Suppose an application needs text notes from Alice or Bob. One filter with both authors expresses that request:

authors = [Alice, Bob] AND kinds = [TextNote]

Suppose it instead needs Alice’s text notes plus Bob’s metadata. That requires two filters:

(authors = [Alice] AND kinds = [TextNote])
OR
(authors = [Bob] AND kinds = [Metadata])

Each additional filter is another OR branch and can expand the response.

Tag filters follow the same rule. Multiple accepted values for one tag name are alternatives within that constraint; constraints for different tag names must all match. Use the typed single-letter tag APIs where available instead of assembling # field names as strings.

Time windows

since and until are inclusive. Use them to bound a request to the history relevant to the application.

When maintaining local history, reconcile a bounded filter with Negentropy sync instead of walking through relay history page by page. Sync compares event IDs and transfers only the missing events.

For a live subscription, since also prevents an unbounded initial history. limit bounds stored results from each relay but does not replace a time window.

Understand pool-wide results

The limit is applied by each relay, not globally across the pool. Results can overlap or arrive out of order; the SDK validates and deduplicates them by event ID. Relay selection remains separate and is added by the request target.

Filters become network work only when paired with a client and request target.

Client and relays

Client owns the relay pool, the configured database, subscriptions, and the notification stream. Reuse one client while those resources should remain active; creating a new client also creates a new pool and loses its connection and subscription state.

Each relay in the pool has its own connection state and operation result. One relay may accept an event while another rejects it or is offline, so multi-relay operations return successful and failed relay outcomes instead of one boolean.

Build the client

Client::default and the equivalent binding constructors use in-memory storage. Use ClientBuilder when the client needs a persistent database or other pool-wide configuration.

The client transports and stores signed events. Signing remains separate: produce an Event with Keys or another signer, then pass that event to the client.

Adding a relay registers it with the pool; connect starts managed connection and reconnection work. Operations still report availability per relay.

Select relays

A relay can be configured for read, write, discovery, or synchronization work. Request targets then choose the relays for one operation:

  • automatic targets use eligible relays from the pool;
  • explicit targets restrict an operation to selected relays;
  • manual targets can associate different filters with different relays.

Capabilities are persistent pool configuration; a request target applies only to one request. Use explicit targets when a specific relay is part of the operation’s requirement. Otherwise, let the client resolve the pool target.

The next chapter enables gossip, allowing automatic targets to select relays from discovered NIP-65 and NIP-17 lists.

Operation outputs

Publishing and subscription setup return the main value together with per-relay success and failure maps. Inspect those maps before declaring the operation successful. Acceptance by one relay, every configured relay, or a specific relay are different guarantees, and the SDK does not choose one for the application.

When retrying a publication, reuse the same signed Event. Rebuilding it normally changes its timestamp and event ID.

Database and cleanup

The relay pool moves events; the database stores and queries them. A local query does not contact a relay, and a successful query does not say whether the event is still available remotely. The next chapter configures a persistent database.

No explicit shutdown is required. Dropping the client stops its relay connections and background tasks. Use disconnect only when connections should stop while the client remains available, and unsubscribe when one live subscription ends before the client does.

Invalid URLs and request construction fail the operation directly. Relay-specific failures can coexist with successful results from other relays, so preserve that distinction when handling an output.

The following chapters cover streaming, collecting, and live subscriptions.

Gossip

Gossip lets the client select relays for the public keys involved in an operation instead of sending every request to every relay in the pool. It learns read and write relays from NIP-65 lists, inbox relays from NIP-17 lists, and relay hints observed in events.

The gossip store is separate from the event database. It contains relay-selection data and the information needed to refresh it. SQLite is recommended when the platform supports it because that knowledge survives restarts; the in-memory store is useful for Web, tests, and short-lived processes.

Configure the client

Rust

Rust provides the stores as separate crates. Add the SQLite store to the existing SDK dependencies:

[dependencies]
nostr-gossip-sqlite = "0.45"

For an ephemeral store, use this dependency instead:

nostr-gossip-memory = "0.45"
use nostr_gossip_sqlite::prelude::NostrGossipSqlite;
use nostr_sdk::prelude::*;

async fn build_gossip_client() -> Result<Client, Box<dyn std::error::Error>> {
    let gossip = NostrGossipSqlite::open("./data/gossip.sqlite").await?;
    let client = Client::builder().gossip(gossip).build();

    client
        .add_relay("wss://relay.damus.io")
        .capabilities(RelayCapabilities::DISCOVERY)
        .await?;
    client
        .add_relay("wss://purplepag.es")
        .capabilities(RelayCapabilities::DISCOVERY)
        .await?;
    client.connect().await;

    Ok(client)
}
Python
from nostr_sdk import (
    Client,
    ClientBuilder,
    NostrGossip,
    RelayCapabilities,
    RelayUrl,
)


async def build_gossip_client() -> Client:
    gossip = await NostrGossip.sqlite("./data/gossip.sqlite")
    client = ClientBuilder().gossip(gossip).build()
    capabilities = RelayCapabilities.discovery()

    await client.add_relay(
        RelayUrl.parse("wss://relay.damus.io"), capabilities=capabilities
    )
    await client.add_relay(
        RelayUrl.parse("wss://purplepag.es"), capabilities=capabilities
    )
    await client.connect()

    return client
JavaScript
Node.js
import {
    ClientBuilder,
    NostrGossip,
    RelayCapabilities,
    RelayUrl,
} from "@nostrdevkit/nostr-sdk-node";

async function buildGossipClient() {
    const gossip = await NostrGossip.sqlite("./data/gossip.sqlite");
    const client = new ClientBuilder().gossip(gossip).build();
    const capabilities = RelayCapabilities.discovery();

    await client.addRelay(RelayUrl.parse("wss://relay.damus.io"), capabilities, false, undefined);
    await client.addRelay(RelayUrl.parse("wss://purplepag.es"), capabilities, false, undefined);
    await client.connect(undefined);

    return client;
}
Web
import {
    ClientBuilder,
    NostrGossip,
    RelayCapabilities,
    RelayUrl,
    uniffiInitAsync,
} from "@nostrdevkit/nostr-sdk-web";

async function buildGossipClient() {
    await uniffiInitAsync();

    const gossip = NostrGossip.inMemory();
    const client = new ClientBuilder().gossip(gossip).build();
    const capabilities = RelayCapabilities.discovery();

    await client.addRelay(RelayUrl.parse("wss://relay.damus.io"), capabilities, false, undefined);
    await client.addRelay(RelayUrl.parse("wss://purplepag.es"), capabilities, false, undefined);
    await client.connect(undefined);

    return client;
}

The Web package does not expose SQLite, so gossip data is rebuilt after a page reload.

React Native
import {
    ClientBuilder,
    NostrGossip,
    RelayCapabilities,
    RelayUrl,
} from "@nostrdevkit/nostr-sdk-react-native";

export async function buildGossipClient(databasePath: string) {
    const gossip = await NostrGossip.sqlite(databasePath);
    const client = new ClientBuilder().gossip(gossip).build();
    const capabilities = RelayCapabilities.discovery();

    await client.addRelay(RelayUrl.parse("wss://relay.damus.io"), capabilities, false, undefined);
    await client.addRelay(RelayUrl.parse("wss://purplepag.es"), capabilities, false, undefined);
    await client.connect(undefined);

    return client;
}

Pass an absolute path inside the application’s data directory as databasePath.

Kotlin
import org.nostrdevkit.sdk.*

suspend fun buildGossipClient(databasePath: String): Client {
    val gossip = NostrGossip.sqlite(databasePath)
    val client = ClientBuilder().gossip(gossip).build()
    val capabilities = RelayCapabilities.discovery()

    client.addRelay(RelayUrl.parse("wss://relay.damus.io"), capabilities)
    client.addRelay(RelayUrl.parse("wss://purplepag.es"), capabilities)
    client.connect()

    return client
}

On Android, pass a path inside the application files directory.

Swift
import NostrSDK

func buildGossipClient(databasePath: String) async throws -> Client {
    let gossip = try await NostrGossip.sqlite(path: databasePath)
    let client = ClientBuilder().gossip(gossip: gossip).build()
    let capabilities = RelayCapabilities.discovery()

    _ = try await client.addRelay(
        url: try RelayUrl.parse(url: "wss://relay.damus.io"),
        capabilities: capabilities
    )
    _ = try await client.addRelay(
        url: try RelayUrl.parse(url: "wss://purplepag.es"),
        capabilities: capabilities
    )
    await client.connect()

    return client
}

Pass a path inside Application Support or another application-owned directory.

C#
namespace Snippets;

using Nostr.Sdk;

public static class GossipExample
{
    public static async Task<Client> BuildGossipClient(string databasePath)
    {
        var gossip = await NostrGossip.Sqlite(databasePath);
        var client = new ClientBuilder().Gossip(gossip).Build();
        var capabilities = RelayCapabilities.Discovery();

        await client.AddRelay(RelayUrl.Parse("wss://relay.damus.io"), capabilities);
        await client.AddRelay(RelayUrl.Parse("wss://purplepag.es"), capabilities);
        await client.Connect();

        return client;
    }
}

ClientBuilder.gossip enables gossip routing. The discovery relays are marked with DISCOVERY, so they are used to refresh relay lists rather than as general read and write relays. Configure more than one discovery relay so one unavailable service does not prevent updates.

Requests, subscriptions, sync, and publishing continue to use their normal APIs. With automatic targets, filters and events containing public keys allow the client to select the corresponding discovered relays. Explicit or manual targets remain explicit and bypass automatic gossip selection.

Databases

The client database is the local event store used by queries and reconciliation. It indexes events by Nostr filters and applies protocol storage rules such as replacement and deletion.

Configure a persistent backend before building the client when local history must survive restarts or be used by Negentropy sync.

Use LMDB

Rust provides LMDB through the separate nostr-lmdb crate. The native bindings include NostrLmdb in the nostr-sdk package. Other Rust backends are listed in the Nostr repository.

The usual setup is to open one database for an application-owned path, pass it to ClientBuilder, and query it through the client. Binding-specific differences are noted in their tabs.

Rust

Add the backend alongside nostr-sdk:

[dependencies]
nostr-lmdb = "0.45"
use nostr_lmdb::NostrLmdb;
use nostr_sdk::prelude::*;

async fn query_database() -> Result<(), Box<dyn std::error::Error>> {
    let database = NostrLmdb::open("./data/nostr").await?;
    let client = ClientBuilder::default().database(database).build();

    let filter = Filter::new().kind(Kind::TextNote).limit(20);
    let events = client.database().query(filter).await?;
    println!("Found {} stored events", events.len());

    Ok(())
}
Python
from nostr_sdk import (
    ClientBuilder,
    Filter,
    Kind,
    KindStandard,
    NostrLmdb,
    RelayUrl,
    SyncDirection,
    SyncOptions,
)

async def query_database() -> None:
    database = await NostrLmdb.open("./data/nostr")
    client = ClientBuilder().database(database).build()

    filter = Filter().kind(Kind.from_std(KindStandard.TEXT_NOTE)).limit(20)
    events = await client.database().query(filter)
    print(f"Found {len(events)} stored events")

JavaScript
Node.js
import {
    ClientBuilder,
    Filter,
    Kind,
    KindStandard,
    NostrLmdb,
    RelayUrl,
    SyncDirection,
    SyncOptions,
} from "@nostrdevkit/nostr-sdk-node";

async function queryDatabase() {
    const database = await NostrLmdb.open("./data/nostr");
    const client = new ClientBuilder().database(database).build();

    const filter = new Filter()
        .kind(Kind.fromStd(KindStandard.TextNote))
        .limit(20n);
    const events = await client.database().query(filter);
    console.log(`Found ${events.length} stored events`);
}
Web

The Web package exposes the database interface but does not bundle a persistent backend. Implement NostrDatabase over browser storage and pass it to ClientBuilder.database; the default in-memory store is not a replacement for durable browser data.

React Native
import {
    ClientBuilder,
    Filter,
    Kind,
    KindStandard,
    NostrLmdb,
    RelayUrl,
    SyncDirection,
    SyncOptions,
} from "@nostrdevkit/nostr-sdk-react-native";

export async function queryDatabase(databasePath: string) {
    const database = await NostrLmdb.open(databasePath);
    const client = new ClientBuilder().database(database).build();

    const filter = new Filter()
        .kind(Kind.fromStd(KindStandard.TextNote))
        .limit(20n);
    const events = await client.database().query(filter);
    console.log(`Found ${events.length} stored events`);
}

Pass an absolute path inside the application’s data directory as databasePath.

Kotlin
import org.nostrdevkit.sdk.*

suspend fun queryDatabase() {
    val database = NostrLmdb.open("./data/nostr")
    val client = ClientBuilder().database(database).build()

    val filter = Filter()
        .kind(Kind.fromStd(KindStandard.TEXT_NOTE))
        .limit(20u)
    val events = client.database().query(filter)
    println("Found ${events.size} stored events")
}

On Android, use a path inside the application files directory rather than a relative path.

Swift
import Foundation
import NostrSDK

func queryDatabase(path: String) async throws {
    let database = try await NostrLmdb.open(path: path)
    let client = ClientBuilder().database(database: database).build()

    let filter = Filter()
        .kind(kind: Kind.fromStd(e: .textNote))
        .limit(limit: 20)
    let events = try await client.database().query(filter: filter)
    print("Found \(events.count) stored events")
}

Pass a URL-derived path inside Application Support or another application-owned directory.

C#
using Nostr.Sdk;

public static class DatabaseExample
{

    public static async Task QueryDatabase(string path)
    {
        var database = await NostrLmdb.Open(path);
        var client = new ClientBuilder().Database(database).Build();

        var filter = new Filter()
            .Kind(Kind.FromStd(KindStandard.TextNote))
            .Limit(20);
        var events = await client.Database().Query(filter);
        Console.WriteLine($"Found {events.Length} stored events");
    }
}

client.database().query() is a local operation. It does not contact relays and an empty result says only that no matching event is currently stored. Use a finite request when the feature needs a fresh network read, or sync before querying when the local store is the application’s read model.

How events reach the store

Validated events received through client requests and subscriptions are available to the configured database. Sync also writes events downloaded from relays. Applications may call save_event directly for an already verified event, but that method assumes verification has happened; do not use it as an ingestion path for untrusted wire data.

Database filters use the same Filter type as relay requests, but a local query has no network freshness or relay provenance. Use a relay request for fresh remote data, or sync first when the database is the application’s local view.

Streaming events

Most finite reads should start with stream_events. The SDK opens a short-lived request, validates and deduplicates matching events across the selected relays, and yields each result as soon as it arrives. The application can begin work immediately and does not need to retain the complete result set in memory.

This is different from a live subscription. A finite stream normally ends at end-of-stored-events (EOSE), when all relay streams end, or when its timeout expires. A live subscription remains registered until it is closed.

For a pool request, EOSE happens per relay. One relay may finish immediately, another may yield events first, and a third may fail or reach the timeout. The SDK merges those independent flows into one stream while preserving the relay URL on each item.

The following example reads up to 20 text notes per relay and applies a ten-second upper bound:

Rust
use std::time::Duration;

use nostr_sdk::prelude::*;

async fn stream_events() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::default();
    client.add_relay("wss://relay.damus.io").await?;
    client.connect().await;

    let filter = Filter::new().kind(Kind::TextNote).limit(20);
    let mut stream = client
        .stream_events(filter)
        .timeout(Duration::from_secs(10))
        .await?;

    while let Some((relay_url, result)) = stream.next().await {
        match result {
            Ok(event) => println!("{relay_url}: {}", event.as_json()),
            Err(error) => eprintln!("{relay_url}: {error}"),
        }
    }

    Ok(())
}
Python
import asyncio
from datetime import timedelta

from nostr_sdk import Client, Filter, Kind, KindStandard, RelayUrl, ReqTarget

async def stream_events() -> None:
    client = Client()
    await client.add_relay(RelayUrl.parse("wss://relay.damus.io"))
    await client.connect()

    filter = Filter().kind(Kind.from_std(KindStandard.TEXT_NOTE)).limit(20)
    stream = await client.stream_events(
        ReqTarget.auto([filter]), timeout=timedelta(seconds=10)
    )

    while item := await stream.next():
        if item.event is not None:
            print(f"{item.relay_url}: {item.event.as_json()}")
        elif item.error is not None:
            print(f"{item.relay_url}: {item.error}")

JavaScript
Node.js
import {
    Client,
    Filter,
    Kind,
    KindStandard,
    RelayUrl,
    ReqTarget,
} from "@nostrdevkit/nostr-sdk-node";

async function streamEvents() {
    const client = new Client();
    await client.addRelay(RelayUrl.parse("wss://relay.damus.io"));
    await client.connect();

    const filter = new Filter()
        .kind(Kind.fromStd(KindStandard.TextNote))
        .limit(20n);
    const stream = await client.streamEvents(
        ReqTarget.auto([filter]),
        undefined,
        10_000,
    );

    while (true) {
        const item = await stream.next();
        if (!item) break;

        if (item.event) {
            console.log(`${item.relayUrl}: ${item.event.asJson()}`);
        } else if (item.error) {
            console.error(`${item.relayUrl}: ${item.error}`);
        }
    }

}
Web
import {
    Client,
    Filter,
    Kind,
    KindStandard,
    RelayUrl,
    ReqTarget,
    uniffiInitAsync,
} from "@nostrdevkit/nostr-sdk-web";

await uniffiInitAsync();

async function streamEvents() {
    const client = new Client();
    await client.addRelay(RelayUrl.parse("wss://relay.damus.io"));
    await client.connect();

    const filter = new Filter()
        .kind(Kind.fromStd(KindStandard.TextNote))
        .limit(20n);
    const stream = await client.streamEvents(
        ReqTarget.auto([filter]),
        undefined,
        10_000,
    );

    while (true) {
        const item = await stream.next();
        if (!item) break;

        if (item.event) {
            console.log(`${item.relayUrl}: ${item.event.asJson()}`);
        } else if (item.error) {
            console.error(`${item.relayUrl}: ${item.error}`);
        }
    }

}
React Native
import {
    Client,
    Filter,
    Kind,
    KindStandard,
    RelayUrl,
    ReqTarget,
} from "@nostrdevkit/nostr-sdk-react-native";

export async function streamEvents() {
    const client = new Client();
    await client.addRelay(RelayUrl.parse("wss://relay.damus.io"));
    await client.connect();

    const filter = new Filter()
        .kind(Kind.fromStd(KindStandard.TextNote))
        .limit(20n);
    const stream = await client.streamEvents(
        ReqTarget.auto([filter]),
        undefined,
        10_000,
    );

    while (true) {
        const item = await stream.next();
        if (!item) break;

        if (item.event) {
            console.log(`${item.relayUrl}: ${item.event.asJson()}`);
        } else if (item.error) {
            console.error(`${item.relayUrl}: ${item.error}`);
        }
    }

}
Kotlin
import java.time.Duration
import kotlinx.coroutines.runBlocking
import org.nostrdevkit.sdk.*

suspend fun streamEvents() {
    val client = Client()
    client.addRelay(RelayUrl.parse("wss://relay.damus.io"))
    client.connect()

    val filter = Filter()
        .kind(Kind.fromStd(KindStandard.TEXT_NOTE))
        .limit(20u)
    val stream = client.streamEvents(
        ReqTarget.auto(listOf(filter)),
        timeout = Duration.ofSeconds(10),
    )

    while (true) {
        val item = stream.next() ?: break
        val event = item.event
        val error = item.error
        when {
            event != null -> println("${item.relayUrl}: ${event.asJson()}")
            error != null -> System.err.println("${item.relayUrl}: $error")
        }
    }

}
Swift
import Foundation
import NostrSDK

func streamEvents() async throws {
    let client = Client()
    let relay = try RelayUrl.parse(url: "wss://relay.damus.io")
    _ = try await client.addRelay(url: relay)
    await client.connect()

    let filter = Filter()
        .kind(kind: Kind.fromStd(e: .textNote))
        .limit(limit: 20)
    let stream = try await client.streamEvents(
        target: ReqTarget.auto(filters: [filter]),
        timeout: 10.0
    )

    while let item = await stream.next() {
        if let event = item.event {
            print("\(item.relayUrl): \(try event.asJson())")
        } else if let error = item.error {
            print("\(item.relayUrl): \(error)")
        }
    }

}
C#
using Nostr.Sdk;

public static class ReadExample
{

    public static async Task StreamEvents()
    {
        var client = new Client();
        await client.AddRelay(RelayUrl.Parse("wss://relay.damus.io"));
        await client.Connect();

        var filter = new Filter()
            .Kind(Kind.FromStd(KindStandard.TextNote))
            .Limit(20);
        using var stream = await client.StreamEvents(
            ReqTarget.Auto([filter]),
            timeout: TimeSpan.FromSeconds(10)
        );

        while (await stream.Next() is { } item)
        {
            if (item.Event is { } @event)
            {
                Console.WriteLine($"{item.RelayUrl}: {@event.AsJson()}");
            }
            else if (item.Error is { } error)
            {
                Console.Error.WriteLine($"{item.RelayUrl}: {error}");
            }
        }

    }
}

ReqTarget.auto resolves eligible relays, the timeout bounds the operation, and next yields merged relay outcomes. The loop can process the first event without waiting for every relay or buffering the complete result set.

Stream items

Each item is one relay outcome. Rust returns a relay URL with Result<Event, _>; the bindings expose the URL and either an event or an error.

Process successful events even if another relay reports an error. Events are validated and deduplicated by ID before they reach the client stream, so seeing the same event on several relays does not require application-level deduplication for that request. The relay URL remains useful for diagnostics and for features whose policy depends on where an event was observed.

The stream completes when next returns no item. EOSE applies to the queried relays; it is not a claim that no other relay has matching events.

Request targets and termination

The filter describes what should match; the request target describes where it should be requested. ReqTarget.auto uses relays with read capability and can incorporate relay discovery when gossip is enabled. single and manual targets are available when the application needs exact relay selection or different filters per relay.

EOSE is the default successful completion policy; the timeout is the upper bound when completion does not arrive. Other exit policies are available in the API reference. Stop consuming and release the stream when the caller has enough data.

Use collecting events when the next operation genuinely requires the complete result set.

Collecting events

fetch_events runs the same kind of finite request as stream_events, but buffers matching events and returns a deduplicated collection only after the request terminates. It is convenient for a small result set that must be sorted, compared, or passed to an API expecting a collection.

Prefer streaming when events can be processed independently, the response size is uncertain, or latency to the first result matters.

Choose between the two finite request shapes based on the consumer:

RequirementPrefer
Process each event independentlystream_events
Start work before all relays reach EOSEstream_events
Keep memory bounded by downstream capacitystream_events
Sort or compare the complete small result setfetch_events
Pass a collection to an existing APIfetch_events

fetch_events is not more authoritative than a stream. It uses the same relay request and termination model, then materializes the successful events for convenience. The tradeoff is that the caller receives no value until the operation ends and does not retain per-item relay provenance.

Rust
use std::time::Duration;

use nostr_sdk::prelude::*;

async fn fetch_events() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::default();
    client.add_relay("wss://relay.damus.io").await?;
    client.connect().await;

    let filter = Filter::new().kind(Kind::TextNote).limit(20);
    let events = client
        .fetch_events(filter)
        .timeout(Duration::from_secs(10))
        .await?;

    for event in events {
        println!("{}", event.as_json());
    }

    Ok(())
}
Python
import asyncio
from datetime import timedelta

from nostr_sdk import Client, Filter, Kind, KindStandard, RelayUrl, ReqTarget

async def fetch_events() -> None:
    client = Client()
    await client.add_relay(RelayUrl.parse("wss://relay.damus.io"))
    await client.connect()

    filter = Filter().kind(Kind.from_std(KindStandard.TEXT_NOTE)).limit(20)
    events = await client.fetch_events(
        ReqTarget.auto([filter]), timeout=timedelta(seconds=10)
    )

    for event in events:
        print(event.as_json())

JavaScript
Node.js
import {
    Client,
    Filter,
    Kind,
    KindStandard,
    RelayUrl,
    ReqTarget,
} from "@nostrdevkit/nostr-sdk-node";

async function fetchEvents() {
    const client = new Client();
    await client.addRelay(RelayUrl.parse("wss://relay.damus.io"));
    await client.connect();

    const filter = new Filter()
        .kind(Kind.fromStd(KindStandard.TextNote))
        .limit(20n);
    const events = await client.fetchEvents(ReqTarget.auto([filter]), 10_000);

    for (const event of events) {
        console.log(event.asJson());
    }

}
Web
import {
    Client,
    Filter,
    Kind,
    KindStandard,
    RelayUrl,
    ReqTarget,
    uniffiInitAsync,
} from "@nostrdevkit/nostr-sdk-web";

await uniffiInitAsync();

async function fetchEvents() {
    const client = new Client();
    await client.addRelay(RelayUrl.parse("wss://relay.damus.io"));
    await client.connect();

    const filter = new Filter()
        .kind(Kind.fromStd(KindStandard.TextNote))
        .limit(20n);
    const events = await client.fetchEvents(ReqTarget.auto([filter]), 10_000);

    for (const event of events) {
        console.log(event.asJson());
    }

}
React Native
import {
    Client,
    Filter,
    Kind,
    KindStandard,
    RelayUrl,
    ReqTarget,
} from "@nostrdevkit/nostr-sdk-react-native";

export async function fetchEvents() {
    const client = new Client();
    await client.addRelay(RelayUrl.parse("wss://relay.damus.io"));
    await client.connect();

    const filter = new Filter()
        .kind(Kind.fromStd(KindStandard.TextNote))
        .limit(20n);
    const events = await client.fetchEvents(ReqTarget.auto([filter]), 10_000);

    for (const event of events) {
        console.log(event.asJson());
    }

}
Kotlin
import java.time.Duration
import kotlinx.coroutines.runBlocking
import org.nostrdevkit.sdk.*

suspend fun fetchEvents() {
    val client = Client()
    client.addRelay(RelayUrl.parse("wss://relay.damus.io"))
    client.connect()

    val filter = Filter()
        .kind(Kind.fromStd(KindStandard.TEXT_NOTE))
        .limit(20u)
    val events = client.fetchEvents(
        ReqTarget.auto(listOf(filter)),
        timeout = Duration.ofSeconds(10),
    )

    for (event in events) {
        println(event.asJson())
    }

}
Swift
import Foundation
import NostrSDK

func fetchEvents() async throws {
    let client = Client()
    let relay = try RelayUrl.parse(url: "wss://relay.damus.io")
    _ = try await client.addRelay(url: relay)
    await client.connect()

    let filter = Filter()
        .kind(kind: Kind.fromStd(e: .textNote))
        .limit(limit: 20)
    let events = try await client.fetchEvents(
        target: ReqTarget.auto(filters: [filter]),
        timeout: 10.0
    )

    for event in events {
        print(try event.asJson())
    }

}
C#
using Nostr.Sdk;

public static class ReadExample
{

    public static async Task FetchEvents()
    {
        var client = new Client();
        await client.AddRelay(RelayUrl.Parse("wss://relay.damus.io"));
        await client.Connect();

        var filter = new Filter()
            .Kind(Kind.FromStd(KindStandard.TextNote))
            .Limit(20);
        var events = await client.FetchEvents(
            ReqTarget.Auto([filter]),
            timeout: TimeSpan.FromSeconds(10)
        );

        foreach (var @event in events)
        {
            Console.WriteLine(@event.AsJson());
        }

    }
}

The filter and timeout are the same as in the stream example. Only the consuming shape changes: this call waits for termination and returns a collection.

Bound the collection

The filter limit is applied by each relay, not once to the merged collection. Use a selective filter, a timeout, and a global event cap when a strict collection bound is required.

The SDK validates and deduplicates returned events by ID. Replaceable events and deletions still follow database and NIP semantics rather than simple ID deduplication.

Fetch errors that prevent target resolution or request creation fail the operation. Relay-specific behavior can still produce a valid but incomplete collection. If the feature needs to know which relay supplied or failed to supply each result, use the stream form, where relay provenance and per-item errors remain visible.

Use fetch when the complete, bounded collection is the desired output. Use stream when provenance or early processing must remain visible.

Both finite forms end on an exit policy. The next chapter covers a request intended to stay open: a live subscription.

Live subscriptions

A subscription remains active and delivers matching events until it is closed. Open the notification stream before subscribing so an event arriving immediately after the request cannot be missed by the consumer.

Use this shape when the application needs future events continuously. For a bounded read of stored events, use a finite event stream instead.

A subscription is pool state, not merely an iterator returned to one function. The client records it, sends it to eligible relays, restores it after reconnection where possible, and emits matching events through the shared notification stream. The returned subscription ID is how application code identifies that state later.

Avoid the startup race

Client notifications are observed from the moment a notification stream is created; they are not a replay log. The safe order is therefore:

  1. create the notification stream;
  2. build a bounded filter and request target;
  3. subscribe and retain the returned subscription ID;
  4. consume notifications and select events for that ID;
  5. unsubscribe during teardown.

Reversing the first two runtime operations can lose an event that arrives after the relay accepts the subscription but before the application starts observing notifications.

This order protects the local handoff between subscription setup and consumption. If a feature needs a complete initial view followed by updates, design an overlap:

  1. Start observing notifications.
  2. Subscribe with a since boundary that overlaps the intended initial history.
  3. Fetch or stream the bounded initial history.
  4. Merge both paths by event ID and order them using feature semantics.
  5. Continue with live notifications.

The overlap trades possible duplicates for avoiding a gap. Event IDs make duplicates straightforward to remove; recovering an event that was never requested is harder.

Rust
use nostr_sdk::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::default();
    client.add_relay("wss://relay.damus.io").await?;
    client.connect().await;

    let mut notifications = client.notifications();
    let filter = Filter::new().kind(Kind::TextNote).since(Timestamp::now());
    let subscription = client.subscribe(filter).await?;

    while let Some(notification) = notifications.next().await {
        if let ClientNotification::Event {
            relay_url,
            subscription_id,
            event,
        } = notification
        {
            if &subscription_id == subscription.id() {
                println!("Received {} from {relay_url}", event.id);
                break;
            }
        }
    }

    client.unsubscribe(subscription.id()).await?;
    Ok(())
}
Python
import asyncio

from nostr_sdk import (
    Client,
    ClientNotification,
    Filter,
    Kind,
    KindStandard,
    RelayUrl,
    ReqTarget,
    Timestamp,
)


async def main() -> None:
    client = Client()
    await client.add_relay(RelayUrl.parse("wss://relay.damus.io"))
    await client.connect()

    notifications = client.notifications()
    filter = (
        Filter()
        .kind(Kind.from_std(KindStandard.TEXT_NOTE))
        .since(Timestamp.now())
    )
    subscription = await client.subscribe(ReqTarget.auto([filter]))

    while notification := await notifications.next():
        if isinstance(notification, ClientNotification.NEW_EVENT):
            if notification.subscription_id == subscription.id:
                print(
                    f"Received {notification.event.id().to_bech32()} "
                    f"from {notification.relay_url}"
                )
                break

    await client.unsubscribe(subscription.id)


if __name__ == "__main__":
    asyncio.run(main())
JavaScript
Node.js
import {
    Client,
    ClientNotification,
    Filter,
    Kind,
    KindStandard,
    RelayUrl,
    ReqTarget,
    Timestamp,
} from "@nostrdevkit/nostr-sdk-node";

async function main() {
    const client = new Client();
    await client.addRelay(RelayUrl.parse("wss://relay.damus.io"));
    await client.connect();

    const notifications = client.notifications();
    const filter = new Filter()
        .kind(Kind.fromStd(KindStandard.TextNote))
        .since(Timestamp.now());
    const subscription = await client.subscribe(ReqTarget.auto([filter]));

    while (true) {
        const notification = await notifications.next();
        if (!notification) break;

        if (ClientNotification.NewEvent.instanceOf(notification)) {
            if (notification.inner.subscriptionId === subscription.id) {
                console.log(
                    `Received ${notification.inner.event.id().toBech32()} ` +
                    `from ${notification.inner.relayUrl}`,
                );
                break;
            }
        }
    }

    await client.unsubscribe(subscription.id);
}

await main();
Web
import {
    Client,
    ClientNotification,
    Filter,
    Kind,
    KindStandard,
    RelayUrl,
    ReqTarget,
    Timestamp,
    uniffiInitAsync,
} from "@nostrdevkit/nostr-sdk-web";

async function main() {
    await uniffiInitAsync();

    const client = new Client();
    await client.addRelay(RelayUrl.parse("wss://relay.damus.io"));
    await client.connect();

    const notifications = client.notifications();
    const filter = new Filter()
        .kind(Kind.fromStd(KindStandard.TextNote))
        .since(Timestamp.now());
    const subscription = await client.subscribe(ReqTarget.auto([filter]));

    while (true) {
        const notification = await notifications.next();
        if (!notification) break;

        if (ClientNotification.NewEvent.instanceOf(notification)) {
            if (notification.inner.subscriptionId === subscription.id) {
                console.log(
                    `Received ${notification.inner.event.id().toBech32()} ` +
                    `from ${notification.inner.relayUrl}`,
                );
                break;
            }
        }
    }

    await client.unsubscribe(subscription.id);
}

await main();
React Native
import {
    Client,
    ClientNotification,
    Filter,
    Kind,
    KindStandard,
    RelayUrl,
    ReqTarget,
    Timestamp,
} from "@nostrdevkit/nostr-sdk-react-native";

export async function receiveLiveEvent() {
    const client = new Client();
    await client.addRelay(RelayUrl.parse("wss://relay.damus.io"));
    await client.connect();

    const notifications = client.notifications();
    const filter = new Filter()
        .kind(Kind.fromStd(KindStandard.TextNote))
        .since(Timestamp.now());
    const subscription = await client.subscribe(ReqTarget.auto([filter]));

    while (true) {
        const notification = await notifications.next();
        if (!notification) break;

        if (ClientNotification.NewEvent.instanceOf(notification)) {
            if (notification.inner.subscriptionId === subscription.id) {
                console.log(
                    `Received ${notification.inner.event.id().toBech32()} ` +
                    `from ${notification.inner.relayUrl}`,
                );
                break;
            }
        }
    }

    await client.unsubscribe(subscription.id);
}
Kotlin
package rust.nostr.snippets

import kotlinx.coroutines.runBlocking
import org.nostrdevkit.sdk.*

suspend fun receiveLiveEvent() {
    val client = Client()
    client.addRelay(RelayUrl.parse("wss://relay.damus.io"))
    client.connect()

    val notifications = client.notifications()
    val filter = Filter()
        .kind(Kind.fromStd(KindStandard.TEXT_NOTE))
        .since(Timestamp.now())
    val subscription = client.subscribe(ReqTarget.auto(listOf(filter)))

    while (true) {
        when (val notification = notifications.next() ?: break) {
            is ClientNotification.NewEvent -> {
                if (notification.subscriptionId == subscription.id) {
                    println("Received ${notification.event.id().toBech32()} from ${notification.relayUrl}")
                    break
                }
            }
            else -> Unit
        }
    }

    client.unsubscribe(subscription.id)
}

fun main() = runBlocking {
    receiveLiveEvent()
}
Swift
import Foundation
import NostrSDK

func receiveLiveEvent() async throws {
    let client = Client()
    let relay = try RelayUrl.parse(url: "wss://relay.damus.io")
    _ = try await client.addRelay(url: relay)
    await client.connect()

    let notifications = client.notifications()
    let filter = Filter()
        .kind(kind: Kind.fromStd(e: .textNote))
        .since(timestamp: Timestamp.now())
    let subscription = try await client.subscribe(
        target: ReqTarget.auto(filters: [filter])
    )

    while let notification = await notifications.next() {
        if case let .newEvent(relayUrl, subscriptionId, event) = notification,
           subscriptionId == subscription.id {
            print("Received \(try event.id().toBech32()) from \(relayUrl)")
            break
        }
    }

    _ = try await client.unsubscribe(subscriptionId: subscription.id)
}
C#
namespace Snippets;

using Nostr.Sdk;

public static class LiveExample
{
    public static async Task ReceiveLiveEvent()
    {
        var client = new Client();
        await client.AddRelay(RelayUrl.Parse("wss://relay.damus.io"));
        await client.Connect();

        ClientNotificationStream notifications = client.Notifications();
        Filter filter = new Filter()
            .Kind(Kind.FromStd(KindStandard.TextNote))
            .Since(Timestamp.Now());
        SubscribeOutput subscription = await client.Subscribe(ReqTarget.Auto([filter]));

        while (await notifications.Next() is { } notification)
        {
            if (notification is ClientNotification.NewEvent item &&
                item.SubscriptionId == subscription.Id)
            {
                Console.WriteLine($"Received {item.Event.Id().ToBech32()} from {item.RelayUrl}");
                break;
            }
        }

        await client.Unsubscribe(subscription.Id);
    }
}

The example stops after the first matching event only to remain finite.

Route notifications by subscription ID

The notification stream also carries relay status and other client-wide activity. For event notifications, compare the subscription ID with the ID returned by subscribe.

Subscription setup returns successful and failed relay outcomes. The pool restores active subscriptions after reconnection where possible, but this does not recover events missed while a relay was unavailable. Use a bounded catch-up read when that gap matters.

Close the subscription

Unsubscribing closes the matching relay-side state and prevents it from being restored after reconnection. If the client itself is dropped, its remaining subscriptions and connections stop with it.

The next chapter uses Negentropy sync to reconcile a durable local view after startup or a connection gap.

Negentropy sync

Finite reads ask relays to send matching events. Sync instead compares the event IDs in the local database with those on each target relay using NIP-77 Negentropy, then transfers only the missing events allowed by the selected direction.

Sync requires a database with a meaningful local event set. Running it against a fresh in-memory client only compares the relay with temporary process state.

Use sync for startup catch-up, reconnect recovery, backup, or reconciliation. Use streaming when events should be processed immediately without maintaining a local replica. The target relay must support NIP-77.

Reconcile a bounded set

The example downloads up to 20 matching text notes into the database.

Rust
use nostr_lmdb::NostrLmdb;
use nostr_sdk::prelude::*;

async fn sync_events() -> Result<(), Box<dyn std::error::Error>> {
    let database = NostrLmdb::open("./data/nostr").await?;
    let client = ClientBuilder::default().database(database).build();

    client.add_relay("wss://relay.damus.io").await?;
    client.connect().await;

    let filter = Filter::new().kind(Kind::TextNote).limit(20);
    let opts = SyncOptions::default().direction(SyncDirection::Down);
    let output = client.sync(filter).opts(opts).await?;
    println!("Sync result: {output:?}");

    Ok(())
}
Python
from nostr_sdk import (
    ClientBuilder,
    Filter,
    Kind,
    KindStandard,
    NostrLmdb,
    RelayUrl,
    SyncDirection,
    SyncOptions,
)

async def sync_events() -> None:
    database = await NostrLmdb.open("./data/nostr")
    client = ClientBuilder().database(database).build()

    await client.add_relay(RelayUrl.parse("wss://relay.damus.io"))
    await client.connect()

    filter = (
        Filter()
        .kind(Kind.from_std(KindStandard.TEXT_NOTE))
        .limit(20)
    )
    opts = SyncOptions().direction(SyncDirection.DOWN)
    output = await client.sync(filter, opts=opts)
    print(output)

JavaScript
Node.js
import {
    ClientBuilder,
    Filter,
    Kind,
    KindStandard,
    NostrLmdb,
    RelayUrl,
    SyncDirection,
    SyncOptions,
} from "@nostrdevkit/nostr-sdk-node";

async function syncEvents() {
    const database = await NostrLmdb.open("./data/nostr");
    const client = new ClientBuilder().database(database).build();

    await client.addRelay(
        RelayUrl.parse("wss://relay.damus.io"),
        undefined,
        false,
        undefined,
    );
    await client.connect(undefined);

    const filter = new Filter()
        .kind(Kind.fromStd(KindStandard.TextNote))
        .limit(20n);
    const opts = new SyncOptions().direction(SyncDirection.Down);
    const output = await client.sync(filter, undefined, opts);
    console.log(output);
}
Web

The Web package does not bundle a persistent database. Implement NostrDatabase over browser storage and pass it to ClientBuilder.database before using sync. An in-memory sync is intentionally not shown because it does not provide a durable local replica.

React Native
import {
    ClientBuilder,
    Filter,
    Kind,
    KindStandard,
    NostrLmdb,
    RelayUrl,
    SyncDirection,
    SyncOptions,
} from "@nostrdevkit/nostr-sdk-react-native";

export async function syncEvents(databasePath: string) {
    const database = await NostrLmdb.open(databasePath);
    const client = new ClientBuilder().database(database).build();

    await client.addRelay(
        RelayUrl.parse("wss://relay.damus.io"),
        undefined,
        false,
        undefined,
    );
    await client.connect(undefined);

    const filter = new Filter()
        .kind(Kind.fromStd(KindStandard.TextNote))
        .limit(20n);
    const opts = new SyncOptions().direction(SyncDirection.Down);
    const output = await client.sync(filter, undefined, opts);
    console.log(output);
}
Kotlin
import org.nostrdevkit.sdk.*

suspend fun syncEvents() {
    val database = NostrLmdb.open("./data/nostr")
    val client = ClientBuilder().database(database).build()

    client.addRelay(RelayUrl.parse("wss://relay.damus.io"))
    client.connect()

    val filter = Filter()
        .kind(Kind.fromStd(KindStandard.TEXT_NOTE))
        .limit(20u)
    val opts = SyncOptions().direction(SyncDirection.DOWN)
    val output = client.sync(filter, opts = opts)
    println(output)
}
Swift
import Foundation
import NostrSDK

func syncEvents(path: String) async throws {
    let database = try await NostrLmdb.open(path: path)
    let client = ClientBuilder().database(database: database).build()

    _ = try await client.addRelay(
        url: try RelayUrl.parse(url: "wss://relay.damus.io")
    )
    await client.connect()

    let filter = Filter()
        .kind(kind: Kind.fromStd(e: .textNote))
        .limit(limit: 20)
    let opts = SyncOptions().direction(direction: .down)
    let output = try await client.sync(filter: filter, opts: opts)
    print(output)
}

Pass a URL-derived path inside Application Support or another application-owned directory.

C#
using Nostr.Sdk;

public static class DatabaseExample
{

    public static async Task SyncEvents(string path)
    {
        var database = await NostrLmdb.Open(path);
        var client = new ClientBuilder().Database(database).Build();

        await client.AddRelay(RelayUrl.Parse("wss://relay.damus.io"));
        await client.Connect();

        var filter = new Filter()
            .Kind(Kind.FromStd(KindStandard.TextNote))
            .Limit(20);
        var opts = new SyncOptions().Direction(SyncDirection.Down);
        var output = await client.Sync(filter, opts: opts);
        Console.WriteLine(output);
    }
}

The example selects downward sync so it cannot publish local events. Adjust the filter’s authors, kinds, tags, and limit to match the data set the application needs locally.

After sync, inspect the returned relay outcome before treating the local view as reconciled, then query the database. Add a live subscription when new events must continue arriving after sync ends. The current direction, targeting, timeout, progress, and result APIs belong in the references collected in Where to go next, rather than being duplicated here.

Run an embedded relay

nostr-sdk can also host a relay inside an application process. This is useful for deterministic integration tests, device-local services, and applications that need a relay with their own storage and policy. Rust enables it with the local-relay feature; native packages include it where the platform can listen on a socket.

Start a persistent relay

The example uses LMDB so published events remain available after a restart. For a test fixture, replace it with an isolated in-memory database owned by the test.

Rust

Enable the relay API and add the database used by the example:

[dependencies]
nostr-lmdb = "0.45"
nostr-sdk = { version = "0.45", features = ["local-relay"] }
use std::time::Duration;

use nostr_lmdb::NostrLmdb;
use nostr_sdk::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let database = NostrLmdb::open("./data/relay").await?;
    let relay = LocalRelay::builder().database(database).port(7777).build();

    // Start the relay.
    relay.run().await?;

    println!("Relay listening on {}", relay.url().await);

    // Keep the process running
    loop {
        tokio::time::sleep(Duration::from_secs(60)).await;
    }
}
Python
import asyncio

from nostr_sdk import LocalRelayBuilder, NostrLmdb


async def main() -> None:
    database = await NostrLmdb.open("./data/relay")
    relay = LocalRelayBuilder().database(database).port(7777).build()

    await relay.run()
    print(f"Relay URL: {await relay.url()}")

if __name__ == "__main__":
    asyncio.run(main())
JavaScript
Node.js
import { LocalRelayBuilder, NostrLmdb } from "@nostrdevkit/nostr-sdk-node";

const database = await NostrLmdb.open("./data/relay");
const relay = new LocalRelayBuilder()
    .database(database)
    .port(7777)
    .build();

await relay.run();
console.log("Relay URL:", (await relay.url()).toString());
Web

Browsers cannot listen on a TCP port and cannot host an embedded relay. Connect the Web client to a relay running in another process or service.

React Native
import {
    LocalRelayBuilder,
    NostrLmdb,
} from "@nostrdevkit/nostr-sdk-react-native";

export async function runEmbeddedRelay(databasePath: string) {
    const database = await NostrLmdb.open(databasePath);
    const relay = new LocalRelayBuilder()
        .database(database)
        .port(7777)
        .build();

    await relay.run();
    console.log("Relay URL:", (await relay.url()).toString());

}

Pass an absolute path inside the application’s data directory as databasePath.

Kotlin
package rust.nostr.snippets

import kotlinx.coroutines.runBlocking
import org.nostrdevkit.sdk.LocalRelayBuilder
import org.nostrdevkit.sdk.NostrLmdb

suspend fun runEmbeddedRelay() {
    val database = NostrLmdb.open("./data/relay")
    val relay = LocalRelayBuilder()
        .database(database)
        .port(7777u)
        .build()

    relay.run()
    println("Relay URL: ${relay.url()}")

}

fun relayMain() = runBlocking {
    runEmbeddedRelay()
}

On Android, replace the example path with one inside the application files directory.

Swift
import Foundation
import NostrSDK

func runEmbeddedRelay(path: String) async throws {
    let database = try await NostrLmdb.open(path: path)
    let relay = LocalRelayBuilder()
        .database(database: database)
        .port(port: 7777)
        .build()

    try await relay.run()
    print("Relay URL: \(await relay.url())")
}

Pass a URL-derived path inside Application Support or another application-owned directory.

C#
namespace Snippets;

using Nostr.Sdk;

public static class RelayExample
{
    public static async Task RunEmbeddedRelay(string path)
    {
        var database = await NostrLmdb.Open(path);
        var relay = new LocalRelayBuilder()
            .Database(database)
            .Port(7777)
            .Build();

        await relay.Run();
        Console.WriteLine($"Relay URL: {await relay.Url()}");
    }
}

Keep the handle in the component that owns the service. Dropping its last owner stops the relay.

LocalRelayBuilder also exposes listener, limit, NIP-42, and read/write policy options. Continue with Where to go next when the relay needs configuration beyond this minimal setup.

Where to go next

This book covers the common path through Nostr Dev Kit. Continue with the appropriate reference for APIs and protocol details that are outside that path.

Rust API reference

Native packages

The package pages provide releases and platform metadata:

Protocol reference

Use the NIP repository for wire semantics and interoperability requirements. The API shows how to construct a value; the relevant NIP defines how other clients and relays interpret it.