Readstr
A Google Reader-style aggregator for RSS feeds and Nostr long-form content.
Readstr is a full-stack web application built with Next.js that provides a unified reading experience for RSS/Atom feeds, Nostr long-form content (NIP-23), and video feeds from YouTube and Rumble. It features a clean, three-panel interface reminiscent of Google Reader, with keyless Nostr authentication (NIP-07) and cross-device sync of subscriptions and read status over Nostr relays — the app never handles a private key.
Where to start
- Mobile & PWA — responsive design and installable PWA support.
- Feed Discovery — how Readstr finds feeds from any URL.
- Tagging System — organizing feeds with tags and categories.
- Subscription Sync — cross-device sync via Nostr events (kind 30404/30405), including bidirectional sync, category sync, and deletion/read-status tracking.
- Guide API — REST API exposing the curated feed directory.
- CLI Development Guide — building the Go/Charm TUI client.
- Flash Integration — Bitcoin Lightning subscription payments via PayWithFlash.
Project links
- Source: https://github.com/privkeyio/readstr
- Live app: https://readstr.privkey.io
- License: MIT
Mobile & PWA Support
Mobile Responsive Design
The app is fully responsive and optimized for mobile devices:
Features
- Hamburger Menu: On mobile, tap the menu icon to access feeds and tags
- Adaptive Layout: 3-panel layout adapts to single-column on mobile
- Touch-Friendly: All buttons and controls are optimized for touch input
- Back Navigation: Swipe or tap back button to navigate between article list and content
- Mobile Header: Fixed header with quick access to add feeds
Breakpoints
- Mobile: < 768px (single column, hamburger menu)
- Tablet/Desktop: >= 768px (multi-column layout)
Progressive Web App (PWA)
Install Readstr as a standalone app on your device!
Installation
On Mobile (iOS/Android)
- Open the app in your mobile browser
- Tap the “Share” or “Menu” button
- Select “Add to Home Screen”
- The app will install with an icon on your home screen
On Desktop (Chrome/Edge)
- Look for the install icon in the address bar
- Click “Install Readstr”
- The app will open in its own window
PWA Features
- Offline Support: Service worker caches static assets for offline access
- App-like Experience: Runs in standalone mode without browser UI
- Fast Loading: Cached resources load instantly
- Install Prompt: Browser prompts users to install the app
- Manifest: Full PWA manifest with app name, icons, and theme colors
Technical Details
Service Worker
- Automatically registered via
@ducanh2912/next-pwa - Caches static assets (JS, CSS, images, fonts)
- Network-first strategy for API calls
- Cache-first for static resources
Build
To build with PWA support:
npm run build -- --webpack
Note: Next.js 16 uses Turbopack by default, but @ducanh2912/next-pwa requires webpack. The --webpack flag ensures proper PWA generation.
Icons
- SVG icon at
/public/icon.svg - PNG icons: 192x192 and 512x512
- Apple touch icon support
- Maskable icons for Android
Development
When running in development mode (npm run dev), the service worker is disabled to prevent caching issues.
Browser Support
- iOS Safari: 11.3+
- Android Chrome: Full support
- Desktop Chrome/Edge: Full support
- Firefox: Partial support (no install prompt)
RSS Feed Discovery Feature
Overview
Implemented intelligent RSS/Atom feed discovery that automatically finds feeds when users enter URLs that aren’t direct feed URLs.
How It Works
3-Step Discovery Process
-
Direct Feed Check (
checkIfFeed)- Attempts to fetch the URL directly
- Validates Content-Type header (application/rss+xml, application/atom+xml, etc.)
- Returns the URL if it’s already a valid feed
-
HTML Parsing (
findFeedInHTML)- Fetches the page as HTML
- Uses cheerio to parse
<link>tags withrel="alternate" - Looks for
type="application/rss+xml"ortype="application/atom+xml" - Returns the first valid feed URL found
-
Common Locations (
tryCommonFeedLocations)- Tests standard feed paths relative to the domain:
/feed/feed.xml/rss/rss.xml/atom.xml/index.xml
- Returns the first valid feed found
- Tests standard feed paths relative to the domain:
User Experience
What Users Can Enter
- Direct feed URLs:
https://example.com/feed.xml✅ - Homepage URLs:
https://example.com✅ (will find/feedautomatically) - Blog URLs:
https://example.com/blog✅ (will check HTML for feed links)
Error Messages
Provides descriptive errors when feeds can’t be found:
- “Could not find RSS/Atom feed at this URL. Please check the URL and try again.”
- Displays in red error box in the Add Feed modal
- Clears when user modifies the URL input
Implementation Files
/src/lib/feed-discovery.ts
- Core discovery logic
- Three helper functions + main
discoverFeed()orchestrator - Uses cheerio for HTML parsing
- Implements 10-second timeout for network requests
/src/server/api/routers/feed.ts
- Integrated into
subscribeFeedmutation - Calls
discoverFeed()for RSS feeds before saving - Preserves discovered feed title if available
- Throws descriptive TRPCError on failure
/src/components/add-feed-modal.tsx
- Displays error messages from backend
- Clears errors when user types
- Shows helpful hint: “Enter a feed URL or website homepage - we’ll find the feed for you”
/src/components/feed-reader.tsx
- Manages error state from tRPC mutation
- Passes errors to modal via props
- Clears errors on modal close
Technical Details
Dependencies
- cheerio: HTML parsing to find feed links
- node-fetch: Already available in Next.js server environment
Timeout Handling
All HTTP requests have a 10-second timeout using AbortController:
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10000)
Feed Type Detection
Checks Content-Type headers for:
application/rss+xmlapplication/atom+xmlapplication/xmltext/xmlapplication/feed+json
Testing Scenarios
- Direct Feed URL: Should work immediately
- Homepage with feed: Should find feed in HTML
- Blog with
/feedpath: Should discover via common locations - Invalid URL: Should show error message
- Timeout: Should fail gracefully after 10 seconds
Future Enhancements
Potential improvements:
- Cache discovered feed URLs to avoid re-discovery
- Support for more feed formats (JSON Feed, etc.)
- Show discovery progress indicator
- Allow users to choose from multiple feeds if found
Tagging System Implementation
Overview
The tagging system allows users to organize their RSS and Nostr feeds using custom tags/categories. Tags enable powerful filtering and organization of feeds.
Features
1. Tag Management
- Add tags when subscribing: Users can add multiple tags when subscribing to a new feed
- Tag input with autocomplete: Simple input field with “Add” button or press Enter
- Visual tag display: Tags shown as pills/badges on feeds
- Remove tags: Click × on tag to remove it
2. Sidebar Views
The left sidebar has two views that users can toggle between:
Feeds View
- Shows all subscribed feeds (filtered by selected tags if any)
- Displays tags for each feed
- Shows unread count per feed
- Includes “All Items” aggregated view
Tags View
- Shows all user’s tags
- Displays feed count per tag (how many feeds have this tag)
- Shows unread count per tag (total unread items across all feeds with this tag)
- Click a tag to filter feeds by that tag
3. Tag Filtering
- Multi-tag filtering: Select multiple tags to narrow down feeds
- Active filter indicator: Blue banner shows currently selected tags
- Clear filters: One-click to remove all tag filters
- Drill-down: Switch to Feeds view to see filtered results
Database Schema
Subscription Model
model Subscription {
id String @id @default(cuid())
userPubkey String
feedId String
createdAt DateTime @default(now())
tags String[] @default([]) // Array of tag strings
feed Feed @relation(fields: [feedId], references: [id], onDelete: Cascade)
@@unique([userPubkey, feedId])
@@index([userPubkey, tags])
}
API Endpoints
subscribeFeed
Input:
{
type: 'RSS' | 'NOSTR',
url?: string,
npub?: string,
title?: string,
tags?: string[] // Optional array of tags
}
updateSubscriptionTags
Input:
{
feedId: string,
tags: string[] // Replace existing tags with new array
}
getUserTags
Output:
[{
tag: string,
unreadCount: number, // Total unread items across all feeds with this tag
feedCount: number // Number of feeds with this tag
}]
getFeeds
Input:
{
tags?: string[] // Optional: filter feeds that have ALL specified tags
}
Output:
[{
id: string,
title: string,
type: 'RSS' | 'NOSTR',
unreadCount: number,
tags: string[] // Tags assigned to this feed
// ... other fields
}]
UI Components
AddFeedModal
- Tag input field at the bottom of the modal
- Live tag preview (pills that can be removed before submitting)
- Tags persist when toggling between RSS/Nostr tabs
- Tags cleared on modal close
FeedReader Sidebar
- View Toggle: Feeds ↔️ Tags tabs at top
- Active Filters Banner: Shows when tags are selected (can clear all or remove individually)
- Feeds List: Shows tag pills on each feed
- Tags List: Shows each tag with unread count badge and feed count
Usage Examples
Add a feed with tags
- Click “Add Feed”
- Enter feed URL or search for Nostr profile
- Type tag name in “Tags” field and click “Add” or press Enter
- Add multiple tags as needed
- Click “Add Feed”
Filter feeds by tags
- Click “Tags” tab in sidebar
- Click on one or more tags to filter
- Switch to “Feeds” tab to see filtered results
- Click “Clear” in blue banner to remove filters
View unread counts per tag
- Click “Tags” tab in sidebar
- See unread count badge next to each tag
- Also see how many feeds have each tag
Implementation Notes
Tag Filtering Logic
- Uses PostgreSQL’s
hasEveryoperator for array field filtering - Filters are AND-based: selecting multiple tags shows only feeds with ALL selected tags
- Unread counts are calculated by aggregating across all feeds matching the tag(s)
Performance Considerations
- Added composite index on
[userPubkey, tags]for efficient tag queries - Tag aggregation happens in-memory for better performance
- Consider caching tag counts for large datasets
Future Enhancements
- Tag editing for existing subscriptions
- Tag rename/merge functionality
- Tag color customization
- Export/import tags with feed subscriptions
- Tag-based RSS export (OPML with categories)
- Smart tag suggestions based on feed content
- Tag hierarchies (parent/child tags)
Readstr Subscription Sync
Overview
Readstr uses Nostr events to sync RSS and Nostr long-form content subscriptions across devices. This enables users to maintain a single subscription list that works on mobile, desktop, and web - all tied to their Nostr identity.
New in v2: The sync system now tracks deleted subscriptions and read status across devices for a seamless experience.
How It Works
Event Types
Kind 30404 - Subscription List Sync
Subscription lists are stored as replaceable events using kind 30404. This is in the 30000-39999 range, which means:
- Events are replaceable (newer versions overwrite older ones)
- The
dtag identifies the specific list - Only the most recent event per
pubkey+dtag combination is kept
Kind 30405 - Read Status Sync (New)
Read status for feed items is synced using kind 30405:
- Tracks which items have been marked as read
- Syncs across all devices
- Uses item GUIDs for identification
Event Structure - Subscription List
{
"kind": 30404,
"pubkey": "<user's hex pubkey>",
"created_at": 1732645747,
"tags": [
["d", "readstr-subscriptions"],
["client", "readstr"]
],
"content": "{\"rss\":[...],\"nostr\":[...],\"deleted\":[...],\"tags\":{...},\"lastUpdated\":1732645747}",
"id": "<event id>",
"sig": "<signature>"
}
Event Structure - Read Status
{
"kind": 30405,
"pubkey": "<user's hex pubkey>",
"created_at": 1732645747,
"tags": [
["d", "readstr-read-status"],
["client", "readstr"]
],
"content": "{\"itemGuids\":[...],\"lastUpdated\":1732645747}",
"id": "<event id>",
"sig": "<signature>"
}
Content Schema
The content field for kind 30404 (subscriptions) contains:
interface SubscriptionList {
// RSS feed URLs
rss: string[]
// Nostr npubs for long-form content authors
nostr: string[]
// NEW: Explicitly deleted feeds (URLs or npubs)
deleted?: string[]
// Optional: tags/categories per feed
// Key is the feed URL or npub
tags?: Record<string, string[]>
// Optional: category info per feed (NEW)
// Key is the feed URL or npub
categories?: Record<string, { name: string; color?: string; icon?: string }>
// Unix timestamp of last update
lastUpdated?: number
}
The content field for kind 30405 (read status) contains:
interface ReadStatusList {
// GUIDs of feed items that have been read
itemGuids: string[]
// Unix timestamp of last update
lastUpdated?: number
}
Example Content
{
"rss": [
"https://example.com/feed.xml",
"https://www.youtube.com/feeds/videos.xml?channel_id=UCxyz..."
],
"nostr": [
"npub1cj8znuztfqkvq89pl8hceph0svvvqk0qay6nydgk9uyq7fhpfsgsqwrz4u",
"npub1v5ufyh4lkeslgxxcclg8f0hzazhaw7rsrhvfquxzm2fk64c72hps45n0v5"
],
"tags": {
"https://example.com/feed.xml": ["tech", "news"],
"npub1cj8znuztfqkvq89pl8hceph0svvvqk0qay6nydgk9uyq7fhpfsgsqwrz4u": ["bitcoin", "nostr"]
},
"categories": {
"https://example.com/feed.xml": {
"name": "Technology",
"color": "#3b82f6",
"icon": "💻"
},
"npub1v5ufyh4lkeslgxxcclg8f0hzazhaw7rsrhvfquxzm2fk64c72hps45n0v5": {
"name": "Bitcoin",
"color": "#f59e0b",
"icon": "₿"
}
},
"lastUpdated": 1732645747
}
Implementation Guide
Prerequisites
- nostr-tools library
- NIP-07 browser extension support (Alby, nos2x, etc.) or custom signing
1. Publishing Subscriptions
import { SimplePool, nip19 } from 'nostr-tools'
import type { UnsignedEvent, Event } from 'nostr-tools'
const SUBSCRIPTION_LIST_KIND = 30404
interface SubscriptionList {
rss: string[]
nostr: string[]
tags?: Record<string, string[]>
categories?: Record<string, { name: string; color?: string; icon?: string }>
lastUpdated?: number
}
async function publishSubscriptionList(
subscriptionList: SubscriptionList,
relays: string[]
): Promise<string> {
const pool = new SimplePool()
// Get pubkey from NIP-07 extension
const pubkey = await window.nostr.getPublicKey()
// Create unsigned event
const unsignedEvent: UnsignedEvent = {
kind: SUBSCRIPTION_LIST_KIND,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', 'readstr-subscriptions'],
['client', 'your-app-name'], // Identify your app
],
content: JSON.stringify({
...subscriptionList,
lastUpdated: Math.floor(Date.now() / 1000),
}),
}
// Sign with NIP-07 extension
const signedEvent = await window.nostr.signEvent(unsignedEvent)
// Publish to relays
const publishPromises = pool.publish(relays, signedEvent)
await Promise.race(publishPromises)
pool.close(relays)
return signedEvent.id
}
2. Fetching Subscriptions
async function fetchSubscriptionList(
userPubkey: string,
relays: string[]
): Promise<SubscriptionList | null> {
const pool = new SimplePool()
// Convert npub to hex if needed
let pubkeyHex = userPubkey
if (userPubkey.startsWith('npub')) {
const decoded = nip19.decode(userPubkey)
if (decoded.type === 'npub') {
pubkeyHex = decoded.data
}
}
// Query for the subscription list
const event = await pool.get(relays, {
kinds: [SUBSCRIPTION_LIST_KIND],
authors: [pubkeyHex],
'#d': ['readstr-subscriptions'],
})
pool.close(relays)
if (!event) {
return null
}
return JSON.parse(event.content) as SubscriptionList
}
3. Merging Local and Remote
When syncing, you’ll want to merge local subscriptions with remote ones:
interface Feed {
type: 'RSS' | 'NOSTR'
url: string
tags?: string[]
category?: { name: string; color?: string; icon?: string }
}
function mergeSubscriptions(
localFeeds: Feed[],
remoteList: SubscriptionList
): {
toAdd: Feed[] // Remote feeds not in local
localOnly: Feed[] // Local feeds not in remote
} {
const localRssUrls = new Set(
localFeeds
.filter(f => f.type === 'RSS')
.map(f => f.url.toLowerCase())
)
const localNpubs = new Set(
localFeeds
.filter(f => f.type === 'NOSTR')
.map(f => {
const match = f.url.match(/npub\w+/)
return match ? match[0].toLowerCase() : f.url.toLowerCase()
})
)
const toAdd: Feed[] = []
// Find remote RSS feeds not in local
for (const rssUrl of remoteList.rss) {
if (!localRssUrls.has(rssUrl.toLowerCase())) {
toAdd.push({
type: 'RSS',
url: rssUrl,
tags: remoteList.tags?.[rssUrl],
category: remoteList.categories?.[rssUrl],
})
}
}
// Find remote Nostr feeds not in local
for (const npub of remoteList.nostr) {
if (!localNpubs.has(npub.toLowerCase())) {
toAdd.push({
type: 'NOSTR',
url: npub,
tags: remoteList.tags?.[npub],
category: remoteList.categories?.[npub],
})
}
}
// Find local-only feeds
const remoteRssLower = new Set(remoteList.rss.map(u => u.toLowerCase()))
const remoteNostrLower = new Set(remoteList.nostr.map(n => n.toLowerCase()))
const localOnly = localFeeds.filter(f => {
if (f.type === 'RSS') {
return !remoteRssLower.has(f.url.toLowerCase())
} else {
const match = f.url.match(/npub\w+/)
const npub = match ? match[0].toLowerCase() : f.url.toLowerCase()
return !remoteNostrLower.has(npub)
}
})
return { toAdd, localOnly }
}
4. Complete Sync Flow
async function syncSubscriptions(
localFeeds: Feed[],
relays: string[]
): Promise<void> {
// 1. Get user's pubkey
const pubkey = await window.nostr.getPublicKey()
const npub = nip19.npubEncode(pubkey)
// 2. Fetch remote subscriptions
const remoteList = await fetchSubscriptionList(npub, relays)
if (!remoteList) {
// No remote list exists, publish local
await publishSubscriptionList(
buildSubscriptionList(localFeeds),
relays
)
return
}
// 3. Merge
const { toAdd, localOnly } = mergeSubscriptions(localFeeds, remoteList)
// 4. Prompt user
if (toAdd.length > 0) {
const shouldImport = confirm(
`Found ${toAdd.length} subscriptions on Nostr. Import them?`
)
if (shouldImport) {
// Add remote feeds to local
for (const feed of toAdd) {
await addFeedLocally(feed)
}
}
}
// 5. Upload merged list
const allFeeds = [...localFeeds, ...toAdd]
await publishSubscriptionList(
buildSubscriptionList(allFeeds),
relays
)
}
function buildSubscriptionList(feeds: Feed[]): SubscriptionList {
const rss: string[] = []
const nostr: string[] = []
const tags: Record<string, string[]> = {}
const categories: Record<string, { name: string; color?: string; icon?: string }> = {}
for (const feed of feeds) {
if (feed.type === 'RSS') {
rss.push(feed.url)
if (feed.tags?.length) {
tags[feed.url] = feed.tags
}
if (feed.category) {
categories[feed.url] = feed.category
}
} else {
const npubMatch = feed.url.match(/npub\w+/)
const npub = npubMatch ? npubMatch[0] : feed.url
nostr.push(npub)
if (feed.tags?.length) {
tags[npub] = feed.tags
}
if (feed.category) {
categories[npub] = feed.category
}
}
}
return { rss, nostr, tags, categories }
}
Recommended Relays
Use multiple relays for redundancy:
const SYNC_RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.snort.social',
'wss://relay.nostr.band',
'wss://nostr-pub.wellorder.net',
]
Platform-Specific Notes
Web (Browser)
Use NIP-07 browser extensions for signing:
if (window.nostr) {
const pubkey = await window.nostr.getPublicKey()
const signedEvent = await window.nostr.signEvent(unsignedEvent)
}
React Native / Mobile
Use a Nostr signing library or implement NIP-46 (Nostr Connect) for remote signing:
// Using @nostr-dev-kit/ndk
import NDK from '@nostr-dev-kit/ndk'
const ndk = new NDK({ explicitRelayUrls: SYNC_RELAYS })
await ndk.connect()
// With a signer
const signer = new NDKPrivateKeySigner(privateKey)
ndk.signer = signer
const event = new NDKEvent(ndk)
event.kind = 30404
event.tags = [['d', 'readstr-subscriptions']]
event.content = JSON.stringify(subscriptionList)
await event.publish()
iOS Swift
Use NostrSDK or similar:
import NostrSDK
func publishSubscriptions(_ list: SubscriptionList) async throws {
let content = try JSONEncoder().encode(list)
let event = Event(
kind: 30404,
tags: [
["d", "readstr-subscriptions"],
["client", "my-ios-app"]
],
content: String(data: content, encoding: .utf8)!
)
let signedEvent = try event.sign(with: privateKey)
for relay in relays {
try await relay.publish(signedEvent)
}
}
Android Kotlin
Use nostr-java or similar:
import nostr.event.Event
import nostr.event.Kind
fun publishSubscriptions(list: SubscriptionList) {
val content = gson.toJson(list)
val event = Event.Builder()
.kind(30404)
.tags(listOf(
listOf("d", "readstr-subscriptions"),
listOf("client", "my-android-app")
))
.content(content)
.build()
val signedEvent = event.sign(privateKey)
relays.forEach { relay ->
relay.send(signedEvent)
}
}
Interoperability
Any app that follows this specification can read and write subscription lists. The key identifiers are:
| Field | Value | Purpose |
|---|---|---|
kind | 30404 | Event type for subscription lists |
d tag | readstr-subscriptions | Identifies this specific list type |
client tag | Your app name | Optional, for analytics/debugging |
Reading Lists from Other Apps
When fetching, check for any 30404 events with the d tag:
const events = await pool.querySync(relays, {
kinds: [30404],
authors: [pubkeyHex],
'#d': ['readstr-subscriptions'],
})
// Get the most recent one
const latest = events.sort((a, b) => b.created_at - a.created_at)[0]
Extending the Schema
If you need additional fields, add them to the content JSON. Existing fields should be preserved:
{
"rss": [...],
"nostr": [...],
"tags": {...},
"categories": {...},
"lastUpdated": 1732645747,
"yourAppField": "custom data"
}
Testing
Verify Events on Relays
# Check for subscription sync events
node -e "
const { SimplePool, nip19 } = require('nostr-tools');
const pool = new SimplePool();
const relays = ['wss://relay.damus.io', 'wss://nos.lol'];
async function check() {
const events = await pool.querySync(relays, {
kinds: [30404],
'#d': ['readstr-subscriptions'],
limit: 10,
});
console.log('Found', events.length, 'subscription sync events');
for (const event of events) {
const npub = nip19.npubEncode(event.pubkey);
console.log('User:', npub);
console.log('Content:', JSON.parse(event.content));
}
pool.close(relays);
}
check();
"
Manual Event Inspection
Use nostr.band or njump.me to search for kind 30404 events.
Security Considerations
-
Private Data: Subscription lists are public on relays. Don’t include sensitive information.
-
Event Verification: Always verify event signatures before trusting content.
-
Content Validation: Parse and validate JSON content before using.
-
Rate Limiting: Don’t publish too frequently. Once per subscription change is enough.
Summary
Readstr subscription sync enables cross-device, cross-app subscription management using standard Nostr events. By following this specification, your app can:
- Export user subscriptions to Nostr relays
- Import subscriptions from other devices/apps
- Merge local and remote subscription lists
- Interoperate with Readstr and other compatible apps
The user’s Nostr identity becomes their universal subscription identity.
Bidirectional Automatic Sync
Overview
Readstr now features fully automatic bidirectional sync - your subscriptions, categories, and tags sync seamlessly across all your devices without any manual intervention.
How It Works
🔄 Two-Way Sync
Upload (This Device → Nostr Relays)
- ✅ Happens automatically after every change
- ✅ Triggers: Add feed, remove feed, update tags, change category
- ✅ Timing: 500ms after the change
- ✅ Includes deleted feeds for proper sync
Download (Nostr Relays → This Device)
- ✅ Happens automatically every 15 minutes
- ✅ Triggers: On page load, when fetching feeds
- ✅ Creates categories if they don’t exist
- ✅ Only pulls if >15 minutes since last sync
📱 User Experience
From the User’s Perspective:
-
On Device A: Add a feed to “Technology” category
- Feed is added immediately
- 500ms later: Automatically syncs to Nostr (silent, background)
-
On Device B: Open the app (or wait up to 15 minutes)
- Automatically detects new feed from Nostr
- Creates “Technology” category if it doesn’t exist
- Adds the feed with proper category assignment
- All happens seamlessly, no prompts or buttons
-
Back on Device A: Remove a feed
- Feed disappears immediately
- 500ms later: Deletion syncs to Nostr
-
On Device B: Next refresh (within 15 minutes)
- Feed automatically removed
- Deleted feed tracked in sync to prevent re-adding
🎯 What Gets Synced
Automatically synced on every change:
- RSS feed URLs
- Nostr npub subscriptions
- Feed tags (multiple tags per feed)
- Feed categories (name, color, icon)
- Deleted feeds (to properly sync removals)
Preserved properties:
- Category colors
- Category icons
- Tag associations
- Subscription metadata
🔒 Requirements
For Auto-Export (Upload):
- ✅ Nostr browser extension installed (Alby, nos2x, etc.)
- ✅ Signed in with Nostr
- ✅ Extension must be available (
window.nostr)
For Auto-Import (Download):
- ✅ Signed in with Nostr (any method)
- ✅ Access to Nostr relays
If requirements not met:
- Export: Silently skips (no error shown to user)
- Import: Still works via server-side fetch
Technical Details
Architecture
User Action (Add/Remove/Edit)
↓
Local Mutation (tRPC)
↓
UI Updates Immediately
↓
500ms delay
↓
autoExportToNostr()
↓
Fetch ALL subscriptions (including deleted)
↓
Build subscription list with categories
↓
Sign with Nostr extension
↓
Publish to relays (Kind 30404)
↓
Done (silent success/failure)
Mutations That Trigger Auto-Export
- subscribeFeedMutation - Adding a new feed
- unsubscribeFeedMutation - Removing a feed (soft delete)
- updateTagsMutation - Changing feed tags
- updateCategoryMutation - Moving feed to different category
Auto-Import Triggers
- getFeeds query - On every feed list fetch
- Interval check - Max once per 15 minutes
- Manual sync - Settings > Sync > Import
Data Flow
Kind 30404 Event Structure:
{
"kind": 30404,
"created_at": 1738095789,
"tags": [
["d", "readstr-subscriptions"],
["client", "readstr"]
],
"content": {
"rss": ["https://example.com/feed.xml"],
"nostr": ["npub1abc..."],
"tags": {
"https://example.com/feed.xml": ["tech", "news"]
},
"categories": {
"https://example.com/feed.xml": {
"name": "Technology",
"color": "#3b82f6",
"icon": "💻"
}
},
"deleted": ["https://old-feed.com/rss"],
"lastUpdated": 1738095789
}
}
Performance & Throttling
Upload Throttling
- 500ms delay after mutation completes
- Allows UI to settle before network request
- Prevents rapid-fire uploads during bulk operations
Download Throttling
- 15-minute cooldown between auto-imports
- Prevents excessive relay queries
- Balances freshness with resource usage
Error Handling
- Silent failures on export (logged to console)
- User never sees error modals for background sync
- Manual sync option available if auto-sync fails
Resource Usage
- Export: ~1-2 KB per event
- Import: Single relay query every 15 minutes
- No polling or WebSocket connections
- Minimal battery/bandwidth impact
Migration from Manual Sync
Before (Manual Only)
❌ User adds feed on Device A
❌ Feed stays local to Device A
❌ User must remember to click "Export to Nostr"
❌ User switches to Device B
❌ Must click "Import from Nostr"
❌ Feed finally appears on Device B
After (Automatic)
✅ User adds feed on Device A
✅ Automatically syncs to Nostr (500ms later)
✅ User switches to Device B
✅ Feed appears automatically (within 15 minutes)
✅ Zero manual intervention required
Debugging
Check if Auto-Export is Working
Open browser console and look for:
🔄 Auto-exporting subscriptions to Nostr...
✅ Auto-export successful: <eventId>
Check if Auto-Import is Working
Look for server logs during feed fetch:
🔍 Sync merge - Remote RSS URLs: [...]
🔍 Sync merge - Remote Nostr npubs: [...]
🔍 Sync result: X to add, Y to remove, Z local-only
Common Issues
Auto-export not triggering:
- Check if
window.nostris available - Verify user is signed in
- Check console for errors
Auto-import not working:
- Wait 15 minutes since last sync
- Check relay connectivity
- Verify subscription list exists on relays
Categories not syncing:
- Ensure both devices have category support
- Check that color/icon are being set
- Verify category names match exactly
Future Enhancements
- Conflict resolution for simultaneous edits
- Sync status indicator in UI
- Configurable sync interval (user preference)
- Offline queue for failed uploads
- Selective sync (choose what to sync)
- Sync history/audit log
- Category sort order syncing
- Hierarchical categories
Category Sync Implementation
Overview
Categories are now synced alongside tags in the Nostr subscription sync system (Kind 30404 events). This enables users to maintain their feed organization across different devices and clients.
NEW: Automatic Bidirectional Sync - Changes sync automatically in both directions:
- ✅ Download (Import): Every 15 minutes, checks for new feeds/categories from other devices
- ✅ Upload (Export): Immediately after any change (add/remove feed, change category, update tags)
What Changed
1. Nostr Sync Library (src/lib/nostr-sync.ts)
Updated SubscriptionList interface:
export interface SubscriptionList {
rss: string[]
nostr: string[]
tags?: Record<string, string[]>
categories?: Record<string, { name: string; color?: string; icon?: string }> // NEW
deleted?: string[]
lastUpdated?: number
}
Updated buildSubscriptionListFromFeeds function:
- Now accepts and exports category information for each feed
- Maps feed URLs/npubs to category objects containing name, color, and icon
Updated mergeSubscriptionLists function:
- Returns category information in the
toAddarray - Enables proper category restoration when importing feeds from other devices
2. Server-Side Sync (src/server/api/routers/feed.ts)
Auto-sync in getFeeds endpoint:
- When importing feeds from remote, automatically creates categories if they don’t exist
- Maps remote category names to local category IDs
- Associates imported feeds with their categories
Category creation logic:
- Checks for existing categories by name
- Creates new categories with synced color and icon properties
- Maintains proper sort order for new categories
3. Client-Side Components
Settings Dialog (src/components/settings-dialog.tsx):
- Updated type definitions to include category information
- Passes category data when building subscription lists for export
Feed Reader (src/components/feed-reader.tsx):
- Updated
handleImportFeedsto handle categories - Automatically creates categories during import if they don’t exist
- Maps feeds to categories using the synced category names
- Added
createCategoryMutationfor on-the-fly category creation
4. Documentation
Updated SUBSCRIPTION_SYNC.md:
- Added categories field to the schema documentation
- Updated example JSON to show category structure
- Updated code examples to handle categories
How It Works
Automatic Export (Upload to Nostr)
Changes are automatically exported to Nostr immediately after:
- Adding a feed
- Removing/unsubscribing from a feed
- Updating a feed’s tags
- Changing a feed’s category
Process:
- User makes a change (e.g., adds a feed)
- System waits 500ms for UI to update
- Fetches all subscriptions (including deleted ones)
- Builds subscription list with categories
- Signs and publishes Kind 30404 event to Nostr relays
- Happens silently in background (no user interruption)
Automatic Import (Download from Nostr)
The system automatically imports changes every 15 minutes:
- On page load/refresh
- Every time feeds are fetched
- Only if >15 minutes since last sync
Process:
- Fetches Kind 30404 event from Nostr relays
- Compares with local subscriptions
- For each new feed with a category:
- Checks if category exists locally by name
- Creates category if needed (with synced color/icon)
- Associates feed with the category
- Imports feed with tags and category assignment
Manual Export (Settings)
Users can still manually trigger export from Settings > Sync > “Export to Nostr”
- Useful for forcing an immediate sync
- Same process as automatic export
Exporting Subscriptions (Legacy Documentation - Now Automatic)
User clicks “Export to Nostr” in Settings > Sync(Now happens automatically)- System calls
buildSubscriptionListFromFeedswith current feeds (including categories) - Creates a Kind 30404 event with:
- Feed URLs/npubs
- Tags per feed
- Categories per feed (name, color, icon)
- Deleted feeds (for proper sync)
- Publishes to configured Nostr relays
Importing Subscriptions (Automatic Every 15 Minutes)
- User clicks “Import from Nostr” or automatic sync triggers
- System fetches Kind 30404 event from relays
- For each new feed with a category:
- Checks if category exists locally by name
- Creates category if needed (with synced color/icon)
- Associates feed with the category
- Imports feed with tags and category assignment
Cross-Device Sync
When syncing between devices:
- Categories are matched by name (case-sensitive)
- If a category doesn’t exist, it’s created with synced properties
- Feeds maintain their category associations
- Color and icon preferences are preserved
Example Synced Data
{
"rss": ["https://example.com/feed.xml"],
"nostr": ["npub1abc..."],
"tags": {
"https://example.com/feed.xml": ["tech", "news"]
},
"categories": {
"https://example.com/feed.xml": {
"name": "Technology",
"color": "#3b82f6",
"icon": "💻"
},
"npub1abc...": {
"name": "Bitcoin",
"color": "#f59e0b",
"icon": "₿"
}
},
"lastUpdated": 1738095789
}
Benefits
- Full Organization Sync: Users can switch between devices and maintain their complete feed organization
- Cross-Client Compatibility: Other Nostr clients can read and preserve category information
- Automatic Category Creation: No manual setup needed when moving to a new device
- Visual Consistency: Colors and icons sync across devices
- Backwards Compatible: Clients that don’t support categories will simply ignore the field
- Real-Time Sync: Changes appear on other devices within 15 minutes (or immediately on next page load)
- Zero User Effort: No manual “Export” button clicking required
- Deleted Feed Tracking: Properly syncs feed removals across devices
Technical Implementation
New Endpoint: getAllSubscriptionsForSync
Added to support automatic export with deleted feeds:
getAllSubscriptionsForSync: protectedProcedure
.query(async ({ ctx }) => {
// Returns ALL subscriptions including deleted ones
// Used for building complete sync payload
})
Auto-Export Function
Located in feed-reader.tsx:
const autoExportToNostr = useCallback(async () => {
// Fetches all subscriptions (including deleted)
const allSubscriptions = await utils.feed.getAllSubscriptionsForSync.fetch()
// Builds and publishes to Nostr
const result = await publishSubscriptionList(subscriptionList, signEvent)
}, [user?.npub, utils.feed])
Called automatically after these mutations:
subscribeFeedMutation(add feed)unsubscribeFeedMutation(remove feed)updateTagsMutation(update tags)updateCategoryMutation(change category)
Throttling & Performance
- 500ms delay after mutations (allows UI to update first)
- Runs asynchronously (doesn’t block UI)
- Silent failure (errors logged but don’t interrupt user)
- Import happens max once per 15 minutes (prevents excessive relay queries)
Implementation Notes
- Categories are matched by name during import
- If multiple feeds reference the same category name, only one category is created
- Category sort order is preserved locally but not synced (to allow per-device customization)
- The system gracefully handles missing categories (feeds import without category if creation fails)
- Categories are created on-demand during import to avoid conflicts
Future Enhancements
- Category sort order syncing
- Category merge/rename detection
- Category-level metadata (description, rules)
- Hierarchical categories
Sync System Improvements - Implementation Summary
Overview
Enhanced the Nostr subscription sync system to properly track feed deletions and read status across devices.
Changes Made
1. Database Schema Updates (prisma/schema.prisma)
Subscription Model
- Added
updatedAtfield to track when subscriptions change - Added
deletedAtfield for soft-delete tracking (instead of hard deletes) - Added indexes for efficient querying of deleted items
ReadItem Model
- Added
syncedAtfield to track when read status was last synced to Nostr - Added index for efficient querying of unsynced items
2. Sync Library Updates (src/lib/nostr-sync.ts)
New Interfaces
- Extended
SubscriptionListwithdeleted?: string[]array - Added
ReadStatusListinterface for read status sync
New Event Kind
- Added
READ_STATUS_KIND = 30405for syncing read status
Enhanced Functions
buildSubscriptionListFromFeeds()- Now includes deleted feeds in outputmergeSubscriptionLists()- Now returnstoRemovearray for feeds deleted remotely- Added
publishReadStatus()- Publish read items to Nostr - Added
fetchReadStatus()- Fetch read items from Nostr
3. API Updates (src/server/api/routers/feed.ts)
Modified Endpoints
getFeeds- Now filters out soft-deleted subscriptions (deletedAt: null)unsubscribeFeed- Changed from hard delete to soft delete (setsdeletedAt)
New Endpoints
getSubscriptionsForSync- Returns all subscriptions including deleted ones for synccleanupDeletedSubscriptions- Hard deletes old soft-deleted records (cleanup)markReadItemsSynced- Marks read items as synced after publishinggetUnsyncedReadItems- Gets read items that haven’t been synced yet
4. Documentation Updates (SUBSCRIPTION_SYNC.md)
- Added documentation for kind 30405 (read status sync)
- Added
deletedfield to subscription list schema - Added
ReadStatusListinterface documentation
How It Works
Deletion Tracking
- When a user unsubscribes from a feed, it’s soft-deleted (deletedAt timestamp set)
- During sync, deleted feeds are included in the
deletedarray in the Nostr event - When another device syncs, it sees the deleted feeds and removes them locally
- Old soft-deleted records can be cleaned up after 90 days (configurable)
Read Status Sync
- When items are marked as read, they’re tracked in the local database
- Unsynced read items can be published to Nostr (kind 30405)
- When syncing on another device, the read status is fetched and applied
- Items are marked with
syncedAttimestamp after successful sync
Migration
A database migration has been created:
- File:
prisma/migrations/20260128144700_add_sync_tracking/migration.sql - Adds
deletedAtandupdatedAtto Subscription table - Adds
syncedAtto ReadItem table - Creates necessary indexes
To apply: Run npx prisma migrate deploy in production or npx prisma migrate dev in development
Benefits
- No More Re-adding Deleted Feeds: Deletions are now tracked and synced across devices
- Read Status Sync: Read/unread status can be synced across devices (optional feature)
- Conflict Resolution: Soft deletes allow for better conflict resolution in sync scenarios
- Data Integrity: No loss of information during sync operations
- Cleanup: Old deleted records can be purged after a grace period
Next Steps for Implementation
- Apply the database migration
- Update client-side sync logic to use new
toRemovearray from merge function - Implement UI for read status sync (optional feature)
- Add periodic cleanup job for old deleted subscriptions
- Test cross-device sync scenarios
Backward Compatibility
- Existing sync events (kind 30404) without
deletedfield will continue to work - The
deletedfield is optional and only included when there are deletions - Read status sync (kind 30405) is completely new and optional
Readstr Guide API Implementation
Overview
Create a REST API that exposes the Readstr guide (curated list of RSS and Nostr long-form content feeds) to native mobile apps and external integrations. The API should enable:
- Feed Discovery - List all curated feeds with filtering/search
- Feed Details - Get individual feed info with recent posts
- One-Click Subscribe - Deep-link web pages for native app → web subscription flow
Tech Stack
- Framework: Next.js App Router (API Routes)
- Database: PostgreSQL via Prisma ORM
- Models:
GuideFeed(curated feeds),GuideFeedPost(cached posts)
Database Schema Reference
model GuideFeed {
id String @id @default(cuid())
type FeedType // RSS | NOSTR | NOSTR_VIDEO
url String? // RSS feed URL
npub String? // Nostr pubkey for NOSTR types
title String
description String?
category String? // e.g., "Bitcoin", "Technology", "News"
tags String[] // ["bitcoin", "lightning", "privacy"]
imageUrl String? // Feed avatar/logo
isActive Boolean @default(true)
featured Boolean @default(false)
posts GuideFeedPost[]
}
model GuideFeedPost {
id String @id @default(cuid())
feedId String
feed GuideFeed @relation(fields: [feedId], references: [id])
title String
content String?
url String?
author String?
publishedAt DateTime
}
API Endpoints
1. GET /api/guide - List All Feeds
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
category | string | Filter by category |
tag | string | Filter by tag |
type | string | Filter by feed type: RSS, NOSTR, NOSTR_VIDEO |
featured | boolean | Only featured feeds |
search | string | Search title/description |
limit | number | Results per page (default: 50, max: 100) |
offset | number | Pagination offset |
Response:
{
"feeds": [
{
"id": "clxx...",
"type": "NOSTR",
"npub": "npub1abc...",
"title": "Author Name",
"description": "Long-form Bitcoin content",
"category": "Bitcoin",
"tags": ["bitcoin", "lightning"],
"imageUrl": "https://...",
"featured": true,
"subscribeUrl": "https://readstr.privkey.io/subscribe?npub=npub1abc..."
}
],
"total": 42,
"limit": 50,
"offset": 0,
"categories": ["Bitcoin", "Technology", "News"],
"tags": ["bitcoin", "nostr", "privacy", "lightning"]
}
2. GET /api/guide/[id] - Single Feed Details
Path Parameter: Feed ID or npub
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
includePosts | boolean | Include recent posts |
postLimit | number | Number of posts (default: 10) |
Response:
{
"feed": {
"id": "clxx...",
"type": "NOSTR",
"npub": "npub1abc...",
"title": "Author Name",
"description": "...",
"category": "Bitcoin",
"tags": ["bitcoin"],
"imageUrl": "https://...",
"subscribeUrl": "https://readstr.privkey.io/subscribe?npub=npub1abc...",
"posts": [
{
"id": "post123",
"title": "Article Title",
"content": "Preview text...",
"url": "https://habla.news/a/naddr...",
"author": "npub1abc...",
"publishedAt": "2025-11-26T12:00:00Z"
}
]
}
}
3. GET /subscribe - Subscribe Redirect Page
This is a web page (not JSON API) for handling deep-link subscriptions from native apps.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
npub | string | Required. The guide feed’s Nostr pubkey to subscribe to. Missing → error “Missing npub parameter”. |
tags | string | Optional comma-separated tags. Falls back to the guide feed’s own tags when omitted. |
return | string | Optional same-origin path to redirect to after subscribing. Must start with a single / (e.g. /reader). Off-origin URLs, protocol-relative //host, and custom schemes are rejected and fall back to /reader. |
Behavior:
- If user is logged in → Add subscription and redirect to the
returnpath - If user is not logged in → Show login prompt, then subscribe
- After success → Redirect to the sanitized same-origin
returnpath, defaulting to/reader
Implementation Notes:
- Use Next.js page component at
src/app/subscribe/page.tsx - Check auth state via
useNostrAuth()context - Call
api.feed.subscribeFeed.useMutation()for subscription - Authenticate via NIP-07 browser extension (
connect('nip07'))
4. GET /api/guide/docs - API Documentation
Return OpenAPI-style documentation as JSON for developer reference.
Implementation Details
File Structure
src/app/api/guide/
├── route.ts # GET /api/guide (list feeds)
├── [id]/
│ └── route.ts # GET /api/guide/[id] (single feed)
└── docs/
└── route.ts # GET /api/guide/docs (API docs)
src/app/subscribe/
└── page.tsx # Subscribe page (web, not API)
Response Headers
All API responses should include:
const headers = {
'Content-Type': 'application/json',
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
'Access-Control-Allow-Origin': '*', // Allow native apps
'Access-Control-Allow-Methods': 'GET, OPTIONS',
}
Error Responses
{
"error": "Feed not found",
"code": "NOT_FOUND"
}
Status codes: 200 (success), 400 (bad request), 404 (not found), 500 (server error)
Subscribe Page UX Flow
┌─────────────────────────────────────────────────┐
│ Native App │
│ ┌─────────────────────────────────────────────┐│
│ │ Feed: Bitcoin Magazine ││
│ │ [Subscribe on Readstr] ← Opens browser ││
│ └─────────────────────────────────────────────┘│
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Browser: readstr.privkey.io/subscribe?npub=... │
│ ┌─────────────────────────────────────────────┐│
│ │ Subscribe to Bitcoin Magazine ││
│ │ ││
│ │ ┌────────────────────────────────────────┐ ││
│ │ │ 🔐 Sign in with Nostr Extension │ ││
│ │ └────────────────────────────────────────┘ ││
│ └─────────────────────────────────────────────┘│
└─────────────────────────────────────────────────┘
│
▼ (after login)
┌─────────────────────────────────────────────────┐
│ ✓ Subscribed! Redirecting to reader... │
└─────────────────────────────────────────────────┘
Native App Integration Examples
A native app simply opens the subscribe URL in the browser. There is no app-scheme callback: the /subscribe page only accepts npub, optional tags, and an optional same-origin return path. Custom schemes like myapp://subscribed are rejected by the page and fall back to /reader, so do not pass them.
iOS Swift
func subscribeToFeed(npub: String) {
let subscribeUrl = "https://readstr.privkey.io/subscribe?npub=\(npub)&return=/reader"
UIApplication.shared.open(URL(string: subscribeUrl)!)
}
Android Kotlin
fun subscribeToFeed(npub: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(
"https://readstr.privkey.io/subscribe?npub=$npub&return=/reader"
))
startActivity(intent)
}
React Native
import { Linking } from 'react-native';
const subscribeToFeed = (npub) => {
const url = `https://readstr.privkey.io/subscribe?npub=${npub}&return=/reader`;
Linking.openURL(url);
};
Flutter
import 'package:url_launcher/url_launcher.dart';
void subscribeToFeed(String npub) async {
final url = 'https://readstr.privkey.io/subscribe?npub=$npub&return=/reader';
if (await canLaunch(url)) {
await launch(url);
}
}
Testing
# List all feeds
curl "https://readstr.privkey.io/api/guide"
# Filter by category
curl "https://readstr.privkey.io/api/guide?category=Bitcoin&featured=true"
# Search feeds
curl "https://readstr.privkey.io/api/guide?search=bitcoin&limit=10"
# Get single feed with posts
curl "https://readstr.privkey.io/api/guide/npub1abc...?includePosts=true&postLimit=5"
# Get API documentation
curl "https://readstr.privkey.io/api/guide/docs"
# Test subscribe page in browser
open "https://readstr.privkey.io/subscribe?npub=npub1abc..."
Summary
This API design enables native Nostr apps to:
- Discover curated RSS and Nostr long-form content feeds
- Display feed previews with recent posts
- Subscribe seamlessly via web handoff with Nostr authentication
The one-click subscribe flow leverages the user’s existing Nostr identity (via NIP-07 browser extension) without requiring native apps to implement their own authentication.
Readstr CLI Development Guide
Project Overview
Build a command-line interface (CLI) client for Readstr using Go and Charm’s Bubble Tea framework for TUI components. The CLI should work both as a standalone RSS/Nostr feed reader and optionally sync with the Readstr web app via Nostr protocol and REST APIs.
Target Features
- RSS & Nostr Feed Management: Subscribe, list, read feeds
- TUI Interface: Beautiful terminal UI using Bubble Tea, Lip Gloss, and Bubbles
- Nostr Sync: Cross-device subscription sync via Nostr events (kind 30404)
- Offline Support: Local SQLite database for feeds and read status
- Categories & Tags: Organize feeds like the web version
- Mark as Read: Track read status locally and optionally sync
- Favorites: Star articles for later
- Search & Filter: Find articles across feeds
Tech Stack
Core
- Language: Go 1.21+
- TUI Framework: Bubble Tea
- Styling: Lip Gloss
- Components: Bubbles
- Database: SQLite with go-sqlite3
- Nostr: go-nostr
- RSS Parsing: gofeed
- Markdown Rendering: glamour
Optional Dependencies
- HTTP Client: Standard library
net/http - JSON: Standard library
encoding/json - Config: viper for config management
- Logging: zerolog
Architecture
readstr-cli/
├── cmd/
│ └── readstr/
│ └── main.go # Entry point
├── internal/
│ ├── app/
│ │ └── app.go # Main Bubble Tea model
│ ├── ui/
│ │ ├── feeds.go # Feed list view
│ │ ├── articles.go # Article list view
│ │ ├── reader.go # Article reader view
│ │ ├── categories.go # Category picker
│ │ └── help.go # Help/key bindings
│ ├── db/
│ │ ├── sqlite.go # SQLite operations
│ │ └── models.go # Data models
│ ├── nostr/
│ │ ├── client.go # Nostr client wrapper
│ │ ├── sync.go # Subscription sync (kind 30404)
│ │ └── signer.go # Event signing
│ ├── feed/
│ │ ├── fetcher.go # RSS/Nostr feed fetcher
│ │ └── parser.go # Content parser
│ ├── api/
│ │ └── client.go # Readstr API client
│ └── config/
│ └── config.go # Configuration management
├── pkg/
│ └── styles/
│ └── theme.go # Lip Gloss styles
├── go.mod
├── go.sum
└── README.md
Database Schema (SQLite)
-- Feeds
CREATE TABLE feeds (
id TEXT PRIMARY KEY,
type TEXT NOT NULL, -- 'RSS' | 'NOSTR' | 'NOSTR_VIDEO'
url TEXT, -- RSS URL or empty for Nostr
npub TEXT, -- Nostr pubkey (npub format)
title TEXT NOT NULL,
description TEXT,
last_fetched_at INTEGER, -- Unix timestamp
category_id TEXT, -- FK to categories.id
created_at INTEGER NOT NULL,
UNIQUE(type, url),
UNIQUE(type, npub)
);
-- Feed Items
CREATE TABLE feed_items (
id TEXT PRIMARY KEY,
feed_id TEXT NOT NULL,
guid TEXT NOT NULL, -- RSS guid or Nostr event ID
title TEXT NOT NULL,
content TEXT,
url TEXT,
author TEXT,
published_at INTEGER NOT NULL,
is_read INTEGER DEFAULT 0, -- Boolean: 0=unread, 1=read
is_favorite INTEGER DEFAULT 0,
thumbnail TEXT, -- Video thumbnail URL
video_id TEXT, -- YouTube/Rumble video ID
created_at INTEGER NOT NULL,
FOREIGN KEY(feed_id) REFERENCES feeds(id) ON DELETE CASCADE,
UNIQUE(feed_id, guid)
);
CREATE INDEX idx_feed_items_feed_id ON feed_items(feed_id);
CREATE INDEX idx_feed_items_published_at ON feed_items(published_at DESC);
CREATE INDEX idx_feed_items_is_read ON feed_items(is_read);
-- Tags (many-to-many with feeds)
CREATE TABLE tags (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL
);
CREATE TABLE feed_tags (
feed_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
PRIMARY KEY(feed_id, tag_id),
FOREIGN KEY(feed_id) REFERENCES feeds(id) ON DELETE CASCADE,
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
-- Categories
CREATE TABLE categories (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
color TEXT, -- Hex color code
icon TEXT, -- Emoji icon
sort_order INTEGER DEFAULT 0
);
-- User Preferences
CREATE TABLE preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Common preferences:
-- 'user_npub' - User's Nostr public key
-- 'user_nsec' - User's Nostr private key (encrypted)
-- 'organization_mode' - 'tags' | 'categories'
-- 'mark_read_behavior' - 'on-open' | 'after-10s' | 'never'
-- 'sync_enabled' - '1' | '0'
-- 'nostr_relays' - JSON array of relay URLs
Nostr Integration
Readstr uses two types of Nostr events for cross-device synchronization:
- Kind 30404 - Subscription list sync (which feeds you’re subscribed to)
- Kind 30405 - Read status sync (which articles you’ve read)
Subscription Sync (Kind 30404)
Use Nostr replaceable events (kind 30404) to sync subscriptions across devices.
Event Structure:
{
"kind": 30404,
"pubkey": "<user's hex pubkey>",
"created_at": 1732645747,
"tags": [
["d", "readstr-subscriptions"],
["client", "readstr-cli"]
],
"content": "{\"rss\":[...],\"nostr\":[...],\"tags\":{...},\"deleted\":[...],\"lastUpdated\":1732645747}"
}
Content Schema:
type SubscriptionList struct {
RSS []string `json:"rss"` // RSS feed URLs
Nostr []string `json:"nostr"` // Nostr npubs
Tags map[string][]string `json:"tags"` // URL/npub -> tags
Deleted []string `json:"deleted"` // Removed feeds
LastUpdated int64 `json:"lastUpdated"` // Unix timestamp
}
Implementation:
package nostr
import (
"context"
"encoding/json"
"github.com/nbd-wtf/go-nostr"
)
const (
SubscriptionListKind = 30404
ReadStatusKind = 30405
SubscriptionDTag = "readstr-subscriptions"
ReadStatusDTag = "readstr-read-status"
)
type SyncClient struct {
pool *nostr.SimplePool
relays []string
signer nostr.EventSigner
}
func (c *SyncClient) PublishSubscriptions(list SubscriptionList) error {
content, _ := json.Marshal(list)
event := nostr.Event{
Kind: SubscriptionListKind,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"d", SubscriptionDTag},
{"client", "readstr-cli"},
},
Content: string(content),
}
event.Sign(c.signer)
ctx := context.Background()
for _, relay := range c.relays {
c.pool.Publish(ctx, relay, event)
}
return nil
}
func (c *SyncClient) FetchSubscriptions(pubkey string) (*SubscriptionList, error) {
ctx := context.Background()
filter := nostr.Filter{
Kinds: []int{SubscriptionListKind},
Authors: []string{pubkey},
Tags: nostr.TagMap{"d": []string{SubscriptionDTag}},
Limit: 1,
}
events := c.pool.QuerySync(ctx, c.relays, filter)
if len(events) == 0 {
return nil, nil
}
var list SubscriptionList
json.Unmarshal([]byte(events[0].Content), &list)
return &list, nil
}
Default Relays:
var DefaultRelays = []string{
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.snort.social",
"wss://relay.nostr.band",
"wss://nostr-pub.wellorder.net",
}
Read Status Sync (Kind 30405)
Use Nostr replaceable events (kind 30405) to sync which articles have been read across devices.
Event Structure:
{
"kind": 30405,
"pubkey": "<user's hex pubkey>",
"created_at": 1732645747,
"tags": [
["d", "readstr-read-status"],
["client", "readstr-cli"]
],
"content": "{\"itemGuids\":[\"guid1\",\"guid2\",\"guid3\",...],\"lastUpdated\":1732645747}"
}
Content Schema:
type ReadStatusList struct {
ItemGuids []string `json:"itemGuids"` // GUIDs of read feed items
LastUpdated int64 `json:"lastUpdated"` // Unix timestamp
}
Implementation:
func (c *SyncClient) PublishReadStatus(readStatus ReadStatusList) error {
content, _ := json.Marshal(readStatus)
event := nostr.Event{
Kind: ReadStatusKind,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"d", ReadStatusDTag},
{"client", "readstr-cli"},
},
Content: string(content),
}
event.Sign(c.signer)
ctx := context.Background()
for _, relay := range c.relays {
c.pool.Publish(ctx, relay, event)
}
return nil
}
func (c *SyncClient) FetchReadStatus(pubkey string) (*ReadStatusList, error) {
ctx := context.Background()
filter := nostr.Filter{
Kinds: []int{ReadStatusKind},
Authors: []string{pubkey},
Tags: nostr.TagMap{"d": []string{ReadStatusDTag}},
Limit: 1,
}
events := c.pool.QuerySync(ctx, c.relays, filter)
if len(events) == 0 {
return nil, nil
}
var status ReadStatusList
json.Unmarshal([]byte(events[0].Content), &status)
return &status, nil
}
Sync Strategy:
The read status list can grow large over time. Consider these strategies:
- Incremental Sync: Only sync GUIDs from the last 90 days
- Batch Updates: Accumulate local changes and sync every N items or M minutes
- Merge Logic: When importing, mark items as read if they exist in remote list
func (c *SyncClient) MergeReadStatus(local, remote ReadStatusList) []string {
// Create a set from remote GUIDs
remoteSet := make(map[string]bool)
for _, guid := range remote.ItemGuids {
remoteSet[guid] = true
}
// Add local GUIDs
for _, guid := range local.ItemGuids {
remoteSet[guid] = true
}
// Convert back to slice
merged := []string{}
for guid := range remoteSet {
merged = append(merged, guid)
}
return merged
}
Nostr Feed Fetching (NIP-23 Long-form Content)
Fetch long-form articles from Nostr users:
func (c *NostrFetcher) FetchUserArticles(npub string, since time.Time) ([]*FeedItem, error) {
pubkey, _ := nostr.GetPublicKey(npub) // Convert npub to hex
filter := nostr.Filter{
Kinds: []int{30023}, // NIP-23 long-form
Authors: []string{pubkey},
Since: nostr.Timestamp(since.Unix()),
Limit: 50,
}
events := c.pool.QuerySync(context.Background(), c.relays, filter)
items := []*FeedItem{}
for _, event := range events {
item := &FeedItem{
GUID: event.ID,
Title: event.Tags.GetFirst([]string{"title", ""}).Value(),
Content: event.Content,
Author: npub,
PublishedAt: time.Unix(int64(event.CreatedAt), 0),
URL: buildNostrURL(event), // Link to habla.news or njump.me
}
items = append(items, item)
}
return items, nil
}
API Integration
Guide API (Public Feed Directory)
Base URL: https://readstr.privkey.io/api/guide
List Feeds
type GuideFeed struct {
ID string `json:"id"`
Type string `json:"type"` // "RSS" | "NOSTR" | "NOSTR_VIDEO"
NPUB string `json:"npub,omitempty"`
URL string `json:"url,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
Category string `json:"category"`
Tags []string `json:"tags"`
ImageURL string `json:"imageUrl"`
Featured bool `json:"featured"`
}
func (c *APIClient) GetGuideFeeds(category, tag string, limit int) ([]GuideFeed, error) {
url := fmt.Sprintf("%s/api/guide?category=%s&tag=%s&limit=%d",
c.baseURL, category, tag, limit)
resp, _ := http.Get(url)
defer resp.Body.Close()
var result struct {
Feeds []GuideFeed `json:"feeds"`
}
json.NewDecoder(resp.Body).Decode(&result)
return result.Feeds, nil
}
Get Feed Details
func (c *APIClient) GetFeedDetails(id string, includePosts bool) (*GuideFeed, error) {
url := fmt.Sprintf("%s/api/guide/%s?includePosts=%v", c.baseURL, id, includePosts)
resp, _ := http.Get(url)
defer resp.Body.Close()
var result struct {
Feed GuideFeed `json:"feed"`
}
json.NewDecoder(resp.Body).Decode(&result)
return &result.Feed, nil
}
UI Components (Bubble Tea)
Main App Model
package app
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/viewport"
)
type View int
const (
FeedsView View = iota
ArticlesView
ReaderView
CategoriesView
)
type Model struct {
currentView View
// Components
feedList list.Model
articleList list.Model
readerView viewport.Model
// Data
feeds []Feed
articles []Article
currentFeed *Feed
currentArticle *Article
// State
width, height int
err error
}
func (m Model) Init() tea.Cmd {
return tea.Batch(
loadFeedsCmd(),
tea.EnterAltScreen,
)
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "1":
m.currentView = FeedsView
case "2":
m.currentView = ArticlesView
case "3":
m.currentView = ReaderView
case "enter":
return m.handleEnter()
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.readerView.Width = msg.Width - 4
m.readerView.Height = msg.Height - 10
}
return m, nil
}
func (m Model) View() string {
switch m.currentView {
case FeedsView:
return renderFeedsView(m)
case ArticlesView:
return renderArticlesView(m)
case ReaderView:
return renderReaderView(m)
default:
return "Unknown view"
}
}
Three-Panel Layout (like Google Reader)
┌─────────────────────────────────────────────────────────────────────┐
│ Readstr CLI [Help: ?] │
├───────────────┬───────────────────────┬─────────────────────────────┤
│ Feeds │ Articles │ Reader │
│ │ │ │
│ 📰 All Items │ ▸ Article Title 1 │ # Article Title │
│ │ Published: 2h ago │ │
│ 📁 Bitcoin │ │ Lorem ipsum dolor sit │
│ ├─ Blog 1 │ ▸ Article Title 2 │ amet, consectetur... │
│ └─ Blog 2 │ Published: 5h ago │ │
│ │ │ - Bullet point 1 │
│ 📁 Tech │ ▸ Article Title 3 │ - Bullet point 2 │
│ ├─ HN │ Published: 1d ago │ │
│ └─ Blog 3 │ │ More content here... │
│ │ [10 unread] │ │
│ ⭐ Favorites │ │ │
│ │ │ │
│ │ │ [Space: Scroll Down] │
│ [2 feeds] │ [Page 1/3] │ [j/k: Navigate] │
└───────────────┴───────────────────────┴─────────────────────────────┘
│ q: Quit 1: Feeds 2: Articles 3: Reader a: Add Feed r: Refresh │
└─────────────────────────────────────────────────────────────────────┘
Styles (Lip Gloss)
package styles
import "github.com/charmbracelet/lipgloss"
var (
// Colors
PrimaryColor = lipgloss.Color("#7C3AED")
SecondaryColor = lipgloss.Color("#6B7280")
AccentColor = lipgloss.Color("#3B82F6")
ErrorColor = lipgloss.Color("#EF4444")
// Component styles
TitleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(PrimaryColor).
PaddingLeft(2)
FeedItemStyle = lipgloss.NewStyle().
PaddingLeft(2).
Foreground(lipgloss.Color("#1F2937"))
SelectedStyle = lipgloss.NewStyle().
Bold(true).
Foreground(AccentColor).
Background(lipgloss.Color("#EFF6FF")).
PaddingLeft(2)
UnreadBadge = lipgloss.NewStyle().
Background(AccentColor).
Foreground(lipgloss.Color("#FFFFFF")).
Padding(0, 1).
Bold(true)
PanelBorder = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#E5E7EB"))
)
Key Features to Implement
Phase 1: Core Functionality
- ✅ SQLite database setup
- ✅ RSS feed fetching and parsing
- ✅ Nostr feed fetching (NIP-23)
- ✅ Three-panel TUI layout
- ✅ Navigate between feeds, articles, and reader
- ✅ Mark articles as read
- ✅ Basic keyboard shortcuts
Phase 2: Organization
- ✅ Tag support (assign multiple tags to feeds)
- ✅ Category support (folders with icons)
- ✅ Filter by tags/categories
- ✅ Favorites system
Phase 3: Sync
- ✅ Nostr subscription sync (kind 30404)
- ✅ Export subscriptions to Nostr
- ✅ Import subscriptions from Nostr
- ✅ Merge local and remote subscriptions
- ✅ Conflict resolution
Phase 4: Polish
- ✅ Search across articles
- ✅ Video feed support (YouTube, Rumble)
- ✅ Markdown rendering with syntax highlighting
- ✅ Configuration file (~/.config/readstr/config.yaml)
- ✅ Color themes
- ✅ Guide directory integration
Configuration File
Location: ~/.config/readstr/config.yaml
# User Identity
nostr:
npub: "npub1..." # Your Nostr public key
nsec: "nsec1..." # Your Nostr private key (optional, for signing)
relays:
- "wss://relay.damus.io"
- "wss://nos.lol"
- "wss://relay.snort.social"
# Sync Settings
sync:
enabled: true
auto_sync_interval: 15m # Auto-sync every 15 minutes
# Reading Preferences
reading:
mark_read_behavior: "on-open" # "on-open" | "after-10s" | "never"
organization_mode: "tags" # "tags" | "categories"
# Display
display:
theme: "default" # "default" | "dark" | "light"
feed_list_width: 30
article_list_width: 40
# Database
database:
path: "~/.local/share/readstr/feeds.db"
Commands and Key Bindings
Global
q/Ctrl+C- Quit?- Show help1- Switch to Feeds view2- Switch to Articles view3- Switch to Reader viewTab- Cycle between panels/- Search
Feed List
↑/k- Previous feed↓/j- Next feedEnter- Open feed (show articles)a- Add new feedd- Delete feede- Edit feed (tags/category)r- Refresh feedR- Refresh all feedss- Sync with Nostrt- Filter by tagsc- Filter by category
Article List
↑/k- Previous article↓/j- Next articleEnter- Open articlem- Mark as read/unreadM- Mark all as readf- Toggle favoriteo- Open in browserSpace- Preview article
Reader
↑/k- Scroll up↓/j- Scroll downg- Go to topG- Go to bottomSpace- Page downb- Page upo- Open in browserf- Toggle favoriten- Next articlep- Previous article
CLI Commands
# Basic usage
readstr # Launch TUI
# Feed management
readstr add <url> # Add RSS feed
readstr add -n <npub> # Add Nostr feed
readstr list # List all feeds
readstr remove <id> # Remove feed
readstr refresh # Refresh all feeds
readstr refresh <id> # Refresh specific feed
# Sync
readstr sync export # Export subscriptions to Nostr
readstr sync import # Import subscriptions from Nostr
readstr sync status # Show sync status
# Articles
readstr articles # List recent articles
readstr read <id> # Read article in terminal
readstr mark-read <id> # Mark article as read
readstr favorite <id> # Add to favorites
readstr search <query> # Search articles
# Organization
readstr tags # List all tags
readstr categories # List all categories
readstr tag <feed-id> <tag> # Add tag to feed
# Guide
readstr guide list # Browse guide directory
readstr guide search <query> # Search guide
readstr guide add <id> # Subscribe to guide feed
# Config
readstr config init # Create config file
readstr config show # Show current config
readstr config set <key> <value>
Testing Checklist
RSS Feeds
- Add RSS feed by URL
- Fetch and parse articles
- Mark articles as read
- Refresh feeds
- Handle feed errors gracefully
Nostr Feeds
- Add Nostr user by npub
- Fetch NIP-23 long-form articles
- Display author profiles
- Handle relay errors
Sync
- Export subscriptions to Nostr
- Import subscriptions from Nostr
- Merge local and remote lists
- Handle conflicts (deleted feeds)
- Auto-sync on interval
UI
- Three-panel layout renders correctly
- Keyboard navigation works
- Resize handling
- Color themes apply
- Help screen displays
Organization
- Assign tags to feeds
- Create categories
- Filter by tags
- Filter by categories
- View unread counts per tag/category
Resources
Libraries
- Bubble Tea: https://github.com/charmbracelet/bubbletea
- Lip Gloss: https://github.com/charmbracelet/lipgloss
- Bubbles: https://github.com/charmbracelet/bubbles
- Glamour: https://github.com/charmbracelet/glamour
- go-nostr: https://github.com/nbd-wtf/go-nostr
- gofeed: https://github.com/mmcdole/gofeed
Nostr Specifications
- NIP-01 (Events): https://github.com/nostr-protocol/nips/blob/master/01.md
- NIP-07 (Browser Extension): https://github.com/nostr-protocol/nips/blob/master/07.md
- NIP-23 (Long-form Content): https://github.com/nostr-protocol/nips/blob/master/23.md
- NIP-33 (Replaceable Events): https://github.com/nostr-protocol/nips/blob/master/33.md
API Documentation
- Guide API: https://readstr.privkey.io/api/guide/docs
- Subscription Sync: See
SUBSCRIPTION_SYNC.mdin web repo
Example: Complete Feed Fetcher
package feed
import (
"time"
"github.com/mmcdole/gofeed"
)
type Fetcher struct {
rssParser *gofeed.Parser
nostrClient *nostr.Client
}
func (f *Fetcher) FetchFeed(feed *Feed) ([]*FeedItem, error) {
switch feed.Type {
case "RSS":
return f.fetchRSS(feed)
case "NOSTR":
return f.fetchNostr(feed)
default:
return nil, fmt.Errorf("unknown feed type: %s", feed.Type)
}
}
func (f *Fetcher) fetchRSS(feed *Feed) ([]*FeedItem, error) {
parsed, err := f.rssParser.ParseURL(feed.URL)
if err != nil {
return nil, err
}
items := []*FeedItem{}
for _, item := range parsed.Items {
feedItem := &FeedItem{
FeedID: feed.ID,
GUID: item.GUID,
Title: item.Title,
Content: getContent(item),
URL: item.Link,
Author: getAuthor(item),
PublishedAt: *item.PublishedParsed,
}
items = append(items, feedItem)
}
return items, nil
}
func (f *Fetcher) fetchNostr(feed *Feed) ([]*FeedItem, error) {
since := time.Now().AddDate(0, 0, -30) // Last 30 days
return f.nostrClient.FetchUserArticles(feed.NPUB, since)
}
Next Steps
-
Initialize Go Module
go mod init github.com/yourusername/readstr-cli go get github.com/charmbracelet/bubbletea go get github.com/charmbracelet/lipgloss go get github.com/charmbracelet/bubbles go get github.com/nbd-wtf/go-nostr go get github.com/mmcdole/gofeed go get github.com/mattn/go-sqlite3 -
Set up SQLite Database
- Create database schema
- Write CRUD operations
- Add migrations support
-
Build Basic TUI
- Implement main Bubble Tea model
- Create three-panel layout
- Add keyboard navigation
-
Implement Feed Fetching
- RSS parser
- Nostr client
- Background refresh
-
Add Sync
- Nostr event signing
- Subscription sync
- Merge logic
-
Polish
- Add color themes
- Implement search
- Write documentation
Contact
For questions or collaboration:
- Web App: https://readstr.privkey.io
- GitHub: https://github.com/privkeyio/readstr
- Nostr: npub13hyx3qsqk3r7ctjqrr49uskut4yqjsxt8uvu4rekr55p08wyhf0qq90nt7
Happy coding! 🚀
PayWithFlash Integration
Readstr uses PayWithFlash for Bitcoin Lightning subscription payments.
Setup
1. Create a Subscription Plan on Flash
- Go to Flash Dashboard
- Navigate to New Subs > Create a Subscription Plan
- Configure your plan:
- Name: Readstr Reader
- Price: 1750 sats
- Billing Period: Monthly
- Trial Period: 7 days
2. Configure Webhook
- Click “Use Advanced Webhook Features” checkbox
- Set Webhook URL:
https://readstr.privkey.io/api/webhooks/flash - Save and copy your Subscription Key (you’ll need this for JWT verification)
- Copy your Checkout Page URL
3. Set Environment Variables
Add to your .env.production file:
# Flash Configuration
NEXT_PUBLIC_FLASH_CHECKOUT_URL="https://app.paywithflash.com/subscription-page?flashId=3238"
FLASH_SUBSCRIPTION_KEY="97ee08713791860b5c849941dd596d0208928ab7ae9a468cec993504b06daca4"
Note: These values are already configured in the production environment.
How It Works
Payment Flow
- User subscribes: User clicks “Subscribe” button → tRPC creates checkout URL with pre-filled npub
- Pre-filled form: Flash checkout page auto-fills user’s Nostr npub (no manual entry needed)
- Payment completed: Flash sends webhook to
/api/webhooks/flash - Webhook verified: JWT token is verified using
FLASH_SUBSCRIPTION_KEY - Subscription activated: User record created/updated in database with 30-day access
Pre-Filled User Details
When authenticated users click subscribe, their Nostr npub is automatically passed to Flash using Base64-encoded JSON:
{
"npub": "npub1...",
"external_uuid": "npub1...", // Same as npub for user mapping
"is_verified": true // Skip verification (already logged in)
}
This saves users from manually entering their npub and ensures the webhook links to the correct account.
Webhook Events
Flash sends the following events:
user_signed_up: New subscription created → Status: ACTIVErenewal_successful: Monthly renewal succeeded → Extend 30 daysrenewal_failed: Payment failed → Status: PAST_DUEuser_paused_subscription: User paused → Status: CANCELLEDuser_cancelled_subscription: User cancelled → Status: CANCELLED
Security
- All webhooks include a JWT token in the
Authorizationheader - Token is verified using HS256 algorithm with your subscription key
- Tokens expire after 1 hour
- Invalid tokens are rejected with 401 status
Testing
Test Webhook Locally
-
Use ngrok to expose your local server:
ngrok http 3000 -
Update Flash webhook URL to:
https://your-ngrok-url.ngrok.io/api/webhooks/flash -
Make a test payment on Flash checkout page
-
Check your server logs for webhook processing
Manual Webhook Testing
# Get a valid JWT token from Flash (check their test tools)
curl -X POST https://readstr.privkey.io/api/webhooks/flash \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"eventType": {"id": "1", "name": "user_signed_up"},
"data": {
"npub": "npub1test...",
"email": "test@example.com"
}
}'
Database Schema
Subscriptions are stored in the UserSubscription table:
model UserSubscription {
userPubkey String @unique // Nostr npub or external_uuid
status SubscriptionStatus
trialEndsAt DateTime
subscriptionEndsAt DateTime?
cancelledAt DateTime?
...
}
Subscription Statuses
TRIAL: In 7-day free trial (not used with Flash, goes directly to ACTIVE)ACTIVE: Paid and active subscriptionPAST_DUE: Payment failed, grace periodCANCELLED: User cancelled, valid until end dateEXPIRED: Subscription expired
Troubleshooting
Webhooks not received
- Check Flash dashboard for webhook delivery logs
- Verify webhook URL is accessible publicly (use curl)
- Check server logs for errors
- Ensure HTTPS is enabled (Flash requires HTTPS)
JWT verification fails
- Verify
FLASH_SUBSCRIPTION_KEYmatches the key in Flash dashboard - Check token hasn’t expired (1 hour validity)
- Ensure secret key has no extra whitespace
User not getting access
- Check if webhook was received (server logs)
- Verify user identifier (npub/external_uuid) is correct
- Check database for UserSubscription record
- Verify subscription status is ACTIVE