# 402-announce — Full Reference

> Announce HTTP 402 services on Nostr for decentralised discovery.
> Supports both L402 and x402 payment protocols.
> Source: https://github.com/forgesworn/402-announce

## Overview

402-announce is a TypeScript library that publishes kind 31402 Nostr events describing paid HTTP APIs. These events enable decentralised service discovery — AI agents and Nostr clients can find, evaluate, and pay for APIs without a central registry.

The library handles event construction, signing, and multi-relay publication. Secret key material is zeroised after use.

## Kind 31402 — disambiguation

This library uses kind 31402 for **L402/x402 service announcements** (paid API discovery). This is distinct from any SARA (Simple Autonomous Revenue Agreement) proposals that may also reference kind 31402. The 402-announce definition: a parameterised replaceable event (NIP-33) where pubkey + d-tag uniquely identifies one service listing.

## Installation

```bash
npm install 402-announce
```

Requires Node.js >= 18. ESM only. Single runtime dependency: nostr-tools.

## API Reference

### announceService(config: AnnounceConfig): Promise<Announcement>

High-level function: builds a kind 31402 event, signs it, connects to relays in parallel, and publishes.

**Parameters:**

AnnounceConfig:
  - secretKey (string, required): 64-character hex-encoded Nostr secret key
  - relays (string[], required): Relay URLs (must start with wss:// or ws://). Maximum 50.
  - identifier (string, required): Unique listing ID, used as the Nostr `d` tag. Same pubkey + identifier = same listing.
  - name (string, required): Human-readable service name
  - urls (string[], required): Transport endpoint URLs (1–10 entries, each max 2048 characters). One `url` tag per entry in the event. Clients try in order.
  - about (string, required): Short description of the service
  - pricing (PricingDef[], required): Per-capability pricing. Each entry: { capability, price, currency }
  - paymentMethods (string[][], required): Accepted payment methods. Each entry is [rail, ...params]
  - picture (string, optional): Icon URL
  - topics (string[], optional): Topic tags for search/filtering
  - capabilities (CapabilityDef[], optional): Capability details stored in event content
  - version (string, optional): Service version stored in event content

PricingDef:
  - capability (string): Capability name (e.g. "get_joke", "chat", "inference")
  - price (number): Price amount (integer or fractional)
  - currency (string): Currency unit (e.g. "sats", "USD")

CapabilityDef:
  - name (string): Capability name
  - description (string): Human-readable description
  - endpoint (string, optional): Endpoint path or full URL for this capability (e.g. '/api/joke')
  - schema (unknown, optional): JSON Schema describing the capability's input parameters (max depth 10)
  - outputSchema (unknown, optional): JSON Schema describing the capability's output (max depth 10)

**Returns** Announcement:
  - eventId (string): The published Nostr event ID
  - pubkey (string): Nostr pubkey derived from the secret key
  - close(): void — Disconnect from all connected relays (synchronous; call on shutdown to avoid WebSocket leaks)

**Behaviour:**
  - Validates secretKey (must be 64-char hex) and relay URLs (must be wss:// or ws://)
  - Rejects relay URLs pointing to private/loopback addresses (SSRF prevention)
  - Rejects service configs where all urls are private/loopback (at least one public URL required)
  - Connects to all relays in parallel with a 10-second timeout per relay
  - Individual relay failures are logged as warnings but do not reject the promise
  - If no relay accepts the event, a warning is emitted (but the promise still resolves)
  - Secret key bytes are zeroised after signing
  - Warns on insecure ws:// relay URLs

**Throws:**
  - If secretKey is not a 64-character hex string
  - If relays array is empty or contains more than 50 entries
  - If any relay URL does not start with wss:// or ws://
  - If any relay URL points to a private/loopback address
  - If all service URLs are private/loopback addresses

### buildAnnounceEvent(secretKeyHex: string, config: Omit<AnnounceConfig, 'relays'>): VerifiedEvent

Lower-level function: builds and signs the kind 31402 event without publishing. Use this if you manage relay connections yourself.

**Returns** a nostr-tools VerifiedEvent ready for relay publication.

Secret key bytes are zeroised after signing (via try/finally). Unlike announceService(), this function does not enforce the "at least one public URL" rule — it is a pure serialisation utility.

### isPrivateHost(hostname: string): boolean

Utility export. Returns true if the hostname is a private, loopback, link-local, or reserved address (IPv4 or IPv6). Used internally for SSRF prevention; exported for consumers who need the same guard.

### Constants

- L402_ANNOUNCE_KIND = 31402 — The Nostr event kind for service announcements

### Exported types

- AnnounceConfig — Configuration for announceService()
- Announcement — Handle returned by announceService()
- PricingDef — Pricing for a specific capability
- CapabilityDef — Capability with description, optional endpoint and schemas

## Event Format Specification

### Kind 31402 — Parameterised Replaceable Event

A kind 31402 event is a Nostr parameterised replaceable event (NIP-33). This means:
- The combination of pubkey + kind + d-tag uniquely identifies a listing
- Publishing a new event with the same pubkey + kind + d-tag replaces the previous one
- This allows services to update their announcements (e.g. change pricing) without creating duplicates

### Tag structure

Required tags:
  ["d", "<identifier>"]                                    — Unique listing identifier
  ["name", "<service name>"]                               — Human-readable name
  ["url", "<endpoint URL>"]                                — HTTP endpoint (one tag per URL; 1–10 allowed)
  ["about", "<description>"]                               — Short description
  ["pmi", "<rail>", ...params]                             — Payment method identifier (one tag per method)
  ["price", "<capability>", "<amount>", "<currency>"]      — Pricing (one tag per capability)

Optional tags:
  ["t", "<topic>"]                — Topic tag for filtering (one tag per topic)
  ["picture", "<icon URL>"]       — Service icon

### Content field

JSON object with optional fields:
{
  "capabilities": [
    {
      "name": "get_joke",
      "description": "Returns a random joke",
      "endpoint": "/api/joke",
      "schema": { "type": "object", "properties": { "category": { "type": "string" } } },
      "outputSchema": { "type": "object", "properties": { "joke": { "type": "string" } } }
    }
  ],
  "version": "1.0.0"
}

### Example event

{
  "kind": 31402,
  "pubkey": "ab1234...ef5678",
  "created_at": 1710000000,
  "tags": [
    ["d", "jokes-api"],
    ["name", "Jokes API"],
    ["url", "https://jokes.example.com"],
    ["url", "http://jokesxyz...onion"],
    ["about", "A joke-telling service behind an L402 paywall"],
    ["pmi", "l402", "lightning"],
    ["pmi", "cashu"],
    ["price", "get_joke", "1", "sats"],
    ["t", "comedy"],
    ["t", "ai"]
  ],
  "content": "{\"capabilities\":[{\"name\":\"get_joke\",\"description\":\"Returns a random joke\"}],\"version\":\"1.0.0\"}",
  "id": "...",
  "sig": "..."
}

## Payment Method Identifiers

Each `paymentMethods` entry is an array where the first element is the rail identifier and subsequent elements are rail-specific parameters. Known rails:

  l402      — L402 protocol (Lightning BOLT-11 invoices). Example: ['l402', 'lightning']
  cashu     — Cashu ecash. Example: ['cashu']
  xcashu    — Cashu ecash via NUT-24 (X-Cashu header). Example: ['xcashu']
  lnurlcash — LUD-25 bearer note in an X-LNURLcash header. Example: ['lnurlcash', 'mint.example']
  x402      — x402 stablecoin payments. Example: ['x402', 'base', 'usdc', '0xabc...']
  payment   — IETF Payment protocol. Example: ['payment', 'lightning']

## Multiple URLs vs Multiple Events

**Multiple URLs in one event** — use `urls: ['...', '...']` when the URLs represent the **same service** on different transports (clearnet, Tor, Handshake). Pricing, credentials, and macaroon signing key are identical. Clients try in order and use whichever they can reach. This is for censorship resistance and redundancy.

**Separate kind 31402 events** — publish a new event (different `identifier`) when you have **genuinely different services**: different pricing tiers, different capabilities, or services that operate independently.

Rule: same service + different network paths → one event with multiple `url` tags. Different services → separate events.

## Integration Patterns

### Producer: toll-booth + 402-announce

```typescript
import { createTollBooth } from 'toll-booth'
import { announceService } from '402-announce'

// 1. Protect your API with a paywall
const booth = createTollBooth({ /* payment config */ })
app.use('/api', booth.middleware)

// 2. Announce it on Nostr
const handle = await announceService({
  secretKey: process.env.NOSTR_SECRET_KEY,
  relays: ['wss://relay.damus.io'],
  identifier: 'my-api',
  name: 'My API',
  urls: ['https://api.example.com'],
  about: 'A paid API service',
  pricing: [{ capability: 'query', price: 10, currency: 'sats' }],
  paymentMethods: [['l402', 'lightning']],
})

// 3. Clean up on shutdown
process.on('SIGTERM', () => handle.close())
```

### Producer shortcut: toll-booth-announce

If you're using toll-booth, [toll-booth-announce](https://github.com/forgesworn/toll-booth-announce) maps booth config to 402-announce automatically — pricing and payment methods are derived from your booth config:

```typescript
import { announce } from 'toll-booth-announce'

const handle = await announce(boothConfig, {
  secretKey: process.env.NOSTR_SECRET_KEY,
  relays: ['wss://relay.damus.io'],
  urls: ['https://api.example.com'],
  about: 'My toll-booth API',
})
```

### Consumer: 402-mcp

AI agents use [402-mcp](https://github.com/forgesworn/402-mcp) to discover and consume announced services:

1. 402-mcp subscribes to kind 31402 events on configured relays
2. Agent searches for services by topic, capability, or payment method
3. 402-mcp handles L402 payment negotiation automatically
4. Agent receives the API response

The agent never needs to know about Nostr or payment protocols — 402-mcp abstracts it all.

### Updating a listing

Because kind 31402 is a parameterised replaceable event, calling announceService() again with the same identifier and pubkey replaces the previous listing. Use this to update pricing, add capabilities, or change the endpoint URL.

### Verify on relays

```bash
# Using nak (Nostr Army Knife)
nak req -k 31402 wss://relay.damus.io

# Filter by topic
nak req -k 31402 -t t=ai wss://relay.damus.io
```

### Live dashboard

[402.pub](https://402.pub) shows every service announcing on the network in real time.

## Security Considerations

- **Key zeroisation**: Secret key bytes (Uint8Array) are filled with zeros immediately after signing, in both announceService() and buildAnnounceEvent(). This limits the window during which key material exists in memory.
- **Key handling**: Pass the secret key as a hex string. The library converts to bytes, signs, and zeroises. Never log or persist the key yourself. Note: JavaScript strings are immutable and cannot be erased — minimise the lifetime of the string in your application.
- **SSRF prevention**: Relay URLs and service URLs are checked against private/loopback address patterns. DNS rebinding (hostname resolves to private IP at connection time) is not caught — deploy behind network-level egress controls in production.
- **Relay trust**: Relays are untrusted infrastructure. Events are cryptographically signed — relay operators cannot forge announcements. However, relays can choose not to store or serve your events.
- **Connection timeouts**: Relay connections time out after 10 seconds to prevent indefinite hangs.
- **Graceful degradation**: If some relays fail, the event is still published to the others. A warning is emitted only if no relay accepts the event.
- **Input limits**: URLs max 2048 chars, max 10 service URLs, max 50 relays, JSON schema depth capped at 10, control characters rejected in all string fields.

## Error Handling

The library throws synchronously for invalid configuration:
- secretKey not 64-char hex → Error
- Empty relays array or more than 50 relays → Error
- Invalid relay URL (not wss:// or ws://) → Error
- Relay URL with credentials → Error
- Relay URL pointing to private/loopback address → Error
- All service URLs pointing to private/loopback addresses → Error

Relay-level failures during publication are handled gracefully:
- Connection timeouts → warning logged, other relays still tried
- Publish rejections → warning logged, other relays still tried
- All relays fail → warning logged, promise resolves with eventId from the signed event

## Source

Repository: https://github.com/forgesworn/402-announce
npm: https://www.npmjs.com/package/402-announce
Live dashboard: https://402.pub
Licence: MIT
Runtime dependencies: nostr-tools and @noble/hashes (v3 onion checksum validation)

## Experimental Public Storage Profile

The separate `forgesworn-storage-service-v1` contract supports public paid storage
packages, free signed mirror shelter, and combined listings. It is a documented
kind 31402 extension, not a ratified NIP/BUD. Full reference:
docs/storage-service-v1.md. Schema: schemas/storage-service-v1.schema.json.
Signed synthetic fixtures and selection/rejection cases:
fixtures/storage-v1/manifest.json. These files ship in the npm package.

Storage APIs take no private key and do no network access:
- buildStorageAnnouncement(description, createdAt?) -> unsigned EventTemplate.
- acceptSignedStorageAnnouncement(event, requestedTemplate, { now }) -> exact,
  valid, current external signature; refuses signer rewrites.
- parseStorageAnnouncement(event, { now, maxAgeSeconds?, clockSkewSeconds? }) ->
  verified event, description and current/expired/stale freshness.
- selectStorageAnnouncements(events, options) -> latest valid records per
  pubkey + d, INCLUDING expired/withdrawn records to keep as session watermarks.
- validateStorageDescription(value) -> strict copied public HTTP description.
- validateStorageEndpointBinding(value, chosenListing, selectedOrigin, options)
  -> fresh HTTP snapshot matched to signed service identity, listing and origin.

Select newest before filtering expiry/availability. Keep the lower event ID on
equal timestamps. A newer full/withdrawn/expired record must not resurrect an
older offer. Coarse availability is self-reported admission, not a reservation
or uptime proof. Free shelter is mirror-only, BUD-11-authorised, best effort and
evictable. Paid prices are for the whole capacity/duration/delivery package.
Paid checkout is available only at its explicit HTTPS origin; a mixed onion
listing does not imply onion checkout. PMI `wildbloom-checkout` describes its
actual order flow; never advertise it as L402 or generic payment headers.

The legacy paid-API APIs are unchanged and still require payment methods.
Native publication, /services/v1 serving, consumer UI and automatic purchasing
are separate stages and are not implemented by these pure helpers.
