# 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 unique entries, each max 2048 characters, parseable by new URL(), no control characters, and not using the schemes data:, file:, javascript:, blob: or vbscript:). One `url` tag per entry in the event. Clients try in order. announceService additionally requires at least one public URL (see Behaviour).
  - 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 label (e.g. "sats", "USD"). Free-form: any non-blank string up to 32 characters without control characters. It is not checked against a list of allowed values, and clients decide which units they understand.

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). An http/https URL is public if isPrivateHost does not match its hostname; .onion hostnames and non-http(s) schemes count as public. Other entries may be private
  - 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, `[402-announce] No relays accepted the event` is logged and the promise still resolves with the eventId of the signed event, which no relay stored. The Announcement does not report how many relays accepted it, so the warning is the only signal. To confirm storage, use buildAnnounceEvent and publish yourself, checking each publish result
  - Secret key bytes are zeroised after signing
  - Warns on insecure ws:// relay URLs

**Rejects** (announceService is async, so these are promise rejections, not synchronous 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
  - If a service URL does not parse, or any buildAnnounceEvent validation fails (see below)

### 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.

Throws synchronously on invalid input. Secret key bytes are zeroised after signing (via try/finally). Unlike announceService(), this function does not enforce the "at least one public URL" rule, as 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. Takes a bare hostname, not a URL.

Returns true for: localhost and *.localhost; IPv6 ::1, ::, fe80::/10, fc00::/7, Teredo 2001:0000::/32, and IPv4-mapped, IPv4-compatible or 6to4 forms embedding a private IPv4; IPv4 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 240.0.0.0/4; and octal, hexadecimal, single-integer or two/three-part shorthand IPv4 spellings. It inspects the string only (no DNS resolution) and does not flag multicast such as 224.0.0.1.

### 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 (data:, file:, javascript:, blob: and vbscript: schemes rejected, duplicates rejected), max 10 service URLs, max 50 relays, JSON schema depth capped at 10, control characters rejected in all string fields.

## Error Handling

Invalid configuration is rejected: buildAnnounceEvent throws synchronously, and announceService (an async function) rejects its promise. Cases:
- 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 even though no relay accepted it (the Announcement carries no accepted count)

## 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. All six functions are exported from the `402-announce/storage` subpath and from the package root. The subpath carries only these six functions and the ParsedStorageAnnouncement type; the STORAGE_* constants and the storage description types (StorageServiceDescription, StorageReadOptions and others) are root-only. The package is ESM-only (no `require` export condition). Options are StorageReadOptions = { now: number (Unix seconds); maxAgeSeconds?: number; clockSkewSeconds?: number }. Invalid input throws Error('Invalid storage service profile.'); selectStorageAnnouncements skips invalid records instead but still throws for invalid options or more than 512 records.
- 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, announcement: ParsedStorageAnnouncement, selectedOrigin, options)
  -> fresh HTTP snapshot matched to signed service identity, listing and origin.

Flow: build (unsigned) -> external signer -> accept -> select -> bind to endpoint. Runnable example, with a throwaway local key standing in for the external signer:

```typescript
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'
import {
  acceptSignedStorageAnnouncement,
  buildStorageAnnouncement,
  selectStorageAnnouncements,
  validateStorageEndpointBinding,
} from '402-announce/storage'

const now = Math.floor(Date.now() / 1000)
const signerKey = generateSecretKey() // stand-in for an external signer

const description = {
  profile: 'forgesworn-storage-service-v1',
  service_pubkey: getPublicKey(signerKey),
  identifier: 'open-shelter-node',
  name: 'Open shelter node',
  about: 'Best-effort mirror shelter for signed blobs.',
  origins: ['https://storage.example/'],
  max_blob_bytes: 1073741824,
  observed_at: now,
  expires_at: now + 7200,
  state: 'published',
  checkout: null,
  services: [{
    id: 'open-shelter',
    type: 'free-mirror',
    revision: 1,
    availability: 'accepting',
    operation: 'mirror',
    authorisation: 'bud-11',
    retention: 'best-effort',
    eviction_policy: 'Guest mirrors can be evicted under storage pressure.',
  }],
}

const requested = buildStorageAnnouncement(description)               // 1. unsigned template
const signed = finalizeEvent(requested, signerKey)                    // 2. external signature
const accepted = acceptSignedStorageAnnouncement(signed, requested, { now }) // 3. exact, current
const listings = selectStorageAnnouncements([accepted.event], { now })       // 4. newest per listing
const fetched = description // stands in for a freshly fetched GET /services/v1 body
const current = validateStorageEndpointBinding(fetched, listings[0], 'https://storage.example/', { now }) // 5.
```

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.
