# Nostr Public Notes Monitor - Verified Relay Watchlists (`maydit/nostr-public-notes-monitor`) Actor

Export signature-verified public Nostr notes from two relays. Filter by author, hashtag or keyword, deduplicate IDs, retain provenance and optionally skip previously saved notes. No key needed.

- **URL**: https://apify.com/maydit/nostr-public-notes-monitor.md
- **Developed by:** [Brandt May](https://apify.com/maydit) (community)
- **Categories:** Social media, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.20 / 1,000 verified public note observations

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

![Collect signed public notes — illustrative workflow](https://api.apify.com/v2/key-value-stores/YGxD9JRpduom8Q5Zd/records/nostr-public-notes-monitor-886b2c8e2ca6.png?signature=yoRqHZS45V1ElifcTx8o)

[Open this Actor in Console](https://console.apify.com/actors/gc4K7YPDwmEStApSI/input) · [Try a small run](#try-a-small-run) · [Cost](#cost)

**Mayd-It · Verified public notes**

Collect a bounded watchlist of public Nostr notes with signed text and relay provenance. Filter explicit author keys or hashtag tags, deduplicate observations across two relays, and optionally skip notes already saved.

**Source scope:** Public kind 1 notes from relay.damus.io and nos.lol. Signatures are verified; verification establishes the signing key, not a person’s identity or a post’s truth.

### What you get

- Distinct note IDs with original signed content and verified signatures.
- Author, tag, time and literal keyword filters with observed relay provenance.
- Saved-history deduplication for recurring collection without a WebSocket client.

### Try a small run

Copy this exact input into the Actor’s JSON input editor, then run it.

```json
{
  "relays": [
    "wss://relay.damus.io",
    "wss://nos.lol"
  ],
  "perRelayLimit": 10,
  "maxResults": 20,
  "hashtags": [
    "nostr"
  ]
}
```

This example requests a ten-event window per relay and saves at most 20 distinct matching notes tagged `nostr`. No account or API key is required. `{}` uses the same tag and a seven-day lookback; set your own filters for a watchlist, or explicitly pass `"hashtags": []` for an unfiltered recent window. Open the dataset for verified rows and `SUMMARY` for accepted, rejected and incomplete-source counts.

### Understand your run

Open **Run report** in the run’s Output tab for saved-record counts, reported source issues and limits, and suggested next steps. Use **CSV export** to take the saved results into a spreadsheet. The existing JSON results and detailed machine-readable summary remain available.

The report reflects this run’s recorded coverage. A successful run or a saved result does not establish that every possible source record was collected.

### Cost

**FREE / BRONZE: $0.002 per saved observation ($2 per 1,000).** SILVER: $1.60 per 1,000. GOLD / PLATINUM / DIAMOND: $1.20 per 1,000.

The Actor Start event is **$0.00005 per charged start unit**, with one unit per GB of Actor memory and a minimum of one. The Pricing tab shows the rate for your Apify tier. New, changed and ordinarily saved unchanged observations use the same result rate. Skipped unchanged rows, rejected events, duplicates and error diagnostics are not paid result rows. Use `onlyChanges` for repeated monitoring after establishing history.

### A real result

Selected fields from a recorded run on **10 October 2026**. This is an example of the output shape; source content and counters can change.

```json
{
  "id": "19befb884f45b68a482cde7cc5a47c4049fba50e0ff97001fa303c9bc6222d60",
  "kind": 1,
  "createdAt": "2026-10-10T23:14:02.000Z",
  "hashtags": [
    "bitcoin",
    "moscowtime",
    "nostr"
  ],
  "sourceRelays": [
    "wss://nos.lol"
  ],
  "signatureVerified": true
}
```

This retained sample came from the one-relay community example below. The complete row also includes signed content, its author public key and signature. Relay provenance records where the Actor observed the note.

### More examples

#### Public Nostr Bitcoin Hashtag Notes

Request notes with the exact, case-sensitive `bitcoin` tag.

```json
{
  "hashtags": [
    "bitcoin"
  ],
  "perRelayLimit": 10,
  "maxResults": 20
}
```

#### Nostr Community Tag on nos.lol

Use just nos.lol for the exact `nostr` tag, with at most ten saved notes.

```json
{
  "relays": [
    "wss://nos.lol"
  ],
  "hashtags": [
    "nostr"
  ],
  "perRelayLimit": 10,
  "maxResults": 10
}
```

***

This is an experimental market with limited observed Apify demand; no audience-growth or revenue promise is implied. Every event ID is recomputed and its Schnorr signature is checked before a paid row is saved.

### Input and coverage

| Input | Behavior |
| --- | --- |
| `relays` | One or both of `wss://relay.damus.io` and `wss://nos.lol`; both by default. Other hosts, credentials, paths and custom ports are rejected. |
| `authors` | Up to 20 exact hexadecimal public keys. No username or `npub` resolution. Multiple authors use OR. |
| `hashtags` | Up to 10 exact, case-sensitive `t` tag values, with an optional leading `#`. Multiple tags use OR. With no author, hashtag or keyword at all, `nostr` is sampled by default. |
| `keywords` | Up to 10 case-insensitive substrings matched with OR **within the retrieved window**. This is not full-text search across a relay. |
| `since`, `until` | Inclusive ISO8601 timestamps with timezone. Omitted end freezes at run start; omitted start is `lookbackHours` earlier. Maximum span 366 days. |
| `lookbackHours` | Default 168 (seven days), used when `since` is omitted. |
| `perRelayLimit` | Default 10, maximum 100 stored events per relay. Invalid, duplicate or filtered events can reduce saved results. |
| `maxResults` | Default 20 confirmed saved notes, maximum 1,000. Two supported relays can provide at most 200 candidates per run. |
| `relayTimeoutSeconds` | Default 12 seconds per relay, including DNS and connection; maximum 30. |
| `comparePrevious`, `onlyChanges`, `snapshotName` | Optional confirmed-output history described below. |
| `maxRunSeconds` | Default 240 seconds, 30–3,600 allowed, further bounded by the platform timeout minus 25 seconds. |

Author, hashtag, keyword and time groups combine with AND. All filters are checked locally even when sent to a relay. Notes are sorted newest first, then by event ID for ties. Public text can include spam, empty text, links and arbitrary content. Media and linked pages are not fetched, and keyword matching does not infer sentiment or identity.

The Actor waits for **EOSE**, the relay's acknowledgement that its stored-event response has ended. Missing EOSE, refusals and source errors are recorded as partial or failed coverage. Unsupported, oversized, malformed or unverifiable events are always excluded from output and billing. A completed response with usable verified notes may succeed despite these exclusions, with a warning and categorized rejection counts. If every received event from a source is rejected, that source is marked unusable. Rejected content is never retained; diagnostics contain only the event ID when validly shaped and a reason code.

EOSE completes only the requested bounded response, including processing and rejecting unsupported events. It does not establish all matching history, all public Nostr activity, acceptance of every received event, or the absence of deleted notes. The Actor never publishes events, signs in, authenticates to paid relays, reads private messages, or enriches profiles. Public DNS is checked and pinned for TLS connections; redirects are not followed.

A temporary HTTP 502, 503 or 504 handshake response gets at most one retry after a 500 ms pause, within the original relay time allowance. The attempt outcomes remain in `SUMMARY.sourceCoverage`. Authentication, access restrictions, redirects, subscription refusals and protocol errors are not retried.

Each message is capped at 128 KiB, each relay response at 4 MiB, and each content field at 64,000 UTF-8 bytes. Up to 500 tags are accepted per event. Oversized or unverifiable notes are not billed. These limits are deliberately smaller than some relay limits and are reported in `SUMMARY` if encountered.

### Results

One dataset row represents one distinct verified note in this run:

| Field | Meaning |
| --- | --- |
| `id`, `pubkey`, `kind` | Signed event ID, author public key and kind 1. |
| `createdAt`, `createdAtUnix` | Publisher's signed timestamp in UTC and Unix seconds; separate from retrieval time. |
| `content`, `tags`, `hashtags` | Original signed text and ordered tags; extracted `t` tag values. |
| `signature`, `signatureVerified` | Source signature and successful verification flag. |
| `sourceRelays` | Relays that returned this verified ID during this run. Absence from this list is not proof that another relay lacks it. |
| `rootEventId`, `replyToEventId` | Unambiguous explicit NIP-10 `root`/`reply` markers, otherwise null. Legacy unmarked references are preserved without guessing. |
| `referencedEventIds`, `threadReferenceMode` | Event references and how they were marked. Referenced posts are not fetched. |
| `links` | At most 100 deduplicated HTTP(S) text links; not visited or safety-certified. |
| `nostrUri`, `url` | Standard `nostr:note...` URI and a generated public njump.me viewer link. njump.me is not the fetched data source. |
| `observedAt`, `contentHash` | Retrieval observation time and SHA256 of the stable signed snapshot. |
| `comparisonStatus`, `changedFields`, `previous`, `previousObservedAt` | Optional comparison evidence; `not_requested` without history, otherwise `new`, `changed` or `unchanged`. |

`SUMMARY` includes `rowsSaved`, `rowsObserved` (distinct matching candidates considered for output), `verifiedMatchingNotes`, `unchangedSkipped`, `sourcesSucceeded`, `sourceCoverage`, `invalidEvents`, `rejectionsByReason`, `filtered`, `failedSources`, `windowComplete`, limits and status. `invalidEvents` counts every validation exclusion, including validly signed events exceeding supported size limits; it is not a count of forged signatures. Each source includes reason counts and at most 20 `{id, reason}` rejection samples. `windowComplete` describes transport and verification processing, not acceptance of all received events. Source counters are not audience or engagement metrics. A healthy empty query or unchanged repeat warns and succeeds. A zero-output operational failure fails the run. A zero-row spending-limit stop is a normal budget outcome.

### Saved history

Set `comparePrevious: true` or `onlyChanges: true`, and choose a `snapshotName`. The **first comparison run warns and returns matching notes as new**. History is stored in the named `nostr-public-notes-monitor-history` key-value store and separated by relay, filter and time-window configuration.

Only confirmed saved rows advance their own history. Positive observations from a partial relay response may be saved and remembered, with the run explicitly marked partial; this never establishes completeness or deletions. Unchanged repeats are skipped with `onlyChanges`. Since signed kind 1 event content is immutable, the same valid ID normally remains unchanged. Relay provenance changes do not create false content changes. No automatic deletions are emitted.

For a repeatable historical comparison, use the same fixed `since`/`until`, filters and `perRelayLimit`, and make `maxResults` large enough for the whole bounded window. Otherwise later runs may find previously unsaved notes. Relay retention and late-arriving events can also change the observed window.

Use **one writer per exact history scope**. The Actor checks the stored value again before replacing it and refuses an observed conflicting change; the key-value store offers no atomic compare-and-swap, so this is not a distributed lock. Malformed history is preserved with a diagnostic; use a new `snapshotName` to begin a fresh scope. At 20,000 remembered IDs, choose a new name or narrower watchlist. Named storage and data retention are under your Apify account's control.

### Troubleshooting and support

| If you see… | Check this |
| --- | --- |
| Fewer notes are saved than requested | Limits bound candidates, not guaranteed matches. Duplicates, filters, unsupported sizes and failed verification can reduce output; inspect categorized rejections in `SUMMARY`. |
| The relay did not send EOSE | The stored-event response did not complete. Treat coverage as partial even when usable verified notes were saved. |
| A repeat still returns new IDs | Use identical fixed `since`/`until`, filters and per-relay limits, with room to save the full bounded window. Late arrivals and relay retention can still change that window. |

For help, open an Actor issue or contact **bmay@mayd-it.com**. Include the run ID, selected sources and the relevant `SUMMARY` message. Do not send credentials. This Actor is independent of the platform.

Content remains subject to its creators’ rights and applicable law. Use public data for a legitimate purpose, honor deletion requests and avoid attempting to identify anonymous authors. Storage charges follow your Apify plan.

Protocol references: [NIP-01 events and subscriptions](https://github.com/nostr-protocol/nips/blob/master/01.md), [NIP-10 marked reply references](https://github.com/nostr-protocol/nips/blob/master/10.md), [NIP-11 relay information](https://github.com/nostr-protocol/nips/blob/master/11.md), [nostr-tools](https://github.com/nbd-wtf/nostr-tools). Relay operation, retention and access policies may change; requests that require authentication or are refused are not bypassed.

# Actor input Schema

## `relays` (type: `array`):

Select one or both supported anonymous public relays. Only wss://relay.damus.io and wss://nos.lol are accepted; no credentials, alternate hosts or custom paths. Omitted input uses both.

## `authors` (type: `array`):

Optional exact 64-character hexadecimal public keys, at most 20. Multiple keys use OR. Does not resolve usernames or npub values, enrich profiles, or identify a real person.

## `hashtags` (type: `array`):

Optional at most 10 exact Nostr t-tag values; leading # is removed. Tag matching is case sensitive, as in the relay protocol. Multiple values use OR. Author, tag, time and keyword groups combine with AND. When this field is omitted and no authors or keywords are given, the run samples the nostr tag (logged in the run) instead of an unfiltered window; pass an empty list explicitly to read the unfiltered window.

## `keywords` (type: `array`):

Optional at most 10 case-insensitive substrings, matched with OR locally after retrieving the bounded relay window. This is not a relay-wide full-text search; increase perRelayLimit if the window is too narrow.

## `since` (type: `string`):

Optional inclusive ISO8601 timestamp with timezone, for example 2026-10-01T00:00:00Z. Omitted start is lookbackHours before until. Windows may span at most 366 days.

## `until` (type: `string`):

Optional inclusive ISO8601 timestamp with timezone. Omitted end freezes at the current run start. Use fixed since/until timestamps for repeatable historical windows. Future endpoints over five minutes ahead are rejected.

## `lookbackHours` (type: `integer`):

Used only when since is omitted: look back this many hours from until. Default 168 means seven days.

## `perRelayLimit` (type: `integer`):

Request this many stored events per relay, from 1 to 100. Duplicate, invalid and locally filtered events can reduce saved results. The relay may return fewer, and this does not promise a full historical export.

## `maxResults` (type: `integer`):

Maximum distinct verified notes saved across relays. Default 20 fits both ten-event windows. Unchanged notes skipped by onlyChanges do not consume this cap. At most 200 candidates can be retrieved with two relays.

## `comparePrevious` (type: `boolean`):

Use a named key-value store to compare signed note snapshots for this exact watchlist. The first run warns and returns matching notes as new, establishing history only after confirmed dataset writes.

## `onlyChanges` (type: `boolean`):

Enables comparison and skips unchanged signed snapshots. A valid empty repeat warns and succeeds. Signed kind1 content is immutable, so repeated IDs normally remain unchanged. No deletions are inferred.

## `snapshotName` (type: `string`):

1-80 letters, numbers, underscores or hyphens. Separate logical workflows with different names. History also includes relay/filter/window settings. Run only one writer at a time per exact scope; concurrent changes are detected before commit but storage is not transactional.

## `relayTimeoutSeconds` (type: `integer`):

Total DNS, connection and bounded-response allowance per relay, from 3 to 30 seconds, further limited by the run deadline. Missing EOSE is reported as partial even when some notes were received.

## `maxRunSeconds` (type: `integer`):

Own wall-clock budget, default 240 seconds, further limited by the platform timeout minus a 25-second reserve. Source calls, verification loops and output respect the remaining budget.

## Actor input object example

```json
{
  "relays": [
    "wss://relay.damus.io",
    "wss://nos.lol"
  ],
  "authors": [],
  "hashtags": [
    "nostr"
  ],
  "keywords": [],
  "lookbackHours": 168,
  "perRelayLimit": 10,
  "maxResults": 20,
  "comparePrevious": false,
  "onlyChanges": false,
  "snapshotName": "default",
  "relayTimeoutSeconds": 12,
  "maxRunSeconds": 240
}
```

# Actor output Schema

## `dataset` (type: `string`):

No description

## `runReport` (type: `string`):

Readable summary of saved records, reported source coverage, limits and next steps. Available when this run saves its SUMMARY.

## `csvExport` (type: `string`):

Download saved dataset records as a CSV file for spreadsheets and other tools.

## `summary` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "relays": [
        "wss://relay.damus.io",
        "wss://nos.lol"
    ],
    "authors": [],
    "hashtags": [
        "nostr"
    ],
    "keywords": []
};

// Run the Actor and wait for it to finish
const run = await client.actor("maydit/nostr-public-notes-monitor").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "relays": [
        "wss://relay.damus.io",
        "wss://nos.lol",
    ],
    "authors": [],
    "hashtags": ["nostr"],
    "keywords": [],
}

# Run the Actor and wait for it to finish
run = client.actor("maydit/nostr-public-notes-monitor").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "relays": [
    "wss://relay.damus.io",
    "wss://nos.lol"
  ],
  "authors": [],
  "hashtags": [
    "nostr"
  ],
  "keywords": []
}' |
apify call maydit/nostr-public-notes-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,maydit/nostr-public-notes-monitor"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/gc4K7YPDwmEStApSI/builds/JZxWQQN9NgQJw6el9/openapi.json
