# FunnelCake > FunnelCake is the public API and Nostr relay backend for divine.video, a human-made decentralized short-form video app built on Nostr. Use FunnelCake when building clients, integrations, moderation tools, analytics workflows, or agents that need to understand Divine video events and public read APIs. Production endpoints: - WebSocket relay: `wss://relay.divine.video` - REST API: `https://api.divine.video/api/` - GraphQL beta: `https://relay.divine.video/graphql` Important integration rules: - Use WebSocket relay operations for standard Nostr publishing and subscriptions. - Use REST for analytics, search, bulk reads, RSS feeds, notifications, and management flows. - Use GraphQL only for read-only composed product queries. - Authenticated HTTP requests use NIP-98 where documented. - Always use full 64-character hex Nostr pubkeys and event IDs. Do not truncate IDs in fixtures, logs, or examples. - Do not generate or publish AI-made videos. Divine is for human-created short-form video. ## Docs - [LLM API Guide](/docs/llm-guide.md): Complete FunnelCake guide for coding agents, including REST, WebSocket, GraphQL, auth, pagination, and event examples. - [GraphQL API Guide](/docs/graphql.md): Read-only GraphQL beta schema conventions and query examples. - [OpenAPI JSON](https://api.divine.video/openapi.json): Machine-readable REST API schema. - [Swagger UI](https://api.divine.video/swagger-ui/): Human-readable REST API reference. - [Complete FunnelCake agent docs](/llms-full.txt): Single-file Markdown export for larger-context agents. ## Related Sites - [diVine app](https://divine.video/): User-facing short-form video app. - [diVine app llms.txt](https://divine.video/llms.txt): End-user focused agent guide. --- # FunnelCake API Guide for LLM Coding Agents This document provides comprehensive information for AI coding agents to interact with FunnelCake - a Nostr video relay and analytics platform. Use this as a reference when building applications that integrate with divine.video. ## Overview **FunnelCake** is a specialized Nostr relay for video content. It consists of: 1. **WebSocket Relay** - Standard NIP-01 Nostr protocol for real-time event publishing and subscriptions 2. **REST API** - HTTP endpoints for analytics, search, and bulk operations 3. **GraphQL API** - Read-only beta endpoint for composed product-facing queries ### Base URLs | Environment | WebSocket | REST API | GraphQL | |------------|-----------|----------|---------| | Production | `wss://relay.divine.video` | `https://api.divine.video/api/` | `https://relay.divine.video/graphql` | | Staging | `wss://relay.staging.divine.video` | `https://relay.staging.divine.video/api/` | `https://relay.staging.divine.video/graphql` | Legacy staging alias: `relay.staging.dvines.org` currently points at the same service, but `relay.staging.divine.video` is the preferred hostname for docs and client configuration. Production compatibility aliases: `https://relay.divine.video/api/` (uncached backup) and `https://relay.dvines.org/api/` (legacy alias). --- ## When to Use WebSocket vs REST vs GraphQL ### Use WebSocket (NIP-01) for: - **Publishing events** - Standard Nostr event publishing via WebSocket - **Real-time subscriptions** - Live updates for new videos, comments, reactions - **Signature verification** - When you need cryptographically verifiable events - **Standard Nostr client operations** - Following the protocol spec ### Use REST API for: - **Publishing events** - `POST /api/events` with NIP-98 auth (alternative to WebSocket) - **Analytics and stats** - Engagement metrics, view counts, trending scores - **Bulk operations** - Fetching multiple videos/users in one request - **Search** - Full-text search and hashtag discovery - **Calendar reads** - NIP-52 event, collection, RSVP, and related-video lookups without GraphQL - **Pre-computed data** - Denormalized views like `author_avatar`, social counts - **Pagination** - Two surfaces: legacy `/api/...` paths use `offset` / `before` and return bare arrays or endpoint-specific wrappers (`{ followers, total }`, `{ comments, total }`, etc.); versioned `/api/v2/...` paths return a unified `{ data, pagination: { next_cursor, has_more } }` envelope. See the [Pagination](#pagination) section below for the endpoint-by-endpoint split. ### Use GraphQL for: - **Composed reads** - Fetching user, feed, search, and nested author data in one request - **First-party app screens** - Product-facing views that need only part of a larger object graph - **Read-only discovery** - Search, hashtags, categories, leaderboards, and recommendations - **Bounded event graph reads** - Generic event lookups, exact counts, and reverse traversal via `referencedBy` - **Calendar reads** - NIP-52 calendar lookup, collections, discovery, and RSVP-aware views GraphQL v1 is read-only beta. Use REST or WebSocket for writes, admin flows, moderation, privacy, notifications, and relay management. ### GraphQL calendar support The GraphQL schema exposes NIP-52 calendar read models with these root fields: - `calendarEvent(coordinate: String!)` - `calendarCollection(coordinate: String!)` - `calendarEvents(first:, after:, order:, where:)` Calendar coordinates use the standard NIP-52 address form `kind:pubkey:d-tag`, for example: ```text 31923::town-hall ``` Useful `calendarEvents.where` filters: - `hostPubkey` - `topic` - `near` for geohash-prefix matching - `startsBetween { from to }` - `attending` to require an accepted RSVP by a given pubkey - `includePast` `calendarEvent` also exposes nested `videos`, `rsvps`, `rsvpCounts`, and `parentCalendars` fields for product screens that need the event plus related content in one round trip. ### REST calendar support The REST API exposes the same NIP-52 read models with: - `GET /api/calendar/events/{coordinate}` - `GET /api/calendar/collections/{coordinate}` - `GET /api/calendar/events` - `GET /api/calendar/events/{coordinate}/rsvps` - `GET /api/calendar/events/{coordinate}/rsvp-counts` - `GET /api/calendar/events/{coordinate}/videos` - `GET /api/calendar/events/{coordinate}/parent-calendars` Useful query params on `GET /api/calendar/events`: - `host_pubkey` - `topic` - `near` - `starts_after` - `starts_before` - `attending` - `include_past` - `order=start_asc|start_desc|created_desc` - `limit` - `offset` --- ## Nostr Protocol Basics ### Event Structure Every Nostr event has this structure: ```json { "id": "64-char-hex-sha256-of-serialized-event", "pubkey": "64-char-hex-public-key", "created_at": 1700000000, "kind": 34236, "tags": [ ["d", "unique-identifier"], ["title", "My Video"], ["t", "hashtag"] ], "content": "Description text", "sig": "128-char-hex-schnorr-signature" } ``` ### Computing Event ID The `id` is SHA256 of the serialized event array (NOT the full object): ```javascript // Serialization format for ID computation: const serialized = JSON.stringify([ 0, // Always 0 event.pubkey, // 64-char hex pubkey event.created_at, // Unix timestamp (number) event.kind, // Kind number event.tags, // Tags array event.content // Content string ]); const id = sha256(serialized); // Returns 64-char hex ``` **Important:** Serialize the entire array as compact JSON (no extra whitespace), using your language's standard JSON serializer. Do not hand-roll `content` escaping: its quotes, backslashes, and control characters must use their canonical JSON escapes, while other characters must remain verbatim. ### Key Formats: Hex vs Bech32 (npub/nsec) **All API interactions use hex format (64 characters).** Users often share keys in bech32 format (`npub1...`, `nsec1...`). Convert before API use: ```javascript // npub1qqqqqq... -> hex pubkey // Use a library like nostr-tools: import { nip19 } from 'nostr-tools'; const { data: hexPubkey } = nip19.decode('npub1...'); // hexPubkey is now "abc123..." (64 chars) // Or for event IDs: const { data: hexId } = nip19.decode('note1...'); // For nevent: const { data: { id: hexId } } = nip19.decode('nevent1...'); ``` If you receive an `npub` or `note` from a user, **always decode to hex** before using with the API. ### Supported Event Kinds FunnelCake accepts these event kinds: | Kind | Name | NIP | Purpose | |------|------|-----|---------| | 0 | Profile | NIP-01 | User metadata (name, avatar, bio) | | 3 | Contact List | NIP-02 | Follow lists | | 4 | Encrypted Direct Message | NIP-04 | Legacy encrypted private messages | | 5 | Deletion | NIP-09 | Delete events | | 7 | Reaction | NIP-25 | Likes/reactions | | 16 | Generic Repost | NIP-18 | Reshare any event | | 62 | Relay Set | NIP-62 | Relay-scoped list events and vanish coordination | | 1111 | Comment | NIP-22 | Comments on videos | | 1063 | Audio File Metadata | NIP-94 | Audio metadata and file attachments | | 1984 | Report | NIP-56 | Moderation and abuse reports | | 1985 | Label | NIP-32 | Content and moderation labels | | 10000 | Mute List | NIP-51 | Blocked content (primary block/mute surface) | | 10003 | Bookmark List | NIP-51 | Saved bookmarks | | 24242 | Blossom Auth | Blossom | File operation auth | | 27235 | HTTP Auth | NIP-98 | Signed HTTP authorization | | 30000 | Categorized People List | NIP-51 | **Legacy** — block lists (`d=block`) and other people lists; superseded by kind 10000, see [#496](https://github.com/divinevideo/divine-funnelcake/issues/496) | | 30003 | Bookmark Set | NIP-51 | Categorized bookmarks | | 30005 | Curation Set | NIP-51 | Video playlists | | 30023 | Long-form Content | NIP-23 | Addressable long-form articles | | 31922 | Calendar Event Date | NIP-52 | Calendar event date | | 31923 | Calendar Event Time | NIP-52 | Calendar event time | | 31924 | Calendar Collection | NIP-52 | Calendar collection | | 31925 | Calendar RSVP | NIP-52 | RSVP response events | | 34235 | Horizontal Video | NIP-71 | Long-form video | | 34236 | Vertical Video | NIP-71 | Short-form video | | 34237 | Video Chapters | NIP-71 | Chapter markers | ### Replaceable Events - **Kinds 0, 3, 10000-19999**: Replaceable by pubkey - latest wins - **Kinds 30000-39999**: Parameterized replaceable - requires `d` tag - Address format: `kind:pubkey:d-tag` (e.g., `34236:abc123:my-video`) - The relay REJECTS parameterized replaceable events missing a `d` tag --- ## WebSocket Protocol (NIP-01) ### Connection ```javascript const ws = new WebSocket('wss://relay.divine.video'); ``` ### Client Messages #### EVENT - Publish an event ```json ["EVENT", ] ``` #### REQ - Subscribe to events ```json ["REQ", "", , , ...] ``` #### CLOSE - Close subscription ```json ["CLOSE", ""] ``` #### COUNT - Count matching events (NIP-45) ```json ["COUNT", "", ] ``` ### Relay Messages #### EVENT - Receive matching events ```json ["EVENT", "", ] ``` #### OK - Event publish result ```json ["OK", "", true, ""] ["OK", "", false, "blocked: pubkey is banned"] ``` #### EOSE - End of stored events ```json ["EOSE", ""] ``` **After EOSE:** The subscription stays open! You'll continue receiving new EVENT messages for any newly published events that match your filter. EOSE just means "I've sent you all the historical/stored events." #### COUNT - Count response ```json ["COUNT", "", {"count": 42}] ``` #### CLOSED - Subscription closed ```json ["CLOSED", "", "rate limited"] ``` ### Filter Structure ```json { "ids": ["", ...], "authors": ["", ...], "kinds": [34235, 34236], "#e": ["", ...], "#p": ["", ...], "#t": ["hashtag", ...], "#d": ["identifier", ...], "since": 1700000000, "until": 1700100000, "limit": 50, "search": "full text query" } ``` ### GraphQL Event Filter Shape The GraphQL event surface mirrors the safe subset of NIP-01 filters: - `events(filter: ...)` - `count(filter: ...)` - `referencedBy(...)` Supported filter fields: - `ids` - `authors` - `kinds` - `since` - `until` - `search` - `tagFilters: [{ name, values }]` Important GraphQL caveat: - `search` is supported for the first page of `events(...)` - `search + after` is rejected because relevance ordering is not cursor-stable **Notes:** - `ids` and `authors` must be exact 64-char hex strings - Tag filters use `#` prefix (e.g., `#e`, `#p`, `#t`, `#d`) - `search` enables NIP-50 full-text search ### Example: Block/Mute List Queries (`kind 10000`, primary) Kind 10000 (NIP-51 mute list) is the primary block/mute surface. It is a plain replaceable event keyed by `pubkey + kind` — no `d` tag involved. Restore a user's own mute list: ```json ["REQ", "mutelist-self", { "kinds": [10000], "authors": [""] }] ``` Find mute-list events that mention a user in `p` tags: ```json ["REQ", "mutelist-mentioned", { "kinds": [10000], "#p": [""] }] ``` ### Legacy: Block List Queries (`kind 30000`, `d=block`) > **Legacy.** Superseded by kind 10000 above — see > [#496](https://github.com/divinevideo/divine-funnelcake/issues/496). > Runtime support is kept only so migrated clients can dual-read the legacy > shape for users who haven't migrated yet; new integrations should use > kind 10000 instead. Kind 30000 is a general "categorized people list" kind > shared with other list types (e.g. follow sets such as `d=top8`), so an > unscoped `authors + kinds:[30000]` query is over-broad — it would also > return the caller's non-block lists — and has been removed from this > guide. Always scope kind 30000 queries with `#d: ["block"]` when precision > matters. Find block-list events that mention a user in `p` tags (dual-read only): ```json ["REQ", "blocklist-mentioned", { "kinds": [30000], "#d": ["block"], "#p": [""] }] ``` Narrow to block-list identifiers with `d=block`: ```json ["REQ", "blocklist-d-tag", { "kinds": [30000], "#d": ["block"] }] ``` ### Example: Subscribe to Recent Videos ```json ["REQ", "videos-feed", { "kinds": [34235, 34236], "limit": 50 }] ``` ### Example: Subscribe to Comments on a Video ```json ["REQ", "video-comments", { "kinds": [1111], "#E": [""] }] ``` --- ## Video Events (NIP-71) ### Structure ```json { "kind": 34236, "tags": [ ["d", "unique-video-id"], ["title", "My Amazing Video"], ["imeta", "url https://example.com/video.mp4", "m video/mp4", "dim 1080x1920", "duration 30", "x sha256hash", "image https://example.com/thumb.jpg", "size 5242880", "blurhash LEHV6nWB2yk8pyoJadR*.7kCMdnj" ], ["t", "nostr"], ["t", "bitcoin"] ], "content": "Video description here" } ``` ### Required Tags | Tag | Description | |-----|-------------| | `d` | Unique identifier (REQUIRED for all parameterized replaceable) | | `title` | Video title | | Source | Either `imeta` with `url` or legacy `url` tag | | Thumbnail | Either `imeta` with `image`, or `thumb`/`thumbnail`/`image` tag | ### imeta Tag Format The `imeta` tag packs media metadata in a single tag: ``` ["imeta", "url ", # Required "m ", # video/mp4, video/webm, etc. "dim x", # Dimensions "duration ", # Duration in seconds "x ", # File hash "image ", # Thumbnail "size ", # File size "blurhash ", # Placeholder blur "alt " # Accessibility text ] ``` --- ## Comment Events (NIP-22) Comments use uppercase tags for root scope and lowercase for parent: ```json { "kind": 1111, "tags": [ ["E", ""], ["K", "34236"], ["P", ""], ["e", ""], ["k", "34236"], ["p", ""] ], "content": "Great video!" } ``` **Required tags:** - `K` - Root event kind (the video) - `k` - Parent event kind (video for top-level, 1111 for reply) - `E` - Root event ID (always the video) - `e` - Parent event ID (video or comment being replied to) --- ## Reactions (NIP-25) Like/react to a video: ```json { "kind": 7, "tags": [ ["e", ""], ["p", ""], ["k", "34236"] ], "content": "+" } ``` **Content values:** - `+` or empty - Like - `-` - Dislike - Emoji - Custom reaction (e.g., `🔥`, `❤️`) --- ## Reposts (NIP-18) ### Simple Repost (Kind 6) Funnelcake does **not** treat kind `6` as part of its supported relay allowlist. If you need repost support on this relay, use kind `16`. Kind `6` is shown here only for protocol context on other relays and applies to kind `1` text notes only: ```json { "kind": 6, "tags": [ ["e", "", ""], ["p", ""] ], "content": "" } ``` ### Generic Repost (Kind 16) For any event kind including videos: ```json { "kind": 16, "tags": [ ["e", "", ""], ["p", ""], ["k", "34236"] ], "content": "" } ``` **Use kind 16 for reposting videos on Funnelcake.** --- ## Deletion (NIP-09) Delete your own events: ```json { "kind": 5, "tags": [ ["e", ""], ["e", ""], ["a", "34236::"] ], "content": "optional reason" } ``` - Use `e` tags for specific event IDs - Use `a` tags for addressable events (kind:pubkey:d-tag format) - You can only delete events YOU authored - Relay will reject deletion of others' events --- ## Profile Events (Kind 0) User metadata is stored in kind 0 with JSON content: ```json { "kind": 0, "tags": [], "content": "{\"name\":\"username\",\"display_name\":\"Display Name\",\"picture\":\"https://example.com/avatar.jpg\",\"banner\":\"https://example.com/banner.jpg\",\"about\":\"Bio text\",\"nip05\":\"user@domain.com\",\"lud16\":\"user@getalby.com\",\"website\":\"https://example.com\"}" } ``` **Profile JSON fields:** | Field | Description | |-------|-------------| | `name` | Username (lowercase, no spaces) | | `display_name` | Display name | | `picture` | Avatar URL | | `banner` | Banner image URL | | `about` | Bio/description | | `nip05` | NIP-05 identifier (user@domain.com) | | `lud16` | Lightning address | | `website` | Website URL | --- ## Contact Lists (Kind 3) Follow list stored in tags: ```json { "kind": 3, "tags": [ ["p", "", "", ""], ["p", "", "", ""], ["p", ""] ], "content": "" } ``` Each `p` tag is a followed pubkey. Relay URL and petname are optional. --- ## Addressable Event References For parameterized replaceable events (kinds 30000-39999), use the `a` tag: ```json ["a", "::", ""] ``` Example - reference a video in a playlist: ```json ["a", "34236:abc123def456...:my-video-id", "wss://relay.divine.video"] ``` **Querying by address:** ```json ["REQ", "sub-id", { "#a": ["34236:abc123def456...:my-video-id"] }] ``` --- ## Curation Sets / Playlists (Kind 30005) Create a video playlist: ```json { "kind": 30005, "tags": [ ["d", "my-playlist"], ["title", "My Favorite Videos"], ["description", "A collection of great content"], ["image", "https://example.com/playlist-cover.jpg"], ["a", "34236:pubkey1:video-d-tag-1", "wss://relay.divine.video"], ["a", "34236:pubkey2:video-d-tag-2", "wss://relay.divine.video"], ["e", "video-event-id-3"] ], "content": "" } ``` **Tags:** - `d` - Playlist identifier (REQUIRED) - `title` or `name` - Playlist title - `description` - Playlist description - `image` - Cover image URL - `a` tags - References to videos by address (preferred) - `e` tags - References to videos by event ID --- ## Bookmarks (NIP-51) ### Bookmark List (Kind 10003) Simple list of bookmarked content: ```json { "kind": 10003, "tags": [ ["e", ""], ["a", "34236::"] ], "content": "" } ``` ### Bookmark Set (Kind 30003) Categorized bookmarks with a name: ```json { "kind": 30003, "tags": [ ["d", "watch-later"], ["title", "Watch Later"], ["e", ""], ["a", "34236::"] ], "content": "" } ``` --- ## REST API Reference ### Request Headers For all POST requests: ```http Content-Type: application/json ``` ### Timestamps **Important:** The REST API returns timestamps in two formats depending on the endpoint: - Most list endpoints: ISO 8601 string (`"2024-01-15T10:30:00Z"`) - Raw event endpoints (`/api/event/{id}`, `/api/videos/{id}`): Unix timestamp integer Always check the response structure. When in doubt, the `created_at` field can be parsed as either. ### Videos #### GET /api/videos List videos with sorting and filtering. **Returns a bare JSON array** (legacy v1 shape). For the unified `{ data, pagination }` envelope use `GET /api/v2/videos`. **Query Parameters:** | Param | Type | Description | |-------|------|-------------| | `sort` | string | `recent` (default), `trending`, `popular`, `loops`, `likes`, `comments`, `engagement`, `published`, `watching`. `popular` and `trending` rank by view-time activity; `watching` sorts by 24-hour CDN view count regardless of upload date. | | `period` | string | For `sort=popular` or `sort=trending`: `now`, `today`, `week`, `month`, or `all`. Defaults are `today` for `popular` and `now` for `trending`. Periods mean watched during that period, not posted during that period. | | `kind` | number | Filter by kind (34235 or 34236) | | `limit` | number | Max results (1-100, default 25) | | `offset` | number | Skip N results for pagination (use with `limit`) | | `tag` | string | Filter by hashtag | | `before` | number | Unix timestamp for pagination | | `after` | number | Videos created after timestamp | | `platform` | string | Filter by platform. Allowed values: `vine` for classic Vine imports. Mutually exclusive with `exclude_platform`. | | `exclude_platform` | string | Exclude videos from a platform. Allowed values: `vine` for native-only / non-Vine feeds. Mutually exclusive with `platform`. | | `has_embedded_stats` | boolean | Only videos with loop counts | | `d_tag` | string | Filter by exact NIP-33 `d` tag | | `classic` | boolean | Shortcut for `before` + sort by loops | | `nsfw` | string | Legacy: set to `show` to include default-hidden moderation labels. Superseded by `content_safety`. | | `content_safety` | string | Content safety preset: `default` (hide default-hidden moderation labels), `adult` (show adult content, still hide gore/spam), `open` (show all legal content). CSAM/illegal always blocked. | | `exclude_label` | string | Comma-separated labels to exclude (max 5). Example: `gore,spam` | | `label` | string | Filter by any effective content label (e.g., `safe`, `nsfw`, `violence`, `topic:music`) | | `category` | string | Convenience alias for `label=topic:` | **Note on sort options:** - `recent` - Newest first (default) - `trending` - Dynamic current-attention surface, defaulting to `period=now` - `popular` - View-time popularity with optional `period=now|today|week|month|all` - `loops` - Sorted by embedded loop count (primarily Vine archive data) - `likes`, `comments`, `engagement`, `published` - Alternate ranking modes for discovery/creator views Future UI tabs can map directly to `period=now`, `today`, `week`, `month`, and `all`. **Platform filter recipes:** - Classics-only feed: `GET /api/videos?classic=true&platform=vine&limit=50` - Personalized classic Vine discovery: `GET /api/videos?classic=true&platform=vine&sort=trending&limit=50` - Classic popular by Divine-era watch activity: `GET /api/v2/videos?sort=popular&period=week&platform=vine` - Native-only popular feed: `GET /api/v2/videos?sort=popular&period=now&exclude_platform=vine` - Native-only trending feed: `GET /api/v2/videos?sort=trending&exclude_platform=vine` `platform` and `exclude_platform` are mutually exclusive. The only accepted platform filter value today is `vine`; native divine content has an empty platform tag, so use `exclude_platform=vine` for native-only results rather than `platform=divine`. For requests with caller identity (`X-Pubkey` or valid NIP-98), plain classic Vine discovery can be personalized internally through Gorse. The client still calls `/api/videos`; the Gorse category `classic_vine` is an internal backend filter and should not be sent as a public `/api/videos?category=` value. **Note:** When `sort=loops`, the response includes additional fields like `embedded_likes`, `sha256`, `platform`, etc., plus `reactions`, `comments`, `reposts`, and `engagement_score`. When filters like `before`, `after`, `platform`, `exclude_platform`, or `has_embedded_stats` are used, these also use the extended response format. All response formats now include `loops` (safe loop count). **Response:** ```json [ { "id": "64-char-hex", "pubkey": "64-char-hex", "created_at": "2024-01-15T10:30:00Z", "kind": 34236, "d_tag": "my-video", "title": "Video Title", "content": "Description", "thumbnail": "https://...", "video_url": "https://...", "reactions": 150, "comments": 42, "reposts": 10, "engagement_score": 254, "loops": 5000, "views": 12345, "author_name": "CreatorName", "author_avatar": "https://...", "published_at": 1705314600, "language": "en", "text_track_ref": "39307:pubkey:d-tag", "text_track_content": "WEBVTT...", "verification": "human", "pgp_fingerprint": "ABCD1234", "c2pa_manifest_id": "manifest-123", "platform": "vine", "client": "divine-web", "trending_score": 0.85, "content_labels": ["safe"] } ] ``` **Note on labels:** `content_labels` contains discovery/classifier labels such as `topic:music`. `moderation_labels` contains resolved trust-and-safety labels for the active moderation profile, such as `nudity`, `porn`, `graphic-media`, `spam`, or `scam`. See [Content Moderation](#content-moderation) below for the stable moderation vocabulary, merge rules, and server/client responsibility split. **Note on moderation metadata:** `moderation_status`, `ai_score`, `ai_source`, and `ai_checked_at` are omitted unless an active trusted label with confidence at least `0.8` is in the moderation vocabulary. Retracted and lower-confidence labels remain available to moderation tooling but do not populate these public fields. Classifier labels (`topic:`, `object:`, `activity:`, ...) never populate them. When present: ```json { "moderation_status": "ai-generated", "ai_source": "content_labels", "ai_checked_at": 1705314700 } ``` - `moderation_status` is the newest moderation-vocabulary label on the video, in canonical form (`nudity`, `graphic-media`, `ai-generated`, `deepfake`, `synthetic-media`, ...). - `ai_source` is `content_labels` only when a label canonicalizes to `ai-generated`, `deepfake`, or `synthetic-media`. - `ai_score` is reserved and currently never set: no trusted labeler writes a score into a label value. - `ai_checked_at` is the unix time of the newest label that set `ai_source`. **Note on safety filtering:** `/api/videos` supports `content_safety`, legacy `nsfw=show`, `exclude_label` (max 5), and `moderation_profile=default|strict|user`. `moderation_profile=user` requires NIP-98 authentication. `content_safety=adult` includes age-restricted moderation labels while still applying hidden-label policy; `content_safety=open` shows all legal content. `illegal` and `csam` remain blocked regardless of parameters. **Note on `views`:** The `views` field is the total view count from the view tracking system. For replaceable videos, app-facing totals follow the stable logical video identity `(pubkey, kind, d_tag)`, so metadata edits do not reset counts. It is included in all video list endpoints (`/api/videos`, `/api/users/{pubkey}/videos`, `/api/search`, `/api/videos/bulk`, etc.). **Note on `loops`:** The `loops` field uses safe loop logic: prefers computed loops from view tracking, falls back to capped embedded loops for pre-2025 videos (Vine imports), and 0 for newer videos without view data. For replaceable videos, computed loop totals also follow logical video identity across metadata edits. **Note on compact list/feed metadata fields:** Compact video-card endpoints currently include `/api/v2/videos` popular/list paths and `/api/users/{pubkey}/feed`. These rows include: - badge-critical extracted fields (`verification`, `pgp_fingerprint`, `c2pa_manifest_id`, `platform`, `client`, `text_track_ref`, `text_track_content`), - public moderation fields (`moderation_status`, `ai_score`, `ai_source`, `ai_checked_at`), - `language` from NIP-32 self-labeling tags. These compact endpoints omit full `tags`, `proofmode`, and `device_attestation` blobs. The following feed may include an optional `proof` summary for badge state; other compact list endpoints may omit `proof` until that summary is wired there. Fetch `/api/videos/{id}` when a client needs raw event tags or full proof material for a verification-detail view. #### GET /api/v2/videos Same query parameters and item schema as `GET /api/videos`, wrapped in the unified pagination envelope. Recommended for new partner integrations that want a single predictable list shape. **Additional query parameters (vs v1):** - `cursor` — opaque pagination cursor returned by the previous page - `offset` and `before` are still accepted as legacy aliases (v1 callers can flip the path without changing query params) **Response:** ```json { "data": [ /* same item shape as GET /api/videos */ ], "pagination": { "next_cursor": "o:25", "has_more": true } } ``` The item shape inside `data` is identical to the bare-array entries in `GET /api/videos`. See [Pagination](#pagination) for client-side patterns. #### GET /api/videos/events List videos with full verifiable Nostr events. **Query Parameters:** | Param | Type | Description | |-------|------|-------------| | `sort` | string | `recent` (default), `trending`, `popular`, or `loops` | | `kind` | number | Filter by kind (34235 or 34236) | | `limit` | number | Max results (1-100, default 50) | | `before` | number | Unix timestamp for cursor-based pagination | | `d_tag` | string | Filter by exact NIP-33 `d` tag | | `tag` | string | Filter by hashtag (`t` tag) | | `platform` | string | Filter by platform (e.g., `vine`) | | `classic` | boolean | If true and `sort` is omitted, defaults to `loops` | **Response:** ```json { "videos": [ { "event": { /* full Nostr event */ }, "stats": { "reactions": 150, "comments": 42, "reposts": 10, "engagement_score": 254, "author_name": "CreatorName", "author_avatar": "https://...", "text_track_ref": "", "text_track_content": "", "content_labels": ["safe"] } } ], "next_cursor": "1700000000", "has_more": true } ``` #### GET /api/videos/{id} Get a single video with event and stats. The `{id}` parameter accepts either: - **Event ID**: 64-character hex Nostr event ID (fast primary key lookup) - **D-tag**: Addressable event identifier that stays stable across edits (fallback lookup) This means shared video URLs survive edits — when a user edits video metadata, the event ID changes but the d-tag stays the same. Caching: responses are CDN-cached for up to 1 hour. Moderation actions (ban, quarantine, delete, edit, label updates, vanish) purge the CDN eagerly; browsers revalidate within 15 seconds. **Response:** ```json { "event": { "id": "64-char-hex", "pubkey": "64-char-hex", "created_at": 1700000000, "kind": 34236, "content": "Description", "tags": [["d", "..."], ["text-track", "https://...", "subtitles", "en", "text/vtt"], ...], "sig": "128-char-hex" }, "stats": { "reactions": 150, "comments": 42, "reposts": 10, "engagement_score": 254, "author_name": "CreatorName", "author_avatar": "https://...", "trending_score": 0.85, "embedded_loops": 0, "text_track_ref": "https://cdn.example.com/subtitles/en.vtt", "text_track_content": "WEBVTT\n\n00:00:01.000 --> 00:00:04.000\nHello world", "content_labels": ["safe"] } } ``` - `text_track_ref`: URL from the video event's `text-track` tag (empty string if none) - `text_track_content`: WebVTT subtitle content from associated Kind 39307 event (empty string if none) #### GET /api/videos/{id}/stats Get engagement and view stats (reactions, comments, reposts, views, loops). Even though this endpoint is addressed by event ID, `views` and computed `loops` follow the logical replaceable-video identity `(pubkey, kind, d_tag)` when available. **Response:** ```json { "id": "64-char-hex", "pubkey": "64-char-hex", "created_at": "2024-01-15T10:30:00Z", "kind": 34236, "d_tag": "my-video", "title": "Video Title", "content": "Description", "thumbnail": "https://...", "video_url": "https://...", "reactions": 150, "comments": 42, "reposts": 10, "engagement_score": 254, "loops": 5000, "views": 12345, "author_name": "CreatorName", "author_avatar": "https://...", "published_at": 1705314600, "expiration_at": null, "language": "en", "text_track_ref": "39307:pubkey:d-tag", "text_track_content": "WEBVTT...", "tags": [["d", "my-video"], ["verification", "human"]], "verification": "human", "proofmode": "proofmode-v1", "device_attestation": "apple", "pgp_fingerprint": "ABCD1234", "c2pa_manifest_id": "manifest-123", "platform": "vine", "client": "divine-web", "content_labels": ["safe"] } ``` #### GET /api/videos/{id}/views Get view statistics. For replaceable videos, these totals carry across metadata edits using logical video identity. If the same `(pubkey, kind, d_tag)` later points at different media (`sha256` or canonical `video_url`), historical carry-over is disabled until that anomaly is reviewed. **Response:** ```json { "views": 1000, "unique_viewers": 750, "total_watch_time": 45000, "avg_completion": 0.75 } ``` #### GET /api/videos/{id}/analytics Get post-detail analytics for a single video. **Query:** `window` - Time window: `7d`, `28d`, `30d` (default), `90d`, or `all` **Response:** ```json { "id": "64-char-hex", "window": "30d", "summary": { "views": 12345, "unique_viewers": 6789, "reactions": 150, "comments": 42, "reposts": 10, "shares": null, "saves": null, "followers_gained": null, "profile_visits": null, "total_watch_seconds": 45000.5, "avg_watch_seconds": 12.3, "completion_rate": 0.74, "rewatch_rate": 0.11, "engagement_rate": 0.016, "has_view_data": true }, "traffic_sources": { "for_you": 0.42, "following": 0.18, "profile": 0.12, "search": 0.09, "hashtag": 0.07, "external": 0.08, "shares": 0.04 }, "retention_curve": [ { "second": 0, "viewer_pct": 1.0 }, { "second": 3, "viewer_pct": 0.86 } ] } ``` **Notes:** - `window` defaults to `30d` - Summary fields may be `null` when view-tracking data is unavailable for the selected period #### GET /api/videos/{id}/comments Get NIP-22 comments (Kind 1111) for a video. Accepts event ID or d-tag. **Returns the legacy `{ comments, total }` wrapper** — for the unified envelope use `GET /api/v2/videos/{id}/comments`. **Parameters:** | Param | Type | Default | Description | |-------|------|---------|-------------| | `sort` | string | `newest` | Sort order: `newest` or `oldest` | | `limit` | number | `25` | Max comments (max 100) | | `offset` | number | `0` | Pagination offset | **Response:** ```json { "comments": [ { "id": "abc123...", "pubkey": "def456...", "created_at": 1700000000, "kind": 1111, "content": "Great video!", "sig": "sig_hex...", "tags": [["E","video_id"],["K","34236"]], "reply_to_event_id": null, "reply_to_pubkey": null, "author_name": "Alice", "author_avatar": "https://example.com/alice.jpg" } ], "total": 42 } ``` **Notes:** - `reply_to_event_id` / `reply_to_pubkey` are null for top-level comments, populated for replies - Author metadata comes from Kind 0 profiles - 30s cache #### GET /api/v2/videos/{id}/comments Same query parameters and per-comment item shape as `GET /api/videos/{id}/comments`, wrapped in the unified pagination envelope. Adds `cursor` as the canonical pagination param; `offset` is still accepted as an alias. **Response:** ```json { "data": [ /* same item shape as GET /api/videos/{id}/comments */ ], "pagination": { "next_cursor": "o:25", "has_more": true } } ``` Note: the v2 envelope does not surface `total` — v1 callers that need an exact comment count should keep using `GET /api/videos/{id}/comments`. --- #### GET /api/videos/{id}/live-views Server-Sent Events (SSE) stream for real-time view count updates. **Content-Type:** `text/event-stream` This endpoint streams updates every ~2.5 seconds. No authentication required. **Event format:** ``` event: view_update data: {"views": 1500, "loops": 1125.5, "viewers_now": 12} ``` **Fields:** | Field | Type | Description | |-------|------|-------------| | `views` | number | Total view count | | `loops` | float | Total computed loops (from watch time) | | `viewers_now` | number | Currently active viewers (5-min TTL window) | `views` and `loops` follow logical video identity across replaceable-video edits. `viewers_now` remains tied to the active event-version stream. **Usage:** ```javascript const eventSource = new EventSource('/api/videos/' + videoId + '/live-views'); eventSource.addEventListener('view_update', (event) => { const stats = JSON.parse(event.data); updateViewCounter(stats.views); updateLoopCounter(stats.loops); updateActiveViewers(stats.viewers_now); }); eventSource.addEventListener('error', (event) => { console.warn('Live view error:', event.data); }); // Clean up when done eventSource.close(); ``` **Note:** The stream includes a keep-alive ping every 15 seconds. Connection will stay open indefinitely until the client closes it. --- #### POST /api/videos/stats/bulk Get stats for multiple videos. Maximum 100 event IDs per request. `views` and computed `loops` in the response follow logical replaceable-video identity, so callers can continue to request the current event ID after metadata edits without losing totals. **Request:** ```json { "event_ids": ["id1", "id2", "id3"] } ``` **Response:** ```json { "stats": { "id1": { "reactions": 150, "comments": 42, "reposts": 10, "engagement_score": 254, "views": 1234, "loops": 987.5, "embedded_loops": 5000 }, "id2": { "reactions": 80, "comments": 15, "reposts": 5, "engagement_score": 120, "views": 0, "loops": 0.0, "embedded_loops": null } }, "missing": ["id3"] } ``` #### POST /api/videos/bulk Get multiple videos with full stats. **Request:** ```json { "event_ids": ["id1", "id2"], "from_event": { "kind": 30005, "pubkey": "curator-pubkey", "d_tag": "playlist-name" } } ``` **Response:** ```json { "videos": [ { "id": "64-char-hex", "pubkey": "64-char-hex", "created_at": "2024-01-15T10:30:00Z", "kind": 34236, "d_tag": "my-video", "title": "Video Title", "content": "Description", "thumbnail": "https://...", "video_url": "https://...", "reactions": 150, "comments": 42, "reposts": 10, "engagement_score": 254, "author_name": "CreatorName", "author_avatar": "https://...", "content_labels": ["safe"] } ], "missing": ["id-that-wasnt-found"], "source_event_id": "64-char-hex" } ``` #### POST /api/videos/revisions/bulk Resolve videos to every event ID sharing their NIP-33 coordinate. Maximum 100 event IDs per request. Editing an addressable video (kind 34235/34236) preserves its `(kind, pubkey, d-tag)` coordinate but mints a **new event ID**. Because NIP-25 makes the `e` tag a MUST and the `a` tag only a SHOULD, a kind 7 reaction may name a single concrete revision — so after an edit it matches neither the current event ID nor the coordinate. Call this before matching engagement by event ID. There is no relay-side equivalent: a coordinate-shaped `REQ` returns only the newest revision, and an `until` walk returns nothing because the coordinate collapses before `until` is applied. ```json POST /api/videos/revisions/bulk { "event_ids": ["64-char-hex", "64-char-hex"] } ``` Response: ```json { "revisions": { "64-char-hex": ["older-revision-id", "64-char-hex"] }, "missing": ["id-that-is-not-an-addressable-video"] } ``` Each list includes the requested ID itself and is ordered oldest revision first. Responses are capped at 100 revisions per video; if a coordinate has more than 100 revisions, the requested ID is kept and the remaining slots contain the newest other revisions. IDs that resolve to no addressable video are reported in `missing` rather than mapped to an empty list, so you can tell "no such video" apart from "one revision". Unlike `POST /api/videos/stats/bulk`, this endpoint applies no visibility filter: a deleted, banned, quarantined, label-blocked, expired, or suspended-author video still resolves to its revision IDs. Only event IDs are returned. `GET /api/event/{id}` re-applies most of those checks on follow-up — banned, NIP-09-deleted, addressable-deleted and quarantined IDs, plus banned, suspended or vanished authors — but it does **not** re-apply the expiry or label-based checks at all, so those fetch successfully. Treat the addressable-delete check as best-effort for superseded revision IDs too: the set behind it is built without `FINAL`, so its coverage of those IDs follows background-merge state. **Errors:** `400` if `event_ids` is empty, has more than 100 entries, or contains any value that is not a 64-character hex string. Validation is all-or-nothing: one malformed ID rejects the whole batch rather than being reported in `missing`. IDs are matched case-insensitively. The fetched event only takes you part of the way: its `tags` carry the NIP-40 `expiration` tag and any NIP-32 `l` self-labels, so you can check those yourself, but third-party label decisions are separate kind-1985 events and are not in the payload. To ask whether a revision ID is servable, send it to `POST /api/videos/stats/bulk` — that resolves IDs through the ID-keyed read model, so superseded revisions are covered, and applies the full visibility filter; anything filtered comes back in `missing`. Do not use `GET /api/videos/{id}` for this: it reads the latest-revision model, so every superseded revision ID 404s there regardless of visibility. Do not treat presence in `revisions` as evidence that a video is visible. ### Events #### GET /api/event/{id} Get any Nostr event by ID. Caching: event IDs are immutable, so responses are CDN-cached for up to 24 hours. Moderation actions (ban, quarantine, delete, vanish) purge the CDN eagerly; edits and label updates deliberately do not purge event keys (the payload is content-addressed and never changes). Browsers revalidate within 60 seconds. **Response:** ```json { "id": "64-char-hex", "pubkey": "64-char-hex", "created_at": 1700000000, "kind": 0, "tags": [], "content": "{}", "sig": "128-char-hex" } ``` ### Calendar #### GET /api/calendar/events/{coordinate} Get a single NIP-52 calendar event by coordinate. #### GET /api/calendar/collections/{coordinate} Get a single NIP-52 calendar collection by coordinate. #### GET /api/calendar/events Discover NIP-52 calendar events. Example: ```http GET /api/calendar/events?host_pubkey=&include_past=true&order=start_asc&limit=20 ``` #### GET /api/calendar/events/{coordinate}/rsvps List RSVP events for a calendar event. #### GET /api/calendar/events/{coordinate}/rsvp-counts Get RSVP totals: ```json { "accepted": 12, "declined": 3, "tentative": 4 } ``` #### GET /api/calendar/events/{coordinate}/videos List public videos that reference the event coordinate. #### GET /api/calendar/events/{coordinate}/parent-calendars List parent calendar collections that include the event. ### Users #### GET /api/users/{pubkey} Get user profile and statistics. **Query:** No `include` parameter is currently supported on this endpoint. **Response:** ```json { "pubkey": "64-char-hex", "has_vanish_request": false, "profile": { "profile_updated": "2024-01-15T10:30:00Z", "name": "username", "display_name": "Display Name", "picture": "https://...", "banner": "https://...", "about": "Bio text", "nip05": "user@domain.com", "lud16": "user@getalby.com", "website": "https://..." }, "social": { "follower_count": 1000, "following_count": null }, "stats": { "video_count": 50, "horizontal_videos": 10, "vertical_videos": 40, "note_count": 100, "reaction_count": 200, "comment_count": 50, "repost_count": 30, "first_activity": "2023-06-01T00:00:00Z", "last_activity": "2024-01-15T10:30:00Z", "total_events": 1200 }, "engagement": { "total_reactions": 5000, "total_comments": 1200, "total_reposts": 300, "unique_reactors": 2500, "unique_commenters": 800, "unique_reposters": 200, "total_loops": 250.5, "total_views": 100000, "archived_loops": 8129352476 } } ``` Within `social`, each count is authoritative independently. Numeric zero is a genuine indexed zero; `null` means that field is unavailable, incomplete, not yet indexed, or could not be read from storage. This also applies to user objects returned by the bulk endpoint and by follower/following requests with `include=profiles`. The dedicated social endpoint instead returns `500` when its storage read fails. `has_vanish_request` is true when the pubkey has a NIP-62 vanish request on record. It is historical erasure-request state, not a current tombstone signal: it may stay true after the erasure sweep completes and the pubkey later publishes post-boundary activity again. Do not hide an otherwise live profile solely because this field is true. For pubkeys currently hidden by a pending vanish request, non-video statistics and engagement return defaults: social counts, note/reaction/comment/repost counts, total events, and received-engagement totals are zero, with `first_activity` and `last_activity` as `null`. **Archived loops contract:** `engagement.archived_loops` is the creator's classic Vine loop total — the sum of the `loops` tag carried on their archived videos, the same figure `GET /api/users/top` ranks creators by. The tag is self-reported, so only videos created before 2025 (the Vine archive) count; a `loops` tag on a newer video is ignored. It is separate from `total_views` and `total_loops`, which count Divine-era activity only, so a lifetime total is `archived_loops + total_views`. A creator with no archived videos reports `0`. `null` means the value is unknown because the lookup failed; treat it as unknown, not as zero. Only this endpoint computes it: user objects returned by the bulk endpoint and by follower/following requests with `include=profiles` carry `archived_loops: null`. **View total contract:** `engagement.total_views` is `null` when the live view lookup failed or was not available for that user object, on this endpoint, the bulk endpoint, and follower/following requests with `include=profiles`. A successful lookup for a creator with no views reports `0`. A lifetime total (`archived_loops + total_views`) is complete only when both values are non-null. **Video count contract:** `stats.video_count`, `horizontal_videos`, and `vertical_videos` count the user's latest currently public videos under the same visibility predicate as `GET /api/users/{pubkey}/videos`. The counts exclude banned, authorized NIP-09 event-id-deleted, addressable-deleted, blocked, quarantined, and NSFW-labeled videos; videos from banned, suspended, private, or vanished accounts; expired videos; and future-dated videos until their publication time. `video_count` is the total of the horizontal and vertical public counts. Non-video statistics keep their existing event-count semantics except for pubkeys currently hidden by a pending vanish request, which return the defaulted values described above. **Note:** Badge awards and external identity claims are exposed through dedicated endpoints: `GET /api/users/{pubkey}/badges` and `GET /api/users/{pubkey}/identities`. #### GET /api/users/{pubkey}/videos Get videos by a user. **Returns a bare JSON array** (legacy v1 shape). For the unified envelope use `GET /api/v2/users/{pubkey}/videos`. **Query:** `limit` (1-100, default 25), `offset` (skip N results), `before` (timestamp cursor), `sort` (`recent` default, `trending`, `popular`, `loops`, `likes`, `comments`, `published`) **Note:** When `sort=loops`, the response uses the extended format with `embedded_likes`, `platform`, etc., plus `reactions`, `comments`, `reposts`, and `engagement_score` (same as the main `/api/videos?sort=loops` response). All formats include `loops` and `views`. **Note:** The response objects include extracted badge tag fields and moderation fields (`moderation_status`, `ai_score`, `ai_source`, `ai_checked_at`). Do not rely on this legacy user-videos endpoint being compact yet; raw event `tags`, `proofmode`, or `device_attestation` fields may still appear until this path is wired to the compact response shape. **Note:** This endpoint returns `content_labels` on each item, but it does not currently expose `nsfw`, `content_safety`, or `exclude_label` query parameters. Treat it as a label-returning endpoint, not a preference-aware safety-filter endpoint. **Response:** ```json [ { "id": "64-char-hex", "pubkey": "64-char-hex", "created_at": "2024-01-15T10:30:00Z", "kind": 34236, "d_tag": "my-video", "title": "Video Title", "content": "Description", "thumbnail": "https://...", "video_url": "https://...", "reactions": 150, "comments": 42, "reposts": 10, "engagement_score": 254, "loops": 5000, "views": 12345, "author_name": "CreatorName", "author_avatar": "https://...", "verification": "human", "content_labels": ["safe"] } ] ``` #### GET /api/v2/users/{pubkey}/videos Same query parameters and item schema as `GET /api/users/{pubkey}/videos`, wrapped in the unified pagination envelope. Accepts `cursor` plus `offset` / `before` as legacy aliases. ```json { "data": [ /* same items */ ], "pagination": { "next_cursor": "o:25", "has_more": true } } ``` #### GET /api/users/{pubkey}/collabs Get videos where a user appears as a collaborator (`p` tag) but is not the author. **Query:** `limit` (1-100, default 50), `offset` (skip N results), `sort` (`recent` default, `popular`, `likes`, `comments`, `published`) **Response:** Same schema as `GET /api/users/{pubkey}/videos`. #### GET /api/users/{pubkey}/followers Get user's followers (paginated). **Returns the legacy `{ followers, total, offset, limit }` wrapper** — for the unified envelope use `GET /api/v2/users/{pubkey}/followers`. For a subject currently hidden by a pending vanish request, this endpoint returns an empty list. **Query:** `limit` (1-100, default 25), `offset` (skip N results), `include` (set to `profiles` to include full user data), `q` (case-insensitive substring match against the profile's `name`, `display_name`, and `nip05`; max 100 chars) With `q`, `total` counts the matches rather than the whole list, and the response echoes the applied filter back as `query` (a sibling of `followers` in the wrapper, of `data` in the v2 envelope). A deployment predating `q` ignores the unknown key and answers with the **unfiltered** page, so treat a missing `query` in the response as "this server did not filter" rather than reading an unfiltered page as a set of matches. The key is omitted entirely when no filter was requested, and no other v2 list endpoint emits it. To avoid broad public profile scans, `q` over 100 characters returns `400 Query too long`; a filter whose whitespace-separated tokens all contain fewer than two alphanumeric characters returns an empty filtered page without dispatching to storage. Pass `sort=newest` (default) or `sort=oldest` to order the page by the follower's kind 3 `created_at` — when they last rewrote their contact list, not the moment they followed. An unrecognised value returns `400`. The applied order is echoed back as `sort` under the same rule as `query`: a deployment predating the parameter omits the key and answers newest-first regardless. **Response (default):** ```json { "followers": [ "pubkey1-64-char-hex", "pubkey2-64-char-hex", "pubkey3-64-char-hex" ], "total": 1500, "offset": 0, "limit": 50, "sort": "newest" } ``` **Response (with `include=profiles`):** ```json { "followers": [ { "pubkey": "64-char-hex", "has_vanish_request": false, "profile": { "name": "username", "display_name": "Display Name", "picture": "https://...", "about": "Bio text" }, "social": { "follower_count": 500, "following_count": null }, "stats": { ... }, "engagement": { ... } } ], "total": 1500, "offset": 0, "limit": 50, "sort": "newest" } ``` Within each embedded `social` object, either count can independently be `null`. #### GET /api/v2/users/{pubkey}/followers Same query parameters as `GET /api/users/{pubkey}/followers`, wrapped in the unified pagination envelope. Accepts `cursor` plus `offset` as a legacy alias. Items inside `data` are bare pubkey strings or full `UserData` objects depending on the `include=profiles` flag. ```json { "data": [ /* pubkeys or UserData */ ], "pagination": { "next_cursor": "o:25", "has_more": true } } ``` Note: the v2 envelope does not surface `total` — v1 callers that need an exact follower count should keep using `GET /api/users/{pubkey}/followers` (or `GET /api/users/{pubkey}/social` for counts only). #### GET /api/users/{pubkey}/following Get who the user follows. **Returns the legacy `{ following, total, offset, limit }` wrapper** — for the unified envelope use `GET /api/v2/users/{pubkey}/following`. For a subject currently hidden by a pending vanish request, this endpoint returns an empty list. **Query:** `limit` (1-100, default 25), `offset` (skip N results), `include` (set to `profiles` to include full user data), `q` (narrows the list the same way it does on `/followers`) **Response (default):** ```json { "following": [ "pubkey1-64-char-hex", "pubkey2-64-char-hex", "pubkey3-64-char-hex" ], "total": 250 } ``` **Response (with `include=profiles`):** ```json { "following": [ { "pubkey": "64-char-hex", "has_vanish_request": false, "profile": { "name": "username", "display_name": "Display Name", "picture": "https://...", "about": "Bio text" }, "social": { "follower_count": null, "following_count": 200 }, "stats": { ... }, "engagement": { ... } } ], "total": 250, "offset": 0, "limit": 50 } ``` Within each embedded `social` object, either count can independently be `null`. #### GET /api/v2/users/{pubkey}/following Same query parameters as `GET /api/users/{pubkey}/following`, wrapped in the unified pagination envelope. Accepts `cursor` plus `offset` as a legacy alias. ```json { "data": [ /* pubkeys or UserData */ ], "pagination": { "next_cursor": "o:25", "has_more": true } } ``` #### GET /api/users/{pubkey}/social Get follower/following counts only (lightweight endpoint for social stats). The follower count includes accounts whose latest indexed kind-3 contact list still follows the profile owner, except accounts the owner has placed in their latest indexed kind-10000 block/mute list. Block direction matters: the owner blocking a follower removes that follower, while the follower blocking the owner does not. Follow and unfollow state follows the newest indexed kind-3 event. Treat each field independently. A numeric zero is an authoritative indexed zero; an indexed profile with no social-summary row returns zero for both fields. `null` means that field is unavailable, incomplete, or not yet indexed. One field can be numeric while the other is `null`. Counts may differ from other Nostr apps because each service may have indexed a different portion of the public network. Block-list and social-summary refreshes run independently, so a new block or unblock can take up to two refresh windows plus the endpoint cache lifetime to appear here. A profile's first follow edge can likewise read as zero until the next social-summary refresh. **Response:** ```json { "follower_count": 1500, "following_count": null } ``` Returns `500` when social statistics cannot be read. A successful response uses `null` only for field-level unavailability, not for a backend failure. #### GET /api/users/{pubkey}/feed Get the user's **home feed** - personalized video feed from followed accounts. This is the main timeline showing content from people the user follows. **Query Parameters:** | Param | Type | Description | |-------|------|-------------| | `sort` | string | `recent` (default), `trending`, `likes`, `comments`, or `published` | | `limit` | number | Max results (1-1000, default 50) | | `before` | number | Unix timestamp cursor for pagination | | `offset` | number | Skip N results for offset-based pagination | **Note:** `videos` in this response use compact video-card rows like other public video list endpoints. They omit full `tags`, `proofmode`, and `device_attestation` blobs. This endpoint may include an optional `proof` summary for badge state; fetch `/api/videos/{id}` for raw event tags or full proof material. **Following-feed proof summary example:** ```json { "videos": [ { "id": "64-char-hex", "title": "Video Title", "proof": { "status": "present", "level": "basic_proof", "checked_at": 1779494400, "version": 1, "checks": { "proofmode_present": true, "proofmode_parse_ok": true, "pgp_signature_present": true, "pgp_signature_valid": null, "device_attestation_present": true, "device_attestation_valid": null, "c2pa_manifest_present": false, "c2pa_manifest_valid": null } } } ], "next_cursor": "1700000000", "has_more": true } ``` **Note:** This endpoint returns `content_labels` on each video, but it does not currently expose `nsfw`, `content_safety`, or `exclude_label` query parameters. **Response:** ```json { "videos": [...], "next_cursor": "1700000000", "has_more": true } ``` #### GET /api/users/{pubkey}/notifications Get user notifications (requires NIP-98 auth). Engagement (`reaction`, `repost`, `comment`, `reply`) on a video that has since been deleted, banned, or is under moderation review (quarantined) is excluded from both the list and `unread_count` — including `a`-tag coordinate-only engagement that carries no concrete event id (the coordinate is resolved to its current latest revision). Engagement on a merely *edited* video (a newer revision, not a delete) is unaffected, and a later replacement published after a delete un-suppresses the card. Notifications from source pubkeys the recipient has muted/blocked (kind `10000` mute list) are excluded server-side, in addition to standard moderation-set filtering. `unread_count` reflects the same exclusion, so the badge stays consistent with the list. **Query:** - `types` - Filter by type (comma-separated): `reaction`, `reply`, `repost`, `mention`, `comment`, `follow`, `zap`, `delete` - `unread_only` - Only show unread (default false) - `limit` - Max results (default 50) - `before` - Cursor for pagination (timestamp) - `before_id` - Cursor tiebreaker for pagination (`source_event_id` from `next_cursor_id`) **Response:** ```json { "notifications": [ { "source_pubkey": "64-char-hex", "source_event_id": "64-char-hex", "source_kind": 7, "source_created_at": 1700000000, "referenced_event_id": "64-char-hex", "referenced_event_kind": 34236, "notification_type": "reaction", "created_at": 1700000000, "read": false, "content": "🔥", "referenced_d_tag": "my-video-dtag", "source_profile": { "display_name": "Alice", "picture": "https://example.com/avatar.jpg", "nip05": "alice@example.com" }, "referenced_video": { "title": "My Cool Video", "thumbnail": "https://example.com/thumb.jpg", "blurhash": "LEHV6nWB2yk8pyo0adR*.7kCMdnj", "d_tag": "my-video-dtag" }, "referenced_event_title": "My Cool Video", "reply_context": null, "reaction_context": null, "target_comment_id": null, "root_event_id": "64-char-hex", "root_event_kind": 34236, "root_event_pubkey": "64-char-hex", "root_d_tag": "my-video-dtag", "root_addressable_id": "34236::my-video-dtag", "comment_content": null } ], "unread_count": 5, "next_cursor": "1699999000", "next_cursor_id": "64-char-hex", "has_more": true } ``` Optional fields (present when data exists): `content` (source event content), `comment_content` (target comment body for comment/reply/like-on-comment navigation), `reaction_context` (`reaction_on_your_comment` for confirmed comment reactions), `referenced_event_kind` (kind of `referenced_event_id` when known), `referenced_d_tag` (stable event identifier), `source_profile` (actor's display_name/picture/nip05), `referenced_video` (title/thumbnail/blurhash/d_tag for the root video, always reflects latest version after edits) ``` **Notification Types:** - `reaction` - Someone liked/reacted to your content. Reactions on videos and comments share this type; `reaction_context: "reaction_on_your_comment"` and `target_comment_id` identify confirmed comment reactions. Absence of `reaction_context` means a video reaction, non-video reaction, or unclassified target. - `reply` - Threaded reply (kind 1 direct reply, or kind 1111 whose NIP-22 parent is another comment) - `comment` - Top-level NIP-22 comment on your video (kind 1111, parent is the video) - `repost` - Someone reposted your video - `mention` - Someone mentioned you via p-tag - `follow` - Someone followed you - `zap` - Someone zapped you (lightning payment) - `delete` - Someone issued a delete request referencing your event **Optional response fields (added 2026-04-20):** - `referenced_event_kind` - Kind of the event named by `referenced_event_id`. Populated when Funnelcake stores that event. For kind 7 reactions, kind 16 generic reposts, and kind 1111 NIP-22 comments/replies, Funnelcake can instead use a valid `k` tag only when `referenced_event_id` is also the source event's last valid lowercase `e` target. This fallback is the source event's self-declared target kind and is not verified against the unavailable referenced event. The field is absent when the kind is unknown; absence does not imply a video. A video kind can be present even when `referenced_video` metadata is absent. - `referenced_event_title` - Root video's title (follows the NIP-22 `E` tag for comments/replies, so reply-to-reply still surfaces the video title). - `reply_context` - Only on `reply`. `reply_to_your_comment_on_your_video` or `reply_to_your_comment_on_others_video`. - `reaction_context` - `reaction_on_your_comment` when a `reaction` is known to target a comment. Absent for video reactions, non-video reactions, and reactions whose target cannot be classified. - `target_comment_id` - Comment id to scroll to in the comments sheet. Equals `source_event_id` for `comment` and `reply`; equals the liked comment id for likes/reactions on comments. For kind 7 reactions that self-declare `k:1111`, this comes directly from the reaction's `e` tag and does not require resolving the liked comment event. - `comment_content` - Body of the target comment/reply. For `comment` and `reply`, `content` also contains this body; for likes/reactions on comments, `content` remains the reaction content. - `root_event_id` - Navigation target (the video for kind 1111 notifications and likes/reactions on comments, `referenced_event_id` otherwise). - `root_event_kind` - Root target kind when known (`34236` for addressable short videos). - `root_event_pubkey` - Root video author pubkey. For kind 1111 this prefers uppercase `P`, then the root event author. - `root_d_tag` - Root kind 34236 video d-tag. - `root_addressable_id` - Full coordinate `34236::` when known. Mobile should prefer this over raw event ids. > Kind 1111 notifications where the NIP-22 parent is another kind 1111 comment now emit `notification_type: "reply"` instead of `"comment"`. Clients filtering by `notification_type` to catch thread engagement must include both `comment` and `reply`. > Kind 7 reactions continue to emit `notification_type: "reaction"`. `reaction_context` was added on 2026-08-04 as an additive read-time field for confirmed comment reactions. Historical rows may expose it when their stored notification fields classify as a comment reaction; historical rows are not otherwise rematerialized or rewound. > `referenced_event_kind` was added on 2026-08-24 for newly materialized notification rows. Historical rows are not rematerialized, so the field remains absent there unless another repair rewrites them. #### POST /api/users/{pubkey}/notifications/read Mark notifications as read (requires NIP-98 auth). **Request:** ```json { "notification_ids": ["event-id-1", "event-id-2"] } ``` To mark ALL as read, send empty array or omit: ```json { "notification_ids": [] } ``` **Response:** ```json { "marked_count": 5, "marked_all": true } ``` #### GET /api/users/{pubkey}/identities Get a user's NIP-39 external identity claims. **Response:** ```json { "identities": [ { "platform": "github", "username": "alice", "proof": "https://github.com/alice" }, { "platform": "twitter", "username": "alice_dev", "proof": "" } ] } ``` #### GET /api/users/{pubkey}/analytics Get creator analytics for your own content (requires NIP-98 auth). **Query:** `period` - Time period: `7d`, `30d` (default), `90d`, or `all` **Response:** ```json { "total_views": 50000, "total_loops": 37500.5, "total_watch_time": 125000, "unique_viewers": 12000, "videos": [ { "id": "64-char-hex", "views": 10000, "loops": 7500.25, "unique_viewers": 3000, "avg_completion": 0.85 } ], "daily_stats": [ { "date": "2024-01-15", "views": 1500, "loops": 1125.0 } ], "period": "30d" } ``` #### GET /api/users/{pubkey}/recommendations Get personalized video recommendations for a user. Uses Gorse ML recommendation engine when available, with fallback to popular/recent videos. **Query Parameters:** - `limit` - Max results (1-100, default 20) - `cursor` - Preferred opaque pagination cursor for infinite scroll - `offset` - Legacy offset pagination alias; ignored when `cursor` is present - `category` - Filter by hashtag/category - `fallback` - Strategy when personalization unavailable: "popular" (default) or "recent" - `content_safety` - Preset: `default` (hide default-hidden moderation labels), `adult` (show adult content, still hide gore/spam), `open` (show all legal content) - `nsfw` - Legacy: `show` to include default-hidden moderation labels (superseded by `content_safety`) - `preferred_languages` - Comma-separated ISO-639-1 language codes. Matching videos are ranked first within the returned page when detected-language verdicts are available. **Response:** ```json { "videos": [ { "id": "event-id", "pubkey": "creator-pubkey", "title": "Video Title", "thumbnail": "https://...", "reactions": 42, "comments": 5, "reposts": 2, "detected_language": "en", "author_name": "CreatorName", "author_avatar": "https://...", "content_labels": ["safe"] } ], "source": "personalized", "limit": 12, "offset": 0, "has_more": true, "next_offset": 12, "next_cursor": "eyJ2ZXJzaW9uIjoxLCJwdWJrZXkiOiIuLi4ifQ", "fallback_applied": false, "personalized_count": 12, "exploration_variant": "off", "exploration_count": 0 } ``` `detected_language` appears only when `preferred_languages` is non-empty and a stored verdict exists for that video. Otherwise the field is omitted. **Source Values:** - `personalized` - Every item came from a personalized source - `mixed` - Page blends personalized and fallback items - `popular` - Deterministic popular fallback stream - `recent` - Fallback: most recent videos **Note:** Cold-start users (no interaction history) automatically receive fallback recommendations and do not receive random exploration. Every candidate source is filtered against the viewer's 10,000 most-recent distinct meaningfully watched event ids, where a retained watch is any session with `loops >= 0.75`. Pages cap any single creator at 3 videos. Gorse output that arrives strictly newest-first (a cold-start signature) is demoted into the fallback blend rather than labeled `personalized`. Videos authored by the requested `{pubkey}` are always excluded. **Pagination behavior:** - First request: `GET /api/users/{pubkey}/recommendations?limit=12&fallback=popular` - Continue with `next_cursor` when present - `offset` remains available for compatibility, but `cursor` is the preferred mechanism - `fallback_applied=true` means the returned page used fallback recommendations for some or all results - `personalized_count` reports how many items came from a personalized source; `source` is `personalized` only when it equals the page size - `exploration_variant` reports stable viewer assignment (`off`, `control`, or `treatment`); `exploration_count` reports filled exploration slots **Note:** Recommendations support `content_safety` and legacy `nsfw=show`, but they do not expose `exclude_label` today. On the current implementation, this endpoint only uses the NSFW include/exclude bit, so `adult` and `open` currently behave the same. #### GET /api/users/{pubkey}/badges Get NIP-58 badges awarded to a user. **Query:** `accepted_only` - boolean, default `true`. When `true`, only badge awards accepted in the user's newest profile badges event are returned. The API reads current NIP-58 kind `10008` and legacy kind `30008`, with newest event winning when both exist. **Response:** ```json { "badges": [ { "definition": { "d_tag": "founding-creator", "creator_pubkey": "64-char-hex", "name": "Founding Creator", "description": "Early contributor to divine.video", "image": "https://example.com/badges/founding-creator.png", "thumb": "https://example.com/badges/founding-creator-thumb.png", "created_at": "2025-01-15T10:30:00Z", "coordinate": "30009:64-char-hex:founding-creator" }, "award_event_id": "64-char-hex", "awarded_at": "2025-01-20T12:00:00Z", "awarded_by": "64-char-hex" } ] } ``` #### GET /api/badges/{creator_pubkey}/{d_tag} Get the badge definition metadata for a single badge. **Response:** ```json { "d_tag": "founding-creator", "creator_pubkey": "64-char-hex", "name": "Founding Creator", "description": "Early contributor to divine.video", "image": "https://example.com/badges/founding-creator.png", "thumb": "https://example.com/badges/founding-creator-thumb.png", "created_at": "2025-01-15T10:30:00Z", "coordinate": "30009:64-char-hex:founding-creator" } ``` #### GET /api/users/{pubkey}/identities Get a user's external identity claims published via NIP-39. **Response:** ```json { "identities": [ { "platform": "github", "username": "rabble", "proof": "https://gist.github.com/rabble/identity-proof" }, { "platform": "mastodon", "username": "@rabble@mastodon.social", "proof": "https://mastodon.social/@rabble/1234567890" } ] } ``` #### GET /api/users/top Get top creators ranked by total loop count. **Query Parameters:** | Param | Type | Description | |-------|------|-------------| | `platform` | string | `vine` for classic Viners, `all` for everyone (default: all) | | `limit` | number | Max results (1-100, default 50) | **Response:** ```json [ { "pubkey": "64-char-hex", "name": "King Bach", "picture": "https://...", "total_loops": 5000000, "video_count": 150, "platform": "vine" } ] ``` **Note:** This is different from `/api/leaderboard/creators` which ranks by views over time periods. This endpoint ranks by embedded loop counts (primarily for Vine archive data). #### POST /api/users/bulk Get multiple users in one request. **Request:** ```json { "pubkeys": ["pk1", "pk2"], "from_event": { "kind": 3, "pubkey": "user-pubkey" } } ``` **Response:** ```json { "users": [ { "pubkey": "64-char-hex", "has_vanish_request": false, "profile": { "profile_updated": "2024-01-15T10:30:00Z", "name": "username", "display_name": "Display Name", "picture": "https://...", "banner": "https://...", "about": "Bio text", "nip05": "user@domain.com", "lud16": "user@getalby.com", "website": "https://..." }, "social": { "follower_count": 1000, "following_count": null }, "stats": { "video_count": 50, "horizontal_videos": 10, "vertical_videos": 40, "note_count": 100, "reaction_count": 200, "comment_count": 50, "repost_count": 30, "first_activity": "2023-06-01T00:00:00Z", "last_activity": "2024-01-15T10:30:00Z", "total_events": 1200 }, "engagement": { "total_reactions": 5000, "total_comments": 1200, "total_reposts": 300, "unique_reactors": 2500, "unique_commenters": 800, "unique_reposters": 200 } } ], "missing": ["pk-that-wasnt-found"], "source_event_id": "64-char-hex" } ``` Each embedded `social` field follows the independent availability contract from `GET /api/users/{pubkey}`. A numeric zero is authoritative, and either field can be `null` without invalidating the other. **The `from_event` pattern:** Instead of providing explicit `pubkeys`, you can reference an event that contains pubkeys: - `kind: 3` - Resolve pubkeys from a user's contact list (follow list) - Fetches all `p` tags from the user's kind 3 event - Useful for: "Get profiles of everyone this user follows" If the referenced pubkey has no visible contact list, including because the subject currently has a pending vanish request, the endpoint returns `404 Contact list not found`. ```json // Get profiles of everyone alice follows { "from_event": { "kind": 3, "pubkey": "alice-pubkey" } } ``` Similarly, `/api/videos/bulk` supports: - `kind: 30005` - Resolve videos from a curation set/playlist - Fetches all `e` and `a` tags from the playlist ```json // Get all videos in a playlist { "from_event": { "kind": 30005, "pubkey": "curator-pubkey", "d_tag": "playlist-name" } } ``` ### Search #### GET /api/search Search videos by hashtag or text. **Returns a bare JSON array** (legacy v1 shape). For the unified envelope use `GET /api/v2/search`. **Query:** - `tag` - Search by hashtag - `q` - Full-text search - `type` - Content type: omit for videos (default), `sound` for audio/sounds (`q` is required and `tag` is not supported when `type=sound`) - `sort` - Video text-search sort: `trending`, `loops`, `engagement`, `recent`, `published`. Applies when `q` searches videos; omitted or unknown values use relevance/trending order. - `limit` - Max results (1-100, default 25) - `offset` - Skip N results for pagination - `label` - Filter by any effective content label (for example `nudity` or `topic:music`) - `exclude_label` - Comma-separated labels to exclude (max 5) - `content_safety` - Parsed by the API for future parity, but current search queries still apply the default public NSFW filter - `nsfw` - Legacy alias parsed by the API, but it does not currently bypass the default public NSFW filter on `/api/search` **Note:** Treat `/api/search` as fully supporting `label` and `exclude_label`. Do not depend on `content_safety` or `nsfw=show` for NSFW opt-in parity with `/api/videos` yet. **Note:** When `type=sound`, the `q` parameter is required and `tag` is not supported. The response returns sound stats instead of video stats. **Response:** ```json [ { "id": "64-char-hex", "pubkey": "64-char-hex", "created_at": "2024-01-15T10:30:00Z", "kind": 34236, "d_tag": "my-video", "title": "Video Title", "content": "Description", "thumbnail": "https://...", "video_url": "https://...", "reactions": 150, "comments": 42, "reposts": 10, "engagement_score": 254, "loops": 5000, "views": 12345, "language": "en", "verification": "human", "author_name": "CreatorName", "author_avatar": "https://...", "content_labels": ["safe"] } ] ``` #### GET /api/v2/search Same query parameters and item schema as `GET /api/search`, wrapped in the unified pagination envelope. Accepts `cursor` plus `offset` as a legacy alias. Item schema inside `data` is identical to the bare-array entries from `GET /api/search`. ```json { "data": [ /* video or sound items */ ], "pagination": { "next_cursor": "o:25", "has_more": true } } ``` #### GET /api/search/profiles Search user profiles by name, display_name, nip05, or about/bio. **Returns a bare JSON array** (legacy v1 shape). For the unified envelope use `GET /api/v2/search/profiles`. **Query:** `q` (required), `limit` (1-100, default 25), `offset` (skip N results), `sort_by` ("followers" or "relevance", default "relevance"), `has_videos` (boolean, filter by whether users currently have public videos) **Relevance ranking (default):** - Tier 1: exact name match - Tier 2: prefix match - Tier 3: substring match (includes handles like `jackandjackofficial` for query `jack`) - Then social signals as tie-breakers: follower count, video count, and profile picture presence - NIP-05 signals: verified identifiers are boosted; empty/squatter-like unverified profiles are penalized `sort_by=followers` keeps follower count as the primary sort key. **Response:** ```json [ { "pubkey": "64-char-hex", "name": "username", "display_name": "Display Name", "nip05": "user@domain.com", "about": "Bio text", "picture": "https://example.com/avatar.jpg", "banner": "https://example.com/banner.jpg", "follower_count": 1234, "video_count": 42, "nip05_verified": 1 } ] ``` `video_count` and `has_videos` use the default currently-public-video contract from `GET /api/users/{pubkey}/videos` and the user statistics fields described above; they do not opt into NSFW-labeled videos. Provider-backed search results are hydrated from live user data before they are returned, including when the live count is zero, and `has_videos` is rechecked against that hydrated count. A stale indexed count therefore cannot advertise a video that is no longer publicly listed. #### GET /api/v2/search/profiles Same query parameters and item schema as `GET /api/search/profiles`, wrapped in the unified pagination envelope. Accepts `cursor` plus `offset` as a legacy alias. ```json { "data": [ /* same item shape as GET /api/search/profiles */ ], "pagination": { "next_cursor": "o:25", "has_more": true } } ``` ### Hashtags #### GET /api/hashtags Get popular hashtags. **Query:** `limit` (1-100, default 50), `offset` (default 0), `q` (optional, case-insensitive substring filter, max 100 chars) **Response:** ```json [ { "hashtag": "nostr", "video_count": 500, "unique_creators": 150, "total_loops": 125000, "last_used": "2024-01-15T10:30:00Z", "thumbnail": "https://..." }, { "hashtag": "bitcoin", "video_count": 300, "unique_creators": 80, "total_loops": 75000, "last_used": "2024-01-14T18:45:00Z", "thumbnail": "https://..." } ] ``` #### GET /api/hashtags/trending Get trending hashtags (time-weighted by recent activity). **Query:** `limit` (1-100, default 50), `offset` (default 0), `q` (optional, case-insensitive substring filter, max 100 chars) **Response:** ```json [ { "hashtag": "nostr", "video_count": 500, "videos_24h": 50, "videos_7d": 200, "unique_creators": 150, "last_used": "2024-01-15T10:30:00Z", "trending_score": 8500, "thumbnail": "https://..." } ] ``` ### Categories #### GET /api/categories Get canonical discovery category groups with video counts. Categories are derived from allow-listed VLM (Vision-Language Model) topic labels; approved synonyms are folded before pagination and moderation-adjacent topics are not browsable categories. **Query:** `limit` (1-100, default 50), `offset` (default 0), `q` (optional, case-insensitive substring filter) **Response:** ```json [ { "name": "music", "video_count": 1500 }, { "name": "comedy", "video_count": 1200 }, { "name": "dance", "video_count": 800 } ] ``` **Common categories:** music, comedy, dance, sports, food, animals, fashion, gaming, nature, travel, education, technology, art, beauty, fitness, pets, cooking, DIY, news, entertainment #### Filtering videos by category Use the `category` query parameter on the videos endpoint: ``` GET /api/videos?category=music ``` This is a convenience shorthand for `label=topic:music`. The `category` parameter maps to the underlying label system. ### Sounds Sounds are Kind 1063 (NIP-94) file metadata events for audio content. Videos can reference sounds via an `e` tag with `"audio"` marker. The `usage_count` field indicates how many videos use a given sound. #### GET /api/sounds List sounds with sorting. **Query:** `sort` (`trending` default, `recent`, `popular`, `uses`), `limit` (1-100, default 50), `offset` (default 0) **Note on sort options:** - `trending` - Sorted by trending score with 72-hour half-life decay (default) - `recent` - Newest first - `popular` - Sorted by engagement score - `uses` - Sorted by number of videos that use the sound **Response:** ```json [ { "id": "64-char-hex", "pubkey": "64-char-hex", "created_at": "2024-01-15T10:30:00Z", "kind": 1063, "d_tag": "my-sound", "title": "Chill Beat", "content": "A relaxing instrumental track", "audio_url": "https://...", "thumbnail": "https://...", "reactions": 42, "comments": 5, "reposts": 3, "usage_count": 15, "engagement_score": 120.0, "author_name": "BeatMaker", "author_avatar": "https://...", "trending_score": 65.2 } ] ``` #### GET /api/sounds/{id}/stats Get stats for a specific sound by event ID. **Response:** ```json { "id": "64-char-hex", "pubkey": "64-char-hex", "created_at": "2024-01-15T10:30:00Z", "kind": 1063, "title": "Chill Beat", "reactions": 42, "comments": 5, "reposts": 3, "usage_count": 15, "engagement_score": 120.0, "trending_score": 65.2 } ``` **Errors:** `404` if sound not found. #### GET /api/users/{pubkey}/sounds Get sounds created by a user. **Query:** `limit` (1-100, default 50), `offset` (default 0) **Response:** Same schema as `GET /api/sounds`. #### GET /api/users/{pubkey}/recommendations/sounds Get personalized sound recommendations. Uses Gorse ML recommendation engine when available, with fallback to popular/recent sounds. **Query:** `limit` (1-100, default 20), `fallback` (`popular` default, `recent`) **Response:** ```json { "sounds": [ { "id": "64-char-hex", "pubkey": "64-char-hex", "title": "Trending Beat", "reactions": 42, "usage_count": 15, "author_name": "BeatMaker", "author_avatar": "https://..." } ], "source": "personalized" } ``` **Source Values:** - `personalized` - ML-based recommendations from Gorse - `popular` - Fallback: popular sounds - `recent` - Fallback: most recent sounds **Note:** Cold-start users (no interaction history) automatically receive fallback recommendations. #### Searching sounds Use the search endpoint with `type=sound`: ``` GET /api/search?type=sound&q=chill+beats&limit=20 ``` The `q` parameter is required for sound search, and `tag` is not supported with `type=sound`. The response returns sound stats in the same format as `GET /api/sounds`. ### Stats #### GET /api/stats Get platform statistics. **Response:** ```json { "total_events": 162586, "total_videos": 18919, "vine_videos": 15000 } ``` ### Leaderboards Leaderboards show top videos and creators ranked by views for different time periods. #### Time Periods | Value | Description | Aliases | |-------|-------------|---------| | `day` | Last 24 hours | `daily`, `today`, `24h` | | `week` | Last 7 days | `weekly`, `7d` | | `month` | Last 30 days (default) | `monthly`, `30d` | | `year` | Last 365 days | `yearly`, `365d` | | `alltime` | All time | `all`, `all_time`, `forever` | #### GET /api/leaderboard/videos Get top videos ranked by blended popularity for a time period. Each row exposes its raw signals (`views`, `unique_viewers`, `loops`, `reactions`, `comments`, `reposts`) plus a server-computed `engagement_score` and `rank_score`; results are sorted by `rank_score` descending, with `views` then `id` as deterministic tiebreakers. `rank_score` is `views + unique_viewers*2 + log1p(loops)*0.5 + reactions*4 + comments*8 + reposts*12`. `engagement_score` is `reactions + comments*2 + reposts*3` (matches the convention used by `/api/videos`). The `views`, `unique_viewers`, and `loops` numbers are scoped to the requested `period`. **Engagement counts (`reactions`, `comments`, `reposts`) are all-time totals** — a video that went viral last year still gets credit for those reactions on the `day` leaderboard. This is intentional for now: the period leaderboards rebuild from the pre-aggregated `engagement_counts` table rather than scanning events per request. A future migration may add period-scoped engagement once we have the storage budget for it. The `views` field includes both authenticated views (from the Divine app) and CDN views (from third-party Nostr apps that play videos). The `loops` field only includes authenticated play-throughs from the Divine app. The `views` count will always be >= `loops`. **Parameters:** - `period` (optional): Time period - `day`, `week`, `month` (default), `year`, or `alltime` - `limit` (optional): Max results 1-100, default 50 - `offset` (optional): Pagination offset, default 0 - `platform` (optional): Restrict to one source platform. Allowed values: `vine` (classic Vine imports). Mutually exclusive with `exclude_platform`. To get native-only content, use `exclude_platform=vine` rather than `platform=` — native divine content has an empty platform tag, not `"divine"`. - `exclude_platform` (optional): Inverse of `platform`: drop rows from this platform. Allowed values: `vine`. Unknown query parameters are tolerated for forward compatibility and ignored if they are not recognized. Unknown `platform` / `exclude_platform` values return `400` with an explicit error, and supplying both `platform` and `exclude_platform` also returns `400`. **Example:** ```bash # Top videos this week curl "https://api.divine.video/api/leaderboard/videos?period=week&limit=20" # Page 2 of monthly leaderboard curl "https://api.divine.video/api/leaderboard/videos?period=month&limit=50&offset=50" # Classic Vines only, this week curl "https://api.divine.video/api/leaderboard/videos?period=week&platform=vine&limit=10" # Native (non-Vine) only, this week curl "https://api.divine.video/api/leaderboard/videos?period=week&exclude_platform=vine&limit=10" ``` **Response:** ```json { "period": "week", "entries": [ { "id": "abc123...", "pubkey": "creator456...", "title": "Viral Video Title", "thumbnail": "https://cdn.example.com/thumb.jpg", "d_tag": "my-video-1", "video_url": "https://cdn.example.com/video.mp4", "kind": 34236, "author_name": "King Bach", "author_avatar": "https://cdn.example.com/avatar.jpg", "views": 50000, "unique_viewers": 25000, "loops": 37500, "platform": "vine", "reactions": 1200, "comments": 340, "reposts": 90, "engagement_score": 2150, "rank_score": 109548.7 } ] } ``` The `platform` field is always present on every row. It is an empty string (`""`) for native divine content and `"vine"` for classic Vine imports. `reactions`, `comments`, `reposts`, `engagement_score`, and `rank_score` are also always present. Clients that need a different ranking (e.g. recency-decayed) can re-sort locally using the raw signals. #### GET /api/leaderboard/creators Get top creators (Viners) ranked by total views for a time period. **Parameters:** - `period` (optional): Time period - `day`, `week`, `month` (default), `year`, or `alltime` - `limit` (optional): Max results 1-100, default 50 - `offset` (optional): Pagination offset, default 0 **Example:** ```bash # Top creators all time curl "https://api.divine.video/api/leaderboard/creators?period=alltime&limit=100" # Top creators today curl "https://api.divine.video/api/leaderboard/creators?period=day" ``` **Response:** ```json { "period": "alltime", "entries": [ { "pubkey": "creator456...", "name": "King Bach", "display_name": "Bach", "picture": "https://cdn.example.com/avatar.jpg", "views": 5000000, "unique_viewers": 1000000, "loops": 3750000, "videos_with_views": 150 } ] } ``` #### GET /api/awards/diviner-candidates Return the ranked candidate receipt used to select a Diviner award for one exact, closed UTC period. This is a public, read-only endpoint. Successful responses use `Cache-Control: no-store` so award issuance never relies on eligibility cached before a privacy or moderation change. **Parameters:** - `start` (required): Canonical UTC midnight in exact `YYYY-MM-DDT00:00:00Z` form. - `end` (required): End-exclusive canonical UTC midnight in the same form. - `limit` (optional): Maximum candidates from 1 through 100; default 10. The bounds must describe exactly one of these closed periods: - one UTC calendar day; - one Monday-to-Monday seven-day week; or - one complete UTC calendar month, from its first day to the next month's first day. Open, future, non-midnight, non-`Z`, non-exact, duplicate, and unknown query parameters are rejected with HTTP 400. **Example:** ```bash curl "https://api.divine.video/api/awards/diviner-candidates?start=2026-08-21T00%3A00%3A00Z&end=2026-08-22T00%3A00%3A00Z&limit=10" ``` **Response:** ```json { "start": "2026-08-21T00:00:00Z", "end": "2026-08-22T00:00:00Z", "entries": [ { "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", "name": "creator", "display_name": "Creator", "nip05": "creator@example.com", "picture": "https://example.com/avatar.jpg", "latest_eligible_publication_at": "2026-08-10T12:00:00Z", "views": 10000, "unique_viewers": 4000, "loops": 12500.5, "videos_with_views": 24, "positive_reactors": 1500, "distinct_commenters": 300, "distinct_reposters": 200, "distinct_positive_engagers": 1750, "engagement_tier": 1, "engagement_rate": 0.4375, "score": 100.0, "rank": 1 } ] } ``` The score is built from percentiles over the complete eligible candidate population before `limit` is applied: `latest_eligible_publication_at` is the latest canonical publication timestamp among the creator's public, human-made promotion-eligible videos in the half-open activity window `[end - 30 days, end)`. - 30% distinct positive reactors; - 30% distinct reposters; - 20% distinct commenters; - 15% unique viewers; and - 5% loops. Each person counts once per creator and period for each engagement signal. `distinct_positive_engagers` is the union of positive reactors, commenters, and reposters, so one person who uses several signals still counts once in the union. Creators with positive engagement rank in a tier above creators with zero positive engagement. If every eligible creator has zero positive engagement, reach still provides a deterministic fallback winner. After tier and score, ties break by engagement rate, distinct positive engagers, unique viewers, and full pubkey ascending. Candidates must have a recent eligible video publication. The ranking applies Divine's public-account, current-content, privacy, deletion, block, suspension, vanish, quarantine, moderation, and NSFW rules, then adds stricter human-made and promotion-label exclusions. This endpoint does not change `/api/leaderboard/videos` or `/api/leaderboard/creators`. Those existing endpoints retain their documented ranking and response behavior. #### GET /api/awards/creator-period-stats Return paged per-creator engagement for one exact, closed UTC period. This is a public, read-only endpoint. Successful responses use `Cache-Control: no-store`. The counts are **period-scoped** and come from the same pre-aggregated rollups (`creator_daily_stats` and `diviner_daily_engagement`) as the Diviner award, so they include engagement on content that was later removed. Only **who is listed** is visibility-filtered: a creator whose content is entirely removed, or whose account is banned, suspended, deactivated, private, or vanished, is not returned. The metric counts themselves are not per-video filtered. A creator is a recipient when they have a currently public video **anywhere in their catalogue**, however old; the requested period bounds which engagement is counted, not which videos make someone eligible. A creator with a period-scoped view or reaction on an older video is still returned. Paging is a keyset walk on `pubkey`, never `OFFSET`. There is no total. Pass the response's `next_after` value as `after` to continue; it advances past the scanned activity candidates, including candidates omitted because they have no currently public video. A page can contain fewer entries than `limit`, or no entries, while still returning a non-null `next_after`. Continue until `next_after` is null. Creators with no activity in the period are omitted; a row never carries all-zero counts. **Parameters:** - `start` (required): Canonical UTC midnight in exact `YYYY-MM-DDT00:00:00Z` form. - `end` (required): End-exclusive canonical UTC midnight in the same form. - `limit` (optional): Maximum creators from 1 through 500; default 100. - `after` (optional): Keyset cursor returned as `next_after` by the previous response, or empty for the first request. The bounds must describe exactly one of the same closed periods as the Diviner award: one UTC calendar day, one Monday-to-Monday seven-day week, or one complete UTC calendar month. Open, future, reversed, non-midnight, non-`Z`, non-exact, duplicate, and unknown query parameters are rejected with HTTP 400. **Example:** ```bash curl "https://api.divine.video/api/awards/creator-period-stats?start=2026-09-22T00%3A00%3A00Z&end=2026-09-23T00%3A00%3A00Z&limit=100" ``` **Response:** ```json { "start": "2026-09-22T00:00:00Z", "end": "2026-09-23T00:00:00Z", "next_after": null, "entries": [ { "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", "views": 120, "unique_viewers": 60, "loops": 120.0, "reactions": 12, "comments": 3, "reposts": 2 } ] } ``` --- ## RSS Feeds DiVine provides RSS 2.0 feeds with podcast namespace extensions (`podcast`, `itunes`, `media`, `atom`). All feeds return `Content-Type: application/rss+xml` and are cached for 5 minutes. ### Feed Endpoints | Endpoint | Description | |----------|-------------| | `GET /feed/latest` | Latest videos globally | | `GET /feed/trending` | Trending videos | | `GET /feed/tag/{hashtag}` | Videos filtered by hashtag | | `GET /feed/category/{category}` | Videos filtered by category (VLM topic label) | | `GET /feed/{npub}/videos` | A creator's published videos | | `GET /feed/{npub}/feed` | Videos from accounts a user follows | **Notes:** - `{npub}` must be a valid Nostr npub bech32 string (e.g., `npub1...`). Returns 400 for invalid npubs. - All feeds return up to 50 items. - `/feed/latest` is recency-sorted. - `/feed/trending` is trend-sorted. - `/feed/tag/{hashtag}` and `/feed/category/{category}` support `?sort=` (`trending` default) and `?platform=` (for example, `vine`). - No authentication required. ### Podcast Namespace Extensions Each feed includes: - `video` — identifies content type - `` — canonical feed URL - `` — links items to Nostr events Per-item extensions: - `` — video URL with MIME type and file size - `` — creator display name - `` — video thumbnail - `` — video duration (on hashtag/category feeds with full media metadata) - `` — set to the video's d-tag (addressable event identifier) - `` — `https://divine.video/v/{naddr}` deep link Creator feeds (`/feed/{npub}/videos`) also include channel-level: - `` and `` from the creator's Nostr profile - `` set to the creator's npub - `` RSS standard channel image from profile picture ### Example Usage ```bash # Latest videos feed curl https://relay.divine.video/feed/latest # Creator's videos (use their npub) curl https://relay.divine.video/feed/npub1..../videos # Hashtag feed curl https://relay.divine.video/feed/tag/nostr # Category feed curl https://relay.divine.video/feed/category/music ``` ### Caching All feed responses include: ``` Cache-Control: public, max-age=300, stale-while-revalidate=60 ``` --- ## Common Patterns ### Pagination **Important:** Different endpoints have different pagination styles. Seven REST list endpoints are available under both legacy `/api/...` paths (original shapes) and versioned `/api/v2/...` paths (unified `{ data, pagination }` envelope). Both surfaces are stable and supported. See `docs/FUNNELCAKE_API.md` for the canonical contract reference. The seven affected endpoints: | Legacy path (bare array or wrapper) | v2 path (envelope) | |---|---| | `GET /api/videos` (bare array) | `GET /api/v2/videos` | | `GET /api/users/{pubkey}/videos` (bare array) | `GET /api/v2/users/{pubkey}/videos` | | `GET /api/users/{pubkey}/followers` (`{ followers, total, ... }`) | `GET /api/v2/users/{pubkey}/followers` | | `GET /api/users/{pubkey}/following` (`{ following, total, ... }`) | `GET /api/v2/users/{pubkey}/following` | | `GET /api/videos/{id}/comments` (`{ comments, total }`) | `GET /api/v2/videos/{id}/comments` | | `GET /api/search` (bare array) | `GET /api/v2/search` | | `GET /api/search/profiles` (bare array) | `GET /api/v2/search/profiles` | Endpoints not in the seven (`/api/videos/events`, `/api/users/{pubkey}/feed`, `/api/hashtags`, `/api/categories`, etc.) keep their original shapes — they were never affected by the envelope change. **v1 array endpoints** (`/api/videos`, `/api/users/{pubkey}/videos`, `/api/search`, `/api/search/profiles`, `/api/hashtags`) — Return plain arrays: ```javascript // Option 1: Use `before` param with last item's created_at timestamp const videos = await fetch('/api/videos?limit=50').then(r => r.json()); // For next page, use last video's timestamp const lastTimestamp = Math.floor(new Date(videos[videos.length - 1].created_at).getTime() / 1000); const nextPage = await fetch(`/api/videos?limit=50&before=${lastTimestamp}`).then(r => r.json()); // Stop when you get fewer results than requested const hasMore = nextPage.length === 50; ``` **Offset-based pagination** (works on legacy v1 paths and as an alias on v2): ```javascript // Option 2: Use offset for precise paging (useful for search results) const page1 = await fetch('/api/search?q=nostr&limit=20&offset=0').then(r => r.json()); const page2 = await fetch('/api/search?q=nostr&limit=20&offset=20').then(r => r.json()); const page3 = await fetch('/api/search?q=nostr&limit=20&offset=40').then(r => r.json()); // Works for videos sorted by loops too const topVines = await fetch('/api/videos?sort=loops&platform=vine&limit=50&offset=100').then(r => r.json()); ``` **v2 envelope endpoints** (`/api/v2/...` paths above) — Return `{ data, pagination }`: ```javascript const response = await fetch('/api/v2/videos?limit=50').then(r => r.json()); // { data: [...], pagination: { next_cursor: "o:50", has_more: true } } if (response.pagination.has_more) { const nextPage = await fetch(`/api/v2/videos?limit=50&cursor=${response.pagination.next_cursor}`); } ``` **Other paginated endpoints with their own envelopes** (`/api/videos/events`, `/api/users/{pk}/feed`, `/api/users/{pk}/recommendations`) — Return objects with their own cursor field shapes (`{ videos, next_cursor, has_more }`): ```javascript const response = await fetch('/api/videos/events?limit=50').then(r => r.json()); // { videos: [...], next_cursor: "1700000000", has_more: true } if (response.has_more) { const nextPage = await fetch(`/api/videos/events?limit=50&before=${response.next_cursor}`); } ``` ### Hybrid Approach: Events via WebSocket, Stats via REST ```javascript // Subscribe to new videos via WebSocket ws.send(JSON.stringify(["REQ", "feed", { kinds: [34236], limit: 50 }])); // Get engagement + view stats for displayed videos const ids = videos.map(v => v.id); const stats = await fetch('/api/videos/stats/bulk', { method: 'POST', body: JSON.stringify({ event_ids: ids }) }); ``` ### Publishing a Video ```javascript const event = { kind: 34236, created_at: Math.floor(Date.now() / 1000), tags: [ ["d", crypto.randomUUID()], ["title", "My Video"], ["imeta", "url https://cdn.example.com/video.mp4", "m video/mp4", "image https://cdn.example.com/thumb.jpg" ], ["t", "nostr"] ], content: "Video description" }; // Sign with nostr extension or private key const signedEvent = await window.nostr.signEvent(event); ws.send(JSON.stringify(["EVENT", signedEvent])); ``` ### Posting a Comment (Top-Level) ```javascript const comment = { kind: 1111, created_at: Math.floor(Date.now() / 1000), tags: [ ["E", videoId], // Root video (always the video) ["K", "34236"], // Root kind ["P", videoAuthorPubkey], // Root author ["e", videoId], // Parent = video for top-level ["k", "34236"], // Parent kind ["p", videoAuthorPubkey] // Parent author ], content: "Great video!" }; const signedComment = await window.nostr.signEvent(comment); ws.send(JSON.stringify(["EVENT", signedComment])); ``` ### Replying to a Comment (Nested) ```javascript const reply = { kind: 1111, created_at: Math.floor(Date.now() / 1000), tags: [ ["E", videoId], // Root stays as the video ["K", "34236"], // Root kind stays as video kind ["P", videoAuthorPubkey], // Root author stays as video author ["e", parentCommentId], // Parent = the comment being replied to ["k", "1111"], // Parent kind = comment ["p", parentCommentAuthor] // Parent author = comment author ], content: "I agree with your comment!" }; const signedReply = await window.nostr.signEvent(reply); ws.send(JSON.stringify(["EVENT", signedReply])); ``` ### Liking a Video ```javascript const reaction = { kind: 7, created_at: Math.floor(Date.now() / 1000), tags: [ ["e", videoId], ["p", videoAuthorPubkey], ["k", "34236"] ], content: "+" }; const signedReaction = await window.nostr.signEvent(reaction); ws.send(JSON.stringify(["EVENT", signedReaction])); ``` ### Check if I Already Liked a Video ```javascript // Query for my reactions on this video ws.send(JSON.stringify(["REQ", "my-reaction", { kinds: [7], authors: [myPubkey], "#e": [videoId], limit: 1 }])); // If you get an event back, you've already reacted // If you only get EOSE with no events, you haven't ``` ### Unlike a Video (Delete Reaction) ```javascript // First, find your reaction event ID (from the check above) const deletion = { kind: 5, created_at: Math.floor(Date.now() / 1000), tags: [ ["e", reactionEventId] // The ID of your kind 7 event ], content: "" }; const signedDeletion = await window.nostr.signEvent(deletion); ws.send(JSON.stringify(["EVENT", signedDeletion])); ``` ### Updating a Video (Replaceable Events) For parameterized replaceable events (kinds 30000-39999), publishing a new event with the **same `d` tag** replaces the old one: ```javascript // Original video had d-tag "my-video-id" // To update it, publish new event with same d-tag: const updatedVideo = { kind: 34236, created_at: Math.floor(Date.now() / 1000), tags: [ ["d", "my-video-id"], // SAME d-tag = replacement ["title", "Updated Title"], ["imeta", "url https://...", "image https://..."], ["t", "updated"] ], content: "New description" }; // Sign and publish - relay automatically replaces the old event ``` ### Follow a User Following requires a read-modify-write pattern on your kind 3 event: ```javascript // 1. Fetch your current contact list ws.send(JSON.stringify(["REQ", "my-follows", { kinds: [3], authors: [myPubkey], limit: 1 }])); // 2. When you receive it, add the new follow ws.onmessage = (msg) => { const [type, subId, event] = JSON.parse(msg.data); if (type === 'EVENT' && subId === 'my-follows') { // Add new p tag to existing tags const newTags = [ ...event.tags, ["p", newFollowPubkey, "wss://relay.divine.video"] ]; // 3. Publish updated contact list const updatedContacts = { kind: 3, created_at: Math.floor(Date.now() / 1000), tags: newTags, content: event.content // Preserve relay list if any }; const signed = await window.nostr.signEvent(updatedContacts); ws.send(JSON.stringify(["EVENT", signed])); } }; ``` ### Unfollow a User Same pattern, but filter OUT the p tag: ```javascript const newTags = event.tags.filter( tag => !(tag[0] === 'p' && tag[1] === unfollowPubkey) ); ``` ### Check if I'm Following Someone ```javascript // Option 1: Via REST API (check your following list) const following = await fetch(`/api/users/${myPubkey}/following`).then(r => r.json()); const isFollowing = following.following.includes(targetPubkey); // Option 2: Via WebSocket (fetch your kind 3 and check tags) ws.send(JSON.stringify(["REQ", "my-follows", { kinds: [3], authors: [myPubkey], limit: 1 }])); // Check if any p tag matches targetPubkey ``` ### Check if Someone Follows Me ```javascript // Via REST API const followers = await fetch(`/api/users/${myPubkey}/followers`).then(r => r.json()); const followsMe = followers.followers.includes(targetPubkey); // Or check their kind 3 directly via WebSocket ws.send(JSON.stringify(["REQ", "their-follows", { kinds: [3], authors: [targetPubkey], limit: 1 }])); ``` ### Get Video Feed with Author Profiles Videos from `/api/videos` include `pubkey` but not full profile. Batch-fetch profiles: ```javascript // 1. Get videos const videos = await fetch('/api/videos?limit=20').then(r => r.json()); // 2. Collect unique author pubkeys const authorPubkeys = [...new Set(videos.map(v => v.pubkey))]; // 3. Batch fetch profiles const profiles = await fetch('/api/users/bulk', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkeys: authorPubkeys }) }).then(r => r.json()); // 4. Create lookup map const profileMap = {}; profiles.users.forEach(u => profileMap[u.pubkey] = u.profile); // 5. Enrich videos with author info const enrichedVideos = videos.map(v => ({ ...v, author: profileMap[v.pubkey] || null })); ``` ### Build Comment Thread Hierarchy Comments have both root (`E`) and parent (`e`) tags. Build tree: ```javascript // Fetch all comments on a video ws.send(JSON.stringify(["REQ", "comments", { kinds: [1111], "#E": [videoId] }])); // Collect comments, then build tree const comments = []; // Populate from EVENT messages function buildCommentTree(comments, videoId) { const byId = {}; const roots = []; // Index by ID comments.forEach(c => byId[c.id] = { ...c, replies: [] }); // Build hierarchy using lowercase 'e' tag (parent) comments.forEach(c => { const parentTag = c.tags.find(t => t[0] === 'e'); const parentId = parentTag ? parentTag[1] : null; if (parentId === videoId) { // Top-level comment roots.push(byId[c.id]); } else if (byId[parentId]) { // Reply to another comment byId[parentId].replies.push(byId[c.id]); } }); return roots; } ``` --- ## Error Handling ### WebSocket OK Message Prefixes - `blocked:` - Policy rejection (banned, rate limited, kind not allowed) - `invalid:` - Structural issue (missing tags, bad signature) ### REST API Errors ```json { "error": "Video not found" } ``` HTTP Status Codes: - `200` - Success - `400` - Bad request (missing params, validation error) - `401` - Unauthorized (missing NIP-98 auth) - `403` - Forbidden (wrong pubkey for protected endpoint) - `404` - Not found - `500` - Internal server error --- ## Rate Limits & Best Practices ### REST API rate limits Limits are applied per client IP, using a token bucket. Requests over the limit receive `429 Too Many Requests`. | Bucket | Sustained rate | Burst | Applies to | |---|---|---|---| | General | 300 req/min (1 token / 200ms) | 50 | Every endpoint not listed below | | Search | 60 req/min (1 token / 1s) | 10 | `/api/search`, `/api/search/profiles`, `/api/v2/search`, `/api/v2/search/profiles` | | Bulk | 120 req/min (1 token / 500ms) | 20 | `/api/users/bulk`, `/api/videos/bulk`, `/api/videos/stats/bulk`, `/api/videos/revisions/bulk` | Buckets refill continuously rather than resetting on a fixed window, so the sustained rate is what you can hold indefinitely; the burst is headroom for short spikes only. On a `429`, two headers tell you how long to wait, both in seconds: ``` retry-after: 3 x-ratelimit-after: 3 ``` Honour `retry-after` rather than retrying immediately. Successful responses do not currently carry `x-ratelimit-remaining`, so track your own request budget if you need to stay under the limit proactively. ### Rate limits are per-IP, not per-key There is no API key or per-application quota. Clients sharing an egress IP (corporate NAT, a serverless platform, a shared proxy) share a single bucket. If you are building something that runs server-side at scale, that shared bucket is usually the constraint you will hit first. ### Caching Most unauthenticated `GET` responses are served from a CDN edge cache, so they never consume rate limit budget at the origin. Cache lifetimes vary by endpoint: | Endpoint | `Cache-Control` | |---|---| | Video lists (`/api/videos`, `/api/videos/events`) | `public, max-age=5, stale-while-revalidate=25` | | Popular video lists | `public, max-age=300, stale-while-revalidate=300` | | Single video (`/api/videos/{id}`) | `public, max-age=15, stale-while-revalidate=45` | | Video stats/views/likers/reposters | `public, max-age=10, stale-while-revalidate=20` | | Video comments | `public, max-age=60, stale-while-revalidate=60` | | Search | `public, max-age=10, stale-while-revalidate=50` | | Event lookup (`/api/event/{id}`) | `public, max-age=60, stale-while-revalidate=300` | | Calendar events | `public, max-age=10, stale-while-revalidate=20` | | Authenticated endpoints | `private, max-age=30` or `private, max-age=60` | | Errors | `no-store` | `stale-while-revalidate` means a cached response can be served past its `max-age` while a fresh copy is fetched in the background, so list data may be up to `max-age + stale-while-revalidate` seconds old. Check `x-cache` (`HIT`/`MISS`) and `age` on the response to see what you got. Requests carrying an `Authorization` header are never edge-cached and always reach the origin, so authenticated polling consumes rate limit budget far faster than anonymous reads. Use `https://api.divine.video` rather than `https://relay.divine.video/api`. Both work, but the relay hostname is the uncached origin path — slower, and every request counts against your limit. ### Best practices 1. **Respect `Cache-Control` headers** - Don't poll faster than the cache TTL; you will only get the same cached bytes back 2. **Use bulk endpoints** - Prefer `/api/videos/bulk` over multiple single requests 3. **Implement exponential backoff** - On `429` (honouring `retry-after`) and on `5xx` 4. **Prefer the WebSocket relay for live data** - Subscribing beats polling the REST API, and doesn't consume REST rate limit budget 5. **Close WebSocket subscriptions** - Send CLOSE when done to free server resources 6. **Use pagination** - Don't request more than you need 7. **Verify signatures client-side** - Events from REST API are verifiable ### Relay (WebSocket) limits The relay publishes its own limits in its NIP-11 document. Read them at connect time rather than hardcoding these values: ```bash curl -H 'Accept: application/nostr+json' https://relay.divine.video/ ``` Current values: | Limit | Value | |---|---| | Publish burst | 10 events/sec per connection | | Publish sustained | 2 events/sec per connection | | Per-pubkey | 60 events/min | | Connections per IP | 50 | | `max_subscriptions` | 100 per connection | | `max_filters` | 10 per REQ | | `max_limit` | 5000 events | | `max_message_length` | 524288 bytes (512 KB) | | `max_content_length` | 102400 bytes (100 KB) | | `max_event_tags` | 2000 | | `max_subid_length` | 256 | | `created_at` drift tolerance | 60 seconds | Publishing over the limit returns a NIP-01 `OK` message with `false` and a `rate-limited:` prefixed reason. No authentication is required to read or publish. ### WebSocket Connection Lifecycle ```javascript const ws = new WebSocket('wss://relay.divine.video'); ws.onopen = () => { // Connection established - send subscriptions ws.send(JSON.stringify(["REQ", "feed", { kinds: [34236], limit: 50 }])); }; ws.onmessage = (msg) => { const data = JSON.parse(msg.data); const [type, ...rest] = data; switch (type) { case 'EVENT': const [subId, event] = rest; handleEvent(subId, event); break; case 'EOSE': // End of stored events - live events will follow break; case 'OK': const [eventId, success, message] = rest; handlePublishResult(eventId, success, message); break; case 'CLOSED': const [subId, reason] = rest; // Subscription was closed by relay break; } }; ws.onclose = () => { // Implement reconnection with exponential backoff setTimeout(() => reconnect(), 1000); }; // Clean up when done function cleanup() { ws.send(JSON.stringify(["CLOSE", "feed"])); ws.close(); } ``` --- ## Authentication (NIP-98) Protected endpoints require NIP-98 HTTP auth. These endpoints are: - `GET /api/users/{pubkey}/notifications` - View own notifications - `POST /api/users/{pubkey}/notifications/read` - Mark notifications read - `GET /api/users/{pubkey}/analytics` - View own creator analytics - `GET /api/users/{pubkey}/privacy` - View own privacy settings - `PUT /api/users/{pubkey}/privacy` - Update privacy settings - `DELETE /api/users/{pubkey}/privacy` - Remove privacy settings (make account public) - `GET /api/access/{pubkey}` - Check if you have access to view a user's content - `GET /api/moderation/reports` - View moderation reports (admin only) - `GET /api/moderation/labels` - View content labels (admin only) - `GET /api/moderation/trusted-labelers` - List trusted labelers (admin only) - `POST /api/moderation/trusted-labelers` - Add trusted labeler (admin only) - `DELETE /api/moderation/trusted-labelers/{pubkey}` - Remove trusted labeler (admin only) - `POST /management` - Relay management JSON-RPC (admin only, NIP-86 fallback endpoint) - `POST /api/events` - Publish a Nostr event via REST (on relay server, NIP-98 required) ### Auth Header Format ```http Authorization: Nostr ``` ### Auth Event Structure (Kind 27235) ```json { "kind": 27235, "created_at": , "tags": [ ["u", "https://api.divine.video/api/users/{pubkey}/analytics?window=30d"], ["method", "GET"] ], "content": "" } ``` ### Required Tags | Tag | Required | Description | |-----|----------|-------------| | `u` | Always | **Full absolute URL including query parameters.** Must exactly match the URL being requested (scheme + host + path + query string). Per NIP-98 spec. | | `method` | Always | HTTP method: `GET`, `POST`, `PUT`, or `DELETE` | | `payload` | POST/PUT only | SHA256 hex hash of the raw request body bytes. Optional for GET/DELETE (no body). | ### Important Rules 1. **URL must be exact**: The `u` tag must be the complete URL including query parameters. Example: `https://api.divine.video/api/users/abc123.../analytics?window=30d` — not just the path 2. **Time window**: The `created_at` must be within 60 seconds of the server's current time (10s clock skew allowed) 3. **Pubkey match**: The event's `pubkey` must match the `{pubkey}` in the URL path — you can only access your own data 4. **Sign the event**: The event must have a valid Nostr signature from the keypair matching the pubkey 5. **Payload hash**: Only required for requests with a body (POST, PUT). For GET and DELETE, omit the `payload` tag ### Implementation Example ```javascript // Helper: build NIP-98 auth header async function buildNip98Auth(url, method, body = null) { const tags = [ ["u", url], // MUST include query params ["method", method] ]; // Add payload hash for requests with a body if (body) { const hash = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(body) ); const hashHex = Array.from(new Uint8Array(hash)) .map(b => b.toString(16).padStart(2, '0')).join(''); tags.push(["payload", hashHex]); } const authEvent = { kind: 27235, created_at: Math.floor(Date.now() / 1000), tags, content: "" }; const signed = await window.nostr.signEvent(authEvent); return "Nostr " + btoa(JSON.stringify(signed)); } // GET analytics (note: query params included in signed URL) const analyticsUrl = `https://api.divine.video/api/users/${myPubkey}/analytics?window=30d`; const authHeader = await buildNip98Auth(analyticsUrl, "GET"); const res = await fetch(analyticsUrl, { headers: { "Authorization": authHeader } }); // GET notifications const notifUrl = `https://api.divine.video/api/users/${myPubkey}/notifications`; const res2 = await fetch(notifUrl, { headers: { "Authorization": await buildNip98Auth(notifUrl, "GET") } }); // POST mark notifications read (include payload hash) const readUrl = `https://api.divine.video/api/users/${myPubkey}/notifications/read`; const body = JSON.stringify({ notification_ids: ["id1", "id2"] }); const res3 = await fetch(readUrl, { method: "POST", headers: { "Authorization": await buildNip98Auth(readUrl, "POST", body), "Content-Type": "application/json" }, body }); // PUT privacy settings (include payload hash) const privacyUrl = `https://api.divine.video/api/users/${myPubkey}/privacy`; const privacyBody = JSON.stringify({ private: true }); const res4 = await fetch(privacyUrl, { method: "PUT", headers: { "Authorization": await buildNip98Auth(privacyUrl, "PUT", privacyBody), "Content-Type": "application/json" }, body: privacyBody }); ``` ### Common Auth Errors | Status | Error | Likely Cause | |--------|-------|-------------| | 401 | `URL mismatch` | The `u` tag doesn't match the request URL. Check that query params are included and the hostname is correct. | | 401 | `event expired` | The `created_at` is more than 60 seconds old. Generate a fresh event for each request. | | 401 | `invalid signature` | The event signature doesn't verify. Ensure you're signing correctly. | | 401 | `missing 'method' tag` | The auth event is missing the `["method", "GET"]` tag. | | 401 | `payload hash mismatch` | The `payload` tag doesn't match SHA256 of the actual body sent. | | 403 | `You can only view your own` | The auth event's pubkey doesn't match the `{pubkey}` in the URL path. | --- ## REST Event Publishing FunnelCake supports publishing Nostr events via HTTP POST as an alternative to WebSocket. This is useful for server-to-server integrations, mobile clients that prefer REST, or environments where maintaining a WebSocket connection is impractical. ### Endpoint ``` POST https://api.divine.video/api/events ``` > **Note:** Use the canonical production REST host `https://api.divine.video/api/events`. > `https://relay.divine.video/api/events` remains a compatibility path to the same backend. ### Authentication NIP-98 signed authentication is required. The signer's pubkey **must match** the event's `pubkey` field — you can only publish your own events. ### Request ```http POST /api/events HTTP/1.1 Content-Type: application/json Authorization: Nostr { "id": "...", "pubkey": "...", "created_at": 1700000000, "kind": 34236, "tags": [["d", "my-video"], ["title", "My Video"], ...], "content": "", "sig": "..." } ``` The NIP-98 auth event must include a `payload` tag with the SHA256 hex of the request body, since the body contains data: ```json { "kind": 27235, "created_at": , "tags": [ ["u", "https://api.divine.video/api/events"], ["method", "POST"], ["payload", ""] ], "content": "" } ``` ### Response ```json { "event_id": "abc123...", "accepted": true, "message": "" } ``` ### Status Codes | Status | Meaning | Example `message` | |--------|---------|-------------------| | 200 | Event accepted and queued | `""` | | 400 | Malformed request body | `"Invalid event JSON: ..."` | | 401 | Missing or invalid NIP-98 auth | `"Auth failed: invalid signature"` | | 403 | NIP-98 signer ≠ event author | `"NIP-98 signer pubkey does not match event author"` | | 422 | Relay rejected the event | `"invalid: video missing required 'd' tag"` | | 500 | Internal server error | `"Internal error processing event"` | | 503 | Publishing not enabled | `"Event publishing not enabled (no relay URL configured)"` | ### Validation Events submitted via REST go through the **exact same validation pipeline** as WebSocket submissions: - Signature verification (Schnorr/secp256k1) - Content length and tag count limits - Banned pubkey/event ID checks - Kind allowlist filtering - Video-specific field requirements (d-tag, title, source, thumbnail) - Comment NIP-22 tag requirements (K, k, E, e) - Parameterized replaceable event d-tag requirement - Deletion authorization checks - NIP-40 expiration enforcement Accepted events are broadcast to WebSocket subscribers, synced to Gorse recommendations, and queued for moderation enrichment — identical to WebSocket. ### Example: Publish a Video Event ```javascript import { finalizeEvent, getPublicKey } from 'nostr-tools'; import { sha256 } from '@noble/hashes/sha256'; import { bytesToHex } from '@noble/hashes/utils'; const sk = /* your secret key bytes */; const pubkey = getPublicKey(sk); // 1. Build the Nostr event const event = finalizeEvent({ kind: 34236, created_at: Math.floor(Date.now() / 1000), tags: [ ['d', 'my-video-id'], ['title', 'My Video'], ['imeta', 'url https://example.com/video.mp4', 'm video/mp4', 'image https://example.com/thumb.jpg'], ], content: '', }, sk); const body = JSON.stringify(event); // 2. Build the NIP-98 auth event const payloadHash = bytesToHex(sha256(new TextEncoder().encode(body))); const authEvent = finalizeEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), tags: [ ['u', 'https://api.divine.video/api/events'], ['method', 'POST'], ['payload', payloadHash], ], content: '', }, sk); const authHeader = 'Nostr ' + btoa(JSON.stringify(authEvent)); // 3. POST it const resp = await fetch('https://api.divine.video/api/events', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': authHeader, }, body, }); const result = await resp.json(); console.log(result); // { event_id: "...", accepted: true, message: "" } ``` --- ## NIP-86 Management API FunnelCake exposes relay management over NIP-86. This is **HTTP JSON-RPC**, not a Nostr WebSocket command. Clients send signed `POST` requests with `Content-Type: application/nostr+json+rpc`. ### Endpoint Shape There are two repo-specific entrypoints for the same management RPC methods: - **Relay root**: `POST https://relay.divine.video/` - This is the NIP-86 shape described by the draft: send an HTTP request to the same URI that also supports WebSocket upgrades. - **API fallback**: `POST https://relay.divine.video/management` - This exists so relay management still works when you are integrating directly against the funnel API service or using a degraded-path fallback. Use the same JSON-RPC body for either endpoint, but the NIP-98 `u` tag must match the exact URL you are calling. ### Required Headers ```http Content-Type: application/nostr+json+rpc Authorization: Nostr ``` The `Authorization` event follows the same kind `27235` NIP-98 rules described above, with one additional practical rule for management requests: because the request has a body, include a `payload` tag containing the SHA256 hex of the raw JSON body bytes. ### Request Format ```json { "method": "supportedmethods", "params": [] } ``` - `method` is the lowercase RPC method name - `params` is always a JSON array, even when empty ### Response Format ```json { "result": ["supportedmethods", "banpubkey"], "error": null } ``` On error: ```json { "result": null, "error": "Missing pubkey parameter" } ``` ### Supported Methods In This Repo Today These are the methods implemented by the current codebase. **Discovery** - `supportedmethods` **Pubkey management** - `banpubkey` - `unbanpubkey` - `listbannedpubkeys` - `allowpubkey` - `listallowedpubkeys` - `suspendpubkey` - `unsuspendpubkey` - `listsuspendedpubkeys` **Note:** Upstream NIP-86 drafts mention an allowlist-removal method such as `unallowpubkey`, but this repo does **not** currently expose that RPC method. Document against repo behavior, not the aspirational draft surface. **Event moderation** - `listeventsneedingmoderation` - `allowevent` - `banevent` - `listbannedevents` - `getbannedevent` **Relay configuration** - `changerelayname` - `changerelaydescription` - `changerelayicon` **Kind filtering** - `allowkind` - `disallowkind` - `listallowedkinds` **IP blocking** - `blockip` - `unblockip` - `listblockedips` **Tag blocking** - `bantag` - `unbantag` - `listbannedtags` ### Method Semantics #### `supportedmethods` Lists the RPC methods supported by the server. **Request body:** ```json { "method": "supportedmethods", "params": [] } ``` **Response shape:** ```json { "result": [ "supportedmethods", "banpubkey", "unbanpubkey", "listbannedpubkeys" ], "error": null } ``` #### `banpubkey` Ban a pubkey and purge its stored content. This is destructive; removing the ban later does not restore purged events. Use `suspendpubkey` when the action must be reversible and must not purge stored content. A ban blocks ordinary publishing. It is not a retention hold: a banned pubkey can still submit signed NIP-09 deletion and NIP-62 vanish requests for its own content, exactly as a suspended pubkey can. **Params:** `["<64-char-hex-pubkey>", ""]` #### `unbanpubkey` Remove a pubkey ban. **Params:** `["<64-char-hex-pubkey>"]` This removes only the ban record. It does not restore content purged by `banpubkey`. #### `listbannedpubkeys` Return ban records. The repo returns objects such as: ```json { "pubkey": "64-char-hex", "reason": "spam", "banned_at": "2026-03-15T12:00:00Z", "banned_by": "64-char-hex-admin-pubkey" } ``` #### `suspendpubkey`, `unsuspendpubkey`, `listsuspendedpubkeys` Suspend or restore ordinary publishing without deleting the pubkey's stored content. Suspension is not a storage purge, but it does immediately hide the pubkey's existing events from standard public read paths until the pubkey is unsuspended. A suspended pubkey can still submit signed NIP-09 deletion and NIP-62 vanish requests for its own content. Use suspension when an appeal must be reversible. - `suspendpubkey` params: `["<64-char-hex-pubkey>", ""]` - `unsuspendpubkey` params: `["<64-char-hex-pubkey>"]` - `listsuspendedpubkeys` params: `[]` #### `allowpubkey` Add a pubkey to the relay allowlist. **Params:** `["<64-char-hex-pubkey>", ""]` #### `listallowedpubkeys` Return allowlist records. The repo returns objects such as: ```json { "pubkey": "64-char-hex", "reason": "trusted integration", "added_at": "2026-03-15T12:00:00Z", "added_by": "64-char-hex-admin-pubkey" } ``` #### `listeventsneedingmoderation` Return event records pending moderation review. The repo returns richer objects than the minimal draft examples, including fields such as: ```json { "id": "64-char-hex", "pubkey": "64-char-hex", "kind": 34236, "created_at": "2026-03-15T12:00:00Z", "content": "raw event content" } ``` #### `allowevent` Remove an event from the banned-events set. **Params:** `["<64-char-hex-event-id>"]` #### `banevent` Ban an event. **Params:** `["<64-char-hex-event-id>", ""]` #### `listbannedevents` Return banned event records. #### `getbannedevent` Fetch the full raw banned event for admin review. **Params:** `["<64-char-hex-event-id>"]` #### `changerelayname`, `changerelaydescription`, `changerelayicon` Update relay metadata values persisted by the management storage backend. #### `allowkind`, `disallowkind`, `listallowedkinds` Manage allowed event kinds. The repo returns objects for listed kinds: ```json { "kind": 34236, "added_at": "2026-03-15T12:00:00Z" } ``` #### `blockip`, `unblockip`, `listblockedips` Manage IP-level blocking records. #### `bantag`, `unbantag`, `listbannedtags` Manage blocked tag values. - `bantag` params: `["", "", ""]` - `unbantag` params: `["", ""]` - `listbannedtags` params: `[]` **The tag value is optional, and omitting it bans the whole tag name.** When `` is absent, both `bantag` and `unbantag` substitute the wildcard `"*"`, so `bantag ["L"]` bans every value of the `L` tag rather than failing. This is high blast radius — it can reject a broad class of events at ingest. Always pass an explicit `` unless a wildcard ban is what you intend, and note that unbanning a wildcard requires the same omission (or an explicit `"*"`); removing individual values will not clear it. ### Example: `supportedmethods` via `/management` ```javascript const managementUrl = "https://relay.divine.video/management"; const body = JSON.stringify({ method: "supportedmethods", params: [] }); const res = await fetch(managementUrl, { method: "POST", headers: { "Authorization": await buildNip98Auth(managementUrl, "POST", body), "Content-Type": "application/nostr+json+rpc" }, body }); const rpc = await res.json(); console.log(rpc.result); ``` ### Example: `banpubkey` via `/management` ```javascript const managementUrl = "https://relay.divine.video/management"; const body = JSON.stringify({ method: "banpubkey", params: [ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "spam" ] }); const res = await fetch(managementUrl, { method: "POST", headers: { "Authorization": await buildNip98Auth(managementUrl, "POST", body), "Content-Type": "application/nostr+json+rpc" }, body }); const rpc = await res.json(); ``` ### Example: relay root instead of `/management` If you call the relay root directly, sign the root URL itself: ```javascript const relayRootUrl = "https://relay.divine.video/"; const body = JSON.stringify({ method: "supportedmethods", params: [] }); await fetch(relayRootUrl, { method: "POST", headers: { "Authorization": await buildNip98Auth(relayRootUrl, "POST", body), "Content-Type": "application/nostr+json+rpc" }, body }); ``` ### NIP-86 vs `/api/moderation/*` Do not treat these as the same surface: - Use **NIP-86** for relay-management RPC operations such as banning pubkeys, changing relay metadata, or blocking IPs. - Use **`/api/moderation/*` REST endpoints** for admin reporting, label review, and trusted-labeler management. - NIP-86 is JSON-RPC over HTTP `POST`. - `/api/moderation/*` uses ordinary REST paths, query parameters, and JSON request/response bodies. --- ## Useful Queries ### Get videos by a specific creator ``` GET /api/users/{pubkey}/videos?limit=50 ``` ### Get trending short-form videos ``` GET /api/videos?sort=trending&kind=34236&limit=20 ``` ### Search for videos about Bitcoin ``` GET /api/search?q=bitcoin&limit=30 ``` ### Get a user's home feed (personalized timeline) ``` GET /api/users/{pubkey}/feed?sort=recent&limit=50 ``` ### Get classic Vine archive videos ``` GET /api/videos?platform=vine&sort=loops&limit=50 ``` ### Check if specific videos exist ``` POST /api/videos/stats/bulk {"event_ids": ["id1", "id2", "id3"]} ``` ### Get comments on a video (REST) ``` GET /api/videos/{id}/comments?sort=newest&limit=25 ``` Use REST when you need a simple paginated comment list with author metadata. ### Subscribe to comments on a video (WebSocket) ```json ["REQ", "comments", { "kinds": [1111], "#E": [""] }] ``` Use WebSocket when you need live updates or raw Nostr comment events. The `#E` (uppercase) tag matches all comments on a video regardless of nesting depth. Use `#e` (lowercase) to get direct replies to a specific comment. ### Get reactions/comments on MY videos (notifications) Subscribe to events that tag you in `p`: ```json ["REQ", "notifications", { "kinds": [7, 1111, 16], "#p": [""], "since": }] ``` This gives you: - `kind 7` - Reactions on your videos - `kind 1111` - Comments on your videos - `kind 16` - Reposts of your videos The `#p` filter matches because reactions/comments include a `p` tag pointing to the video author. --- ## Content Moderation FunnelCake includes a content moderation system based on NIP-32 (labels) and NIP-56 (reports). Content labels are automatically applied by the divine-moderation-service, which uses a VLM (Vision-Language Model) classifier to analyze video thumbnails and content. Trusted labelers can also tag content with labels like `nsfw`, and those labels are used to filter content from public endpoints. ### Trust And Safety Contract The current public contract is: - `content_labels` is discovery/classifier-only in public video responses. - `moderation_labels` is the resolved trust-and-safety label array for the active moderation profile. - `moderation_profile=default|strict|user` selects the policy used to resolve labels and hide content. `user` requires NIP-98 authentication. - `moderation_status`, `ai_score`, `ai_source`, and `ai_checked_at` are moderation pipeline metadata derived only from active labels with confidence at least `0.8` in the moderation vocabulary (`moderation_status` is the newest such label, canonicalized; `ai_source` is set only by `ai-generated`, `deepfake`, or `synthetic-media`). They are omitted when a video carries only classifier, retracted, or lower-confidence labels, and they do not replace a moderation-only label array. - Accepted kind `1985` label events are stored by the relay. Only kind `1985` events authored by pubkeys in `nostr.trusted_labelers` are projected into the ClickHouse `content_labels` table and the `nsfw_labeled_events_set` used by server-side moderation filters. - Accepted kind `1984` report events are stored by the relay. Report aggregation projects events with an `e` target tag into `nostr.report_counts`; a `report` tag populates the report type when present. Kind `1985` label events should use standard NIP-32 `L` and `l` tags. For example, a content-warning label event can include: ```json [ ["L", "content-warning"], ["l", "nudity", "content-warning"], ["e", ""] ] ``` Kind `1984` report events should include an `e` tag for the reported event. FunnelCake stores all accepted reports, and the moderation reports endpoint aggregates projected report rows for admin review. Live examples seen on production and staging include both moderation labels and discovery labels: - `moderation_labels: ["nudity","sexual"]` - `["activity:listening-to-music","topic:gaming","topic:music"]` ### Label Merge And Normalization All video response objects include a `content_labels` field (array of strings). The response merger currently: 1. Reads discovery/classifier labels from the ClickHouse `content_labels` table. 2. Merges non-moderation raw NIP-32 `l` tags from the video event itself. 3. Excludes `content-warning` labels and known moderation semantic labels from `content_labels`. 4. Resolves `moderation_labels` from the profile-aware moderation read model keyed by media `sha256`. Normalization rules: - Language self-labels such as `["l", "en", "ISO-639-1"]` are excluded. - `["l", "music", "topic"]` becomes `topic:music`. - `["l", "nudity", "content-warning"]` is excluded from `content_labels` and can appear in `moderation_labels`. - Other namespaced labels are represented as `namespace:value`. - Exact duplicate strings are removed. First-seen order is preserved. Moderation labels use profile/source precedence; discovery labels use exact-string deduping. ```json { "id": "64-char-hex", "title": "Video Title", "content_labels": ["safe"] } ``` The field may be an empty array `[]` if the video has not yet been classified and has no applicable raw labels. ### Stable Moderation Vocabulary Use `moderation_labels` for trust-and-safety decisions: | Class | Labels | Server behavior today | Client guidance | |------|--------|------------------------|-----------------| | Always blocked | `illegal`, `csam` | Always hidden on public discovery/search surfaces. No opt-out. | Treat as hard block. | | Default-hidden moderation | `porn`, `graphic-media`, `self-harm`, `spam`, `scam` | Hidden by the default moderation profile. | Safe to map to hide or warn policies. | | Strict-hidden moderation | `nudity`, `sexual`, `porn`, `graphic-media`, `violence`, `self-harm`, `spam`, `scam` | Hidden by the strict moderation profile. | Use for clients that need a more conservative default. | | Not part of the stable hide taxonomy | examples: `safe`, `profanity`, `topic:*`, `activity:*`, `mood:*`, `object:*`, `setting:*`, source/archive labels | Not part of the documented default server hide contract unless separately documented. | Use for discovery, ranking, or client-side warning policy as appropriate. | ### Server-Side Filtering Contract Supported today: - `/api/videos` - Supports `content_safety=default|adult|open`, `moderation_profile=default|strict|user`, legacy `nsfw=show`, `exclude_label=` (max 5), `label=`, and `category=`. - `moderation_profile=user` requires NIP-98 authentication. - `label=` is not moderation-only. - Current positive-match behavior checks stored ClickHouse labels plus raw NIP-32 self-labels. It should not be treated as a full parity guarantee with every response-enriched label source. - `category=` is shorthand for `label=topic:`. - `/api/search` - Supports `label=` and `exclude_label=` (max 5). - The handler also parses `content_safety` and legacy `nsfw=show`, but current search queries still apply the default public NSFW filter. Do not depend on search for NSFW opt-in parity with `/api/videos`. - `/api/users/{pubkey}/recommendations` - Supports `content_safety=default|adult|open` and legacy `nsfw=show`. - Does not expose `exclude_label` today. - On the current implementation, `adult` and `open` behave the same because per-label exclusions are not wired on this endpoint. Still not a stable public contract: - There is no separate `exclude_moderation_labels` parameter today. - Clients should not assume endpoint parity for every safety control. Not supported on these endpoints today: - `/api/users/{pubkey}/videos` returns labels, but does not expose `nsfw`, `content_safety`, `moderation_profile`, or `exclude_label`. - `/api/users/{pubkey}/feed` returns labels, but does not expose `nsfw`, `content_safety`, `moderation_profile`, or `exclude_label`. ### Relay vs Client Responsibilities - Relay responsibility - Always block `illegal` and `csam`. - Hide the default-hidden moderation vocabulary by default on endpoints that expose server-side safety filtering. - Client responsibility - Treat `content_labels` as discovery/classifier metadata. - Use `moderation_labels` for trust-and-safety UI. - Apply warning, blur, confirmation, or additional hide logic for returned content based on product policy. - Do not infer moderation meaning from discovery labels like `topic:*` or `activity:*`. ### Endpoint Parity Caveat The API code uses the same label-enrichment path for list rows, single-video stats, per-user videos, and feed rows. However, deployment parity still needs to be validated per environment. As of March 9, 2026, staging still showed a concrete gap: videos visible via `/api/videos?sort=recent&nsfw=show` could still return `404` from `/api/videos/{id}` for the same full event IDs. Treat staging list-vs-single parity as best-effort until that host is refreshed. ### Content Safety Filtering Documented behavior today is endpoint-specific: - `/api/videos` - `content_safety=default` hides the default-hidden moderation vocabulary. - `content_safety=adult` shows adult content while still excluding `gore` and `spam`. - `content_safety=open` shows all legal content. - Legacy `nsfw=show` is equivalent to `content_safety=open`. - `exclude_label` adds up to 5 explicit label exclusions. - `/api/search` - `exclude_label` adds up to 5 explicit label exclusions. - `label` positive matching works. - `content_safety` and `nsfw` are parsed, but current search queries still apply the default public NSFW filter. - `/api/users/{pubkey}/recommendations` - `content_safety` and legacy `nsfw=show` control whether recommended NSFW items are included. - `adult` and `open` currently behave the same because this endpoint does not apply per-label exclusions. - `exclude_label` is not supported. - `/api/users/{pubkey}/videos` and `/api/users/{pubkey}/feed` - Return `content_labels`, but do not expose safety query parameters today. ``` GET /api/videos?content_safety=adult&exclude_label=drugs,violence GET /api/search?q=cooking&exclude_label=gore,spam GET /api/users/{pubkey}/recommendations?content_safety=open ``` `illegal` and `csam` remain blocked regardless of endpoint or query parameters. ### Moderation Endpoints All moderation endpoints require NIP-98 authentication with an admin pubkey. These are for platform administration, not regular users. These are REST endpoints, not the NIP-86 JSON-RPC management surface documented above. #### GET /api/moderation/reports Get aggregated moderation reports (Kind 1984 report events). **Query:** `limit` (1-100, default 50), `offset` (default 0) **Response:** ```json { "reports": [ { "target_event_id": "64-char-hex", "report_count": 5, "unique_reporters": 3, "latest_report_at": "2026-02-24T10:30:00Z", "report_types": ["spam", "nsfw"] } ], "total": 100, "offset": 0, "limit": 50 } ``` #### GET /api/moderation/labels Get content labels applied by the VLM classifier and trusted labelers. **Query Parameters:** | Param | Type | Description | |-------|------|-------------| | `limit` | number | Max results (1-100, default 50) | | `offset` | number | Pagination offset (default 0) | | `label` | string | Filter by specific label value (e.g. `nsfw`, `safe`, `violence`) | **Response:** ```json { "labels": [ { "target_event_id": "64-char-hex", "label_value": "nsfw", "label_namespace": "com.example.labeler", "labeler_pubkey": "64-char-hex", "labeled_at": "2026-02-24T10:30:00Z" } ], "total": 50, "offset": 0, "limit": 50 } ``` #### GET /api/moderation/trusted-labelers Get the list of trusted labelers whose labels affect content filtering. **Response:** ```json { "labelers": [ { "pubkey": "64-char-hex", "added_at": "2026-02-24T10:30:00Z", "reason": "Divine official moderator" } ] } ``` #### POST /api/moderation/trusted-labelers Add a trusted labeler. **Request:** ```json { "pubkey": "64-char-hex", "reason": "Trusted community moderator" } ``` **Response:** ```json { "status": "ok" } ``` #### DELETE /api/moderation/trusted-labelers/{pubkey} Remove a trusted labeler. **Response:** ```json { "status": "ok" } ``` --- ## Relay Information (NIP-11) Discover relay capabilities by fetching the root URL with `Accept: application/nostr+json`: ```bash curl -H "Accept: application/nostr+json" https://relay.divine.video/ ``` **Response:** ```json { "name": "Funnelcake", "description": "High-performance Nostr relay with ClickHouse backend", "supported_nips": [1, 4, 9, 11, 16, 18, 22, 23, 25, 32, 33, 40, 45, 50, 51, 52, 56, 62, 71, 86, 94, 98], "software": "divine-funnelcake", "version": "0.1.0", "limitation": { "max_message_length": 524288, "max_subscriptions": 100, "max_filters": 10, "max_limit": 5000, "max_subid_length": 256, "max_event_tags": 2000, "max_content_length": 102400, "auth_required": false, "payment_required": false, "restricted_writes": false } } ``` --- ## Private Accounts FunnelCake supports private accounts. When a user makes their account private, their video content is hidden from public queries. Only approved viewers can access private content. ### How Private Accounts Affect Queries - **Public video endpoints** (`/api/videos`, `/api/search`, `/api/hashtags/trending`, `/api/leaderboard/*`, `/api/users/{pubkey}/feed`): Videos from private accounts are **excluded** from results - **User profile endpoint** (`/api/users/{pubkey}`): Still returns the profile. Client apps should check and display a "Private Account" indicator - **User videos/collabs** (`/api/users/{pubkey}/videos`, `/api/users/{pubkey}/collabs`): Returns empty for private accounts - **Direct video lookup** (`/api/videos/{id}`): If you have the event ID, you can still access the video (privacy is about discovery, not direct access) ### Privacy Management Endpoints All privacy management endpoints require NIP-98 authentication. Users can only manage their own privacy settings. #### Get Privacy Settings ```http GET /api/users/{pubkey}/privacy Authorization: Nostr ``` Response: ```json { "pubkey": "abc123...", "is_private": true, "approved_viewers": ["viewer_pubkey_1", "viewer_pubkey_2"] } ``` If no privacy settings exist, returns `is_private: false` with empty `approved_viewers`. #### Update Privacy Settings ```http PUT /api/users/{pubkey}/privacy Authorization: Nostr Content-Type: application/json { "is_private": true, "approved_viewers": ["viewer_pubkey_1", "viewer_pubkey_2"] } ``` **Important:** This is a full replacement -- send the complete list of approved viewers each time. Going private takes effect immediately. #### Delete Privacy Settings ```http DELETE /api/users/{pubkey}/privacy Authorization: Nostr ``` Makes the account public and removes all privacy settings. #### Check Access ```http GET /api/access/{pubkey} Authorization: Nostr ``` Response: ```json { "pubkey": "abc123...", "has_access": true } ``` Returns whether the authenticated viewer has access to the target user's content. Useful for Blossom integration and client apps. ### WebSocket Relay (NIP-42) The relay does not currently support NIP-42 authentication. It does not send an `AUTH` challenge or accept kind-22242 authentication events, so clients must not depend on authenticated `REQ` queries for private content. Issue #591 tracks support for NIP-42 and kind-1059 read gating; PR #1116 contains the in-flight implementation, which ships inert until the relay's NIP-42 handshake and kind-1059 gate are enabled. --- ## API Documentation Endpoints Interactive documentation is available at: | Endpoint | Description | |----------|-------------| | `/docs` | Documentation landing page with links to all resources | | `/docs/llm-guide` | This LLM API guide rendered as HTML | | `/swagger-ui` | Interactive OpenAPI/Swagger UI with live request testing | | `/openapi.json` | Raw OpenAPI 3.0 JSON specification | **Production URLs:** - `https://api.divine.video/docs` - `https://api.divine.video/swagger-ui` **Staging URLs:** - `https://relay.staging.divine.video/docs` - `https://relay.staging.divine.video/swagger-ui` --- # FunnelCake GraphQL API FunnelCake exposes a read-only GraphQL beta alongside the existing WebSocket relay and REST API. ## Endpoint - GraphQL: `https://relay.divine.video/graphql` - GraphiQL: `https://relay.divine.video/graphiql` when enabled on the server If `API_TOKEN` is configured, `POST /graphql` follows the same bearer-token rule as the REST `/api/*` surface. ## When to Use It - Use WebSocket for Nostr event publishing, subscriptions, and real-time relay behavior. - Use REST for operational endpoints, simple resource fetches, RSS feeds, and admin or management flows. - Use GraphQL for first-party or partner clients that need composed read models in one round trip. GraphQL v1 is intentionally limited: - read-only - no mutations - no subscriptions - no moderation, privacy, notification, or management fields - focused on product-facing reads - generic event graph is bounded and read-only, not a full relay replacement ## Query Conventions - Connections use `first` and `after`. - Cursor fields are opaque. Do not parse them on the client. - `apiLimits` exposes the active depth, complexity, page-size, and event-request caps. - Search and recommendation behavior follows the same content-safety defaults as REST. - Recommendation fallback uses the same shared logic as REST when Gorse is unavailable. - `events(filter: ...)` accepts a bounded Nostr-style filter input (`ids`, `authors`, `kinds`, `since`, `until`, `search`, `tagFilters`). - `count(filter: ...)` returns the exact cardinality for the same filter shape. - `search` can be used on the first page of `events`, but `search + after` is rejected because relevance ordering is not cursor-stable. ## Social Counts `User.social.followerCount` and `User.social.followingCount` are nullable integers. They follow the same field-level contract as the REST API: - `null` means the count is unavailable, incomplete, not yet indexed, or could not be read from storage. A `null` in one field does not invalidate a numeric value in the other field. - `0` is an authoritative count for an indexed user with no matching relationships. - `followerCount` counts accounts whose latest indexed kind-3 contact list still follows the profile owner, excluding accounts in the profile owner's latest indexed kind-10000 block list. The direction is owner-to-follower: a follower blocking the owner does not change the owner's count. `searchProfiles` hydrates these counts from the same canonical source as the `user` query and the REST social endpoint, so a search result and the profile page report the same value. If that canonical read fails, the search still resolves and both counts are `null`; a raw search-index count is never presented as authoritative. Counts can differ from other Nostr applications because services may have indexed different portions of the public relay network. Block-list and social-summary indexing refresh independently, so a newly published block or unblock can take up to two refresh windows plus the endpoint cache lifetime to appear. A profile's first follow edge can likewise read as zero until the next social-summary refresh. Clients that generate strict types from the schema must regenerate them: `followerCount` and `followingCount` changed from non-null integers to nullable integers. ## Event Graph GraphQL now exposes a generic event surface in addition to the curated video and discovery queries: - `event(id: ...)` returns the `Event` interface - `events(filter: ..., first: ..., after: ...)` returns an `EventConnection` - `count(filter: ...)` returns an exact count for the same filter - `referencedBy(...)` hangs off every `Event` node and supports reverse traversal by `e` and `a` tags `Event` is implemented by: - `Video` - `GenericEvent` ## Example: Video Lookup ```graphql query VideoById { video(id: "event-or-d-tag") { id title content author { pubkey displayName picture } stats { reactions comments reposts loops views } contentLabels } } ``` ## Example: Generic Event Search ```graphql query EventSearch { events( filter: { authors: ["64-char-hex-pubkey"] tagFilters: [{ name: "t", values: ["graphql"] }] } first: 10 ) { edges { cursor node { __typename id pubkey kind content ... on Video { title } ... on GenericEvent { sig tags } } } pageInfo { hasNextPage endCursor } } } ``` ## Example: Exact Count ```graphql query CountEvents { count( filter: { authors: ["64-char-hex-pubkey"] tagFilters: [{ name: "t", values: ["graphql"] }] } ) } ``` ## Example: Reverse Traversal ```graphql query ReferencedBy { event(id: "64-char-hex-event-id") { __typename id referencedBy(kinds: [1111], first: 20) { edges { node { __typename id kind content } } pageInfo { hasNextPage endCursor } } } } ``` ## Example: Feed Query ```graphql query UserFeed { userFeed(pubkey: "64-char-hex-pubkey", first: 20) { edges { cursor node { id title author { displayName } } } pageInfo { hasNextPage endCursor } } } ``` ## Example: Profile Search ```graphql query SearchProfiles { searchProfiles(query: "alice", first: 10, sortBy: "followers", hasVideos: true) { edges { node { pubkey displayName nip05 social { followerCount } } } pageInfo { hasNextPage endCursor } } } ``` ## Example: Recommendations ```graphql query Recommendations { recommendations(pubkey: "64-char-hex-pubkey", first: 12, fallback: "recent") { source edges { node { id title author { displayName } } } pageInfo { hasNextPage endCursor } } } ``` `source` is one of: - `personalized` - `popular` - `recent` ## Discovery Queries GraphQL v1 also exposes read-only discovery fields for: - `searchVideos` - `hashtags` - `trendingHashtags` - `categories` - `leaderboardVideos` - `leaderboardCreators` - `calendarEvent` - `calendarCollection` - `calendarEvents` These are designed as a curated product schema, not a one-to-one mirror of REST paths. ## Calendar Queries GraphQL exposes NIP-52 calendar read models backed by ClickHouse: - `calendarEvent(coordinate: String!)` - `calendarCollection(coordinate: String!)` - `calendarEvents(first:, after:, order:, where:)` Calendar coordinates use the standard NIP-52 address format: `kind:pubkey:d-tag`. `calendarEvents.where` currently supports: - `hostPubkey` - `topic` - `near` - `startsBetween { from to }` - `attending` - `includePast` `calendarEvent` also exposes nested read-only fields for: - `videos` - `rsvps` - `rsvpCounts` - `parentCalendars` ## Example: Calendar Event Lookup ```graphql query CalendarEvent { calendarEvent(coordinate: "31923:64-char-hex-pubkey:town-hall") { coordinate title summary start geohashes participants { pubkey } rsvpCounts { accepted tentative declined } rsvps(first: 10) { edges { node { pubkey status freeBusy } } } videos(first: 5) { edges { node { id title videoUrl } } } } } ``` ## Example: Calendar Discovery ```graphql query CalendarDiscovery { calendarEvents( first: 20 order: START_ASC where: { hostPubkey: "64-char-hex-pubkey" topic: "meetup" includePast: false } ) { edges { cursor node { coordinate title start geohashes } } pageInfo { hasNextPage endCursor } } } ``` ## Notes for Client Builders - Prefer persisted client queries or a small, controlled query set if you ship third-party apps. - Expect depth, complexity, and page-size limits. - Keep GraphQL for composed reads; keep write paths on WebSocket or REST.