What is a DVM?
A Data Vending Machine (DVM) is a Nostr service that takes a request event, does some work, and returns a result — all over relays. NIP-90 defines the protocol. You publish a kind 5xxx event with your request, the DVM processes it, and publishes a kind 6xxx event with the result.
The payment flow is built in. The DVM can require a Lightning invoice before it does the work. No accounts, no API keys, no subscriptions — just sats over Lightning.
The Architecture
A text-generation DVM handles three things:
- Subscribe to kind 5050 (text generation request) events from relays
- Charge via Lightning invoice (or offer a free tier)
- Respond with a kind 6050 (text generation result) event
Status updates (kind 7000) keep the requester informed: "payment required", "processing", "error".
Setting Up the Subscription
Using go-nostr, subscribe to multiple relays simultaneously:
pool := nostr.NewSimplePool(ctx)
since := nostr.Timestamp(time.Now().Add(-60 * time.Second).Unix())
filters := nostr.Filters{{
Kinds: []int{5050},
Since: &since,
}}
for ev := range pool.SubMany(ctx, relays, filters) {
go handleRequest(ctx, ev.Event, sk, pub, cfg, pool)
}
SubMany subscribes to all relays and deduplicates events by ID. The Since filter avoids backfilling old requests. Each event gets its own goroutine for concurrent processing.
Important: SubMany delivers the same event once per relay. Use a sync.Map to deduplicate:
var processed sync.Map
func handleRequest(ctx context.Context, ev *nostr.Event, ...) {
if _, loaded := processed.LoadOrStore(ev.ID, true); loaded {
return // already handled
}
// process the event
}
Extracting the Prompt
NIP-90 requests carry input in i tags:
prompt := ""
for _, tag := range ev.Tags {
if tag[0] == "i" && len(tag) > 1 {
prompt = tag[1]
break
}
}
if prompt == "" {
sendStatus(ctx, pool, sk, pub, ev, "error", "No prompt found")
return
}
The Free Tier Pattern
Offering 3 free queries per user eliminates the cold start problem — users try your DVM before paying. Track usage with a simple file-backed map:
type freeTracker struct {
mu sync.Mutex
used map[string]int
path string
}
func (ft *freeTracker) hasFree(pubkey string) bool {
ft.mu.Lock()
defer ft.mu.Unlock()
return ft.used[pubkey] < 3
}
No database needed. Append each pubkey to a file on disk. Load it on startup. The free tier converts curious users into paying customers.
Lightning Invoicing via LNbits
When a user needs to pay, create an invoice and send a payment-required status:
func createInvoice(cfg Config, sats int, memo string) (string, string, error) {
body, _ := json.Marshal(map[string]interface{}{
"out": false,
"amount": sats,
"memo": memo,
})
req, _ := http.NewRequest("POST",
cfg.LNbitsURL+"/api/v1/payments", bytes.NewReader(body))
req.Header.Set("X-Api-Key", cfg.LNbitsKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
return result["payment_request"].(string),
result["payment_hash"].(string), nil
}
Then poll for payment confirmation:
func pollForPayment(ctx context.Context, cfg Config, hash string) bool {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
timeout := time.After(10 * time.Minute)
for {
select {
case <-ticker.C:
if checkPaid(cfg, hash) {
return true
}
case <-timeout:
return false
case <-ctx.Done():
return false
}
}
}
10 minutes is generous. Most Lightning payments settle in under 5 seconds. The long timeout handles edge cases like mobile wallets that need user confirmation.
Sending Results
The result is a kind 6050 event tagged with the original request:
func sendResult(ctx context.Context, pool *nostr.SimplePool,
sk, pub string, req *nostr.Event, content string) {
ev := nostr.Event{
Kind: 6050,
PubKey: pub,
CreatedAt: nostr.Now(),
Content: content,
Tags: nostr.Tags{
{"e", req.ID},
{"p", req.PubKey},
{"request", req.String()},
},
}
ev.Sign(sk)
for _, url := range relays {
relay, err := pool.EnsureRelay(url)
if err != nil {
continue
}
relay.Publish(ctx, ev)
}
}
The e tag links the result to the request. The p tag targets the requester. The request tag embeds the original event for verifiability.
NIP-89 Announcement
Make your DVM discoverable by publishing a kind 31990 announcement:
content := '{"name":"My Text DVM","about":"AI text generation. 3 free, then 10 sats."}'
ev := nostr.Event{
Kind: 31990,
PubKey: pub,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"d", "my-textgen-dvm"},
{"k", "5050"},
{"t", "text-generation"},
},
Content: content,
}
ev.Sign(sk)
// publish to relays
Clients like Vendata and nostr.band index these announcements. Users discover your DVM without visiting a website.
Running It
The DVM is a single Go binary. Run it anywhere:
NOSTR_NSEC=nsec1... \
LNBITS_KEY=your-api-key \
GROQ_API_KEY=your-groq-key \
go run main.go
Hosting cost: $0. A VPS, a Raspberry Pi, or any server that can run Go. The relay network handles discovery and delivery. LNbits handles payments. Groq handles inference (free tier). Your running cost is literally zero.
What I Learned Running This in Production
- NIP-90 amounts are in millisats. A
bidtag of "10000" means 10 sats, not 10,000. - nos.lol requires 32-bit PoW for publishing. Mine the nonce tag asynchronously at startup.
- Deduplication is mandatory.
SubMany()delivers the same event from each relay. Without dedup, you'll create 3 invoices for 1 request. - Users discover but don't pay. The free tier solves cold start but conversion is the real challenge. The ratio of discovery to payment is roughly 10:1.
- Skip old events. On restart, filter out events older than 5 minutes to avoid processing stale requests.
The full source code is available on GitHub. The text DVM has been running in production since January 2026, serving Llama 3.3 70B responses at 10 sats each.
Max builds Lightning-powered tools. More at maximumsats.com.