FrogTalk API Reference

HTTP and WebSocket endpoints for chat, social, bots, federation, and server administration. Lists show the HTTP method first, then the path.

Base URL: https://your-host/api · Session: X-Session-Token · Bots/integrations: X-API-Key or Authorization: Bearer <key> · JSON bodies unless noted

Methods: GET read POST create / action PATCH partial update PUT replace DELETE remove

Auth

PREFIX /api/auth · most routes need X-Session-Token
POST   /api/auth/register
POST   /api/auth/login
POST   /api/auth/logout
PATCH  /api/auth/nickname
PATCH  /api/auth/display-name
GET    /api/auth/me
PATCH  /api/auth/profile
PATCH  /api/auth/client-prefs
PATCH  /api/auth/pin/options
DELETE /api/auth/account
GET    /api/auth/captcha
POST   /api/auth/register-secure
POST   /api/auth/recovery-key
POST   /api/auth/recover
POST   /api/auth/verify-recovery-key
POST   /api/auth/federation-ticket
POST   /api/auth/federation-ticket-login
GET    /api/auth/federation-sync-status
POST   /api/auth/federation-sync-resume
POST   /api/auth/federation-sync-reset
POST   /api/auth/repin-account-home
POST   /api/auth/federation-sync-profile-gid
GET    /api/auth/federation-sync-export
POST   /api/auth/federation-sync-export-ticket
POST   /api/auth/federation-sync-export-gid
POST   /api/auth/federation-sync-memberships-gid
GET    /api/federation/profile-card

Account sync (visit another node)

When you log in on a community node, the home server can export a signed bundle: joined channels (themes, slowmode, invites), DM thread prefs, friends/following, profile, and FrogSocial posts (paginated). Stories and the notification inbox are not bulk-imported — they arrive over federation after login.

Peer export routes (federation-sync-export-gid, -ticket, -profile-gid, -memberships-gid) use federation auth: Ed25519 signed headers (same as the federation inbox), plus optional X-Federation-Token when FROGTALK_FEDERATION_AUTH_MODE=dual. Imports check export_sig_b64 against the pinned home pubkey when FROGTALK_SYNC_REQUIRE_EXPORT_SIG=1 (default).

Partial updates — profile, nickname, client prefs

Use PATCH when you only want to change some fields. Omitted JSON keys are left as-is. Invalid preferred_node_url400 with {"error":"invalid preferred_node_url"}.

  • PATCH /api/auth/nickname — change handle
  • PATCH /api/auth/display-name — display name shown in chat
  • PATCH /api/auth/profile — avatar, status, theme, etc.
  • PATCH /api/auth/pin/options — app PIN / admin gate options
  • PATCH /api/auth/client-prefs — Tor preference, preferred node URL, notification sounds (synced on account export)

Example: client preferences

PATCH /api/auth/client-prefs
X-Session-Token: <session>
Content-Type: application/json

{
  "prefer_onion": true,
  "preferred_node_url": "https://frogtalk.app",
  "custom_sounds": {
    "app:msg": "data:audio/wav;base64,…",
    "app:ring": "data:audio/wav;base64,…"
  }
}

# Response
{ "ok": true, "client_prefs": { … } }

preferred_node_url — clearnet https:// or http:// (max 512 chars), or a bare .onion hint. custom_sounds — only app:msg and app:ring (base64 or URLs under /api/auth/app-sounds/{user_id}/{key}).

GET /api/auth/me returns your session user, client_prefs, and custom_style (custom_css editor blob is self-only).

GET /api/federation/profile-card?nickname=&ft_gid= — federated profile card (sanitized styles; optional wall when the user is at home).

Users + Friends

PREFIX /api/users and /api/friends
GET    /api/users
GET    /api/users/{user_id}
POST   /api/users/{user_id}/block
DELETE /api/users/{user_id}/block
GET    /api/users/me/blocked
GET    /api/users/me/bans
GET    /api/users/{user_id}/aliases

GET    /api/users/search
GET    /api/users/profile/{nickname}
GET    /api/users/me/tags
POST   /api/users/me/tags
DELETE /api/users/me/tags/{tag}
POST   /api/users/me/presence

POST   /api/friends/request/{nickname}
POST   /api/friends/accept/{nickname}
POST   /api/friends/decline/{nickname}
POST   /api/friends/cancel/{nickname}
DELETE /api/friends/{nickname}
POST   /api/friends/block/{nickname}
GET    /api/friends

Rooms, Messages, DMs

PREFIX /api/rooms, /api/messages, /api/dms
GET    /api/rooms
POST   /api/rooms
DELETE /api/rooms/{room_name}
GET    /api/rooms/{room_name}
PATCH  /api/rooms/{room_name}
POST   /api/rooms/{room_name}/theme-bg
GET    /api/rooms/{room_name}/theme-bg
DELETE /api/rooms/{room_name}/theme-bg
POST   /api/rooms/{room_name}/moderators
DELETE /api/rooms/{room_name}/moderators/{user_id}
POST   /api/rooms/{room_name}/bans
DELETE /api/rooms/{room_name}/bans/{user_id}
GET    /api/rooms/{room_name}/bans
GET    /api/rooms/{room_name}/pins
POST   /api/rooms/{room_name}/pins/{msg_id}
DELETE /api/rooms/{room_name}/pins/{msg_id}
POST   /api/rooms/{room_name}/join
GET    /api/rooms/{room_name}/content-warning/status
POST   /api/rooms/{room_name}/content-warning/ack
POST   /api/rooms/{room_name}/content-warning/forget
GET    /api/rooms/{room_name}/members
GET    /api/rooms/{room_name}/voice-participants
POST   /api/rooms/{room_name}/leave
GET    /api/rooms/{room_name}/queue
POST   /api/rooms/{room_name}/queue
DELETE /api/rooms/{room_name}/queue/{track_id}
POST   /api/rooms/{room_name}/queue/skip
POST   /api/rooms/{room_name}/queue/clear
POST   /api/rooms/{room_name}/dj-only
POST   /api/rooms/{room_name}/djs
DELETE /api/rooms/{room_name}/djs/{user_id}

POST   /api/messages/{room_name}/send
GET    /api/messages/media/{msg_id}
GET    /api/messages/{room_name}
PATCH  /api/messages/{msg_id}
DELETE /api/messages/{msg_id}
POST   /api/messages/{msg_id}/react
POST   /api/messages/{msg_id}/view
GET    /api/messages/search/global
GET    /api/messages/{room_name}/search
GET    /api/messages/users/mentionable

POST   /api/dms/open/{nickname}
GET    /api/dms
POST   /api/dms/{channel_id}/read
GET    /api/dms/{channel_id}/messages
GET    /api/dms/{channel_id}/messages/{msg_id}/media
POST   /api/dms/{channel_id}/messages
POST   /api/dms/{channel_id}/messages/{msg_id}/view
PUT    /api/dms/{channel_id}/messages/{msg_id}
DELETE /api/dms/{channel_id}/messages/{msg_id}
POST   /api/dms/{channel_id}/messages/{msg_id}/react
GET    /api/dms/{channel_id}/disappear
POST   /api/dms/{channel_id}/disappear
POST   /api/dms/{channel_id}/hide
POST   /api/dms/{channel_id}/unhide
POST   /api/dms/{channel_id}/forwarding
DELETE /api/dms/{channel_id}/messages

Room settings (partial update)

PATCH /api/rooms/{room_name} — send only the fields you want to change: slowmode, invite_only, who_can_invite, forwarding_disabled, channel_theme, banner, about, and (public channels) content_warning JSON.

PATCH /api/messages/{msg_id} — edit a message you authored. PUT on DM messages replaces content on that row.

18+ channel labels

Public channels with content warnings require POST …/content-warning/ack with {"confirm": true} before history, media, or WebSocket sends. Otherwise clients get 451 content_warning_required. Ack lasts for the session until logout, leave, or …/content-warning/forget.

DM forwarding lock

POST /api/dms/{channel_id}/forwarding with {"disabled": true} — per-peer setting; included in account sync as dm_peers[].

Media, Emojis, Preview, Push, Calls, Location

GET    /api/emojis
POST   /api/emojis
DELETE /api/emojis/{emoji_id}

GET    /api/preview

GET    /api/proxy/image

GET    /api/push/vapid-key
POST   /api/push/subscribe
DELETE /api/push/unsubscribe
POST   /api/push/fcm-subscribe
DELETE /api/push/fcm-unsubscribe

GET    /api/calls/{call_id}/pending
GET    /api/calls/pending-latest
POST   /api/calls/{call_id}/decline
POST   /api/calls/decline

POST   /api/location/share
GET    /api/location/dm/{channel_id}
DELETE /api/location/dm/{channel_id}
PATCH  /api/location/settings

GET    /api/media/gifs/search
GET    /api/media/gifs/trending
GET    /api/media/gifs/categories
GET    /api/media/stickers/packs
POST   /api/media/stickers/packs
POST   /api/media/stickers
GET    /api/media/stickers/packs/{pack_id}
POST   /api/media/stickers/packs/{pack_id}/install
DELETE /api/media/stickers/packs/{pack_id}/uninstall
DELETE /api/media/stickers/{sticker_id}
GET    /api/media/stickers/public

PATCH /api/location/settings — update location-sharing preferences (partial JSON).

Social, Wall, Directory, Invites

Wall post bodies use POST / PUT / DELETE. Directory listing visibility uses PATCH /api/directory/channels/{room_name}/visibility. Wall account settings: PATCH /api/wall/settings.

POST   /api/social/follow/{nickname}
DELETE /api/social/follow/{nickname}
GET    /api/social/profile/{nickname}
GET    /api/social/profile/{nickname}/posts
GET    /api/social/profile/{nickname}/followers
GET    /api/social/profile/{nickname}/channels
GET    /api/social/profile/{nickname}/following
GET    /api/social/feed
GET    /api/social/explore
GET    /api/social/suggested
GET    /api/social/profile/{nickname}/media
POST   /api/social/profile/media/{msg_id}/to-wall
GET    /api/social/stories
POST   /api/social/stories
POST   /api/social/stories/upload
POST   /api/social/stories/{story_id}/view
DELETE /api/social/stories/{story_id}
GET    /api/social/stories/{story_id}/viewers

GET    /api/wall/users/{username}
POST   /api/wall/posts                    (plaintext; public or followers privacy)
GET    /api/wall/audience-recipients      (?audience=followers|friends|list:<id>)
POST   /api/wall/posts/encrypted          (friends/followers audiences; client-side encryption)
POST   /api/wall/posts/{post_id}/wrapped-keys  (extend audience after new follower/friend)
GET    /api/wall/posts/{post_id}
GET    /api/wall/posts/{post_id}/media
GET    /api/wall/posts/{post_id}/media-inline
PUT    /api/wall/posts/{post_id}
DELETE /api/wall/posts/{post_id}
DELETE /api/wall/posts/{post_id}/media
POST   /api/wall/posts/{post_id}/reactions
GET    /api/wall/posts/{post_id}/reactions
GET    /api/wall/posts/{post_id}/reactions/detail
POST   /api/wall/posts/{post_id}/repost
GET    /api/wall/posts/{post_id}/comments
POST   /api/wall/posts/{post_id}/comments
POST   /api/wall/posts/{post_id}/comments/{comment_id}/vote
DELETE /api/wall/comments/{comment_id}
PATCH  /api/wall/settings
GET    /api/wall/settings

GET    /api/directory/channels
GET    /api/directory/channels/search
GET    /api/directory/categories
PATCH  /api/directory/channels/{room_name}/visibility
GET    /api/directory/featured
GET    /api/directory/suggested
GET    /api/directory/users/search
GET    /api/directory/channels/{room_name}/profile
POST   /api/directory/channels/{room_name}/like
DELETE /api/directory/channels/{room_name}/like
GET    /api/directory/channels/{room_name}/comments
POST   /api/directory/channels/{room_name}/comments
DELETE /api/directory/channels/{room_name}/comments/{comment_id}
PUT    /api/directory/channels/{room_name}/listing
GET    /api/directory/suggest
GET    /api/directory/new

POST   /api/invites/channels/{room_name}
GET    /api/invites/channels/{room_name}
DELETE /api/invites/channels/{room_name}/{code}
GET    /api/invites/{code}
POST   /api/invites/{code}/join
GET    /api/invites/{code}/landing

Encrypted wall posts (cross-node friends)

Plaintext POST /api/wall/posts with privacy: private or friends is not replicated to peer nodes. For friends-only or followers-only content that must sync across the mesh, use POST /api/wall/posts/encrypted with client-side AES-256-GCM + per-recipient Signal wraps (see /static/js/wall_crypto.js).

GET /api/wall/audience-recipients returns { user_id, nickname, global_user_id } per recipient so the client can wrap keys for federated accounts. The server re-validates every recipient_id against the social graph on create and on wrapped-keys extend.

When a new follower or accepted friend needs access to older encrypted posts, the server may push a WebSocket wall_rewrap_needed event; the author client calls POST /api/wall/posts/{id}/wrapped-keys (requires the payload key cached locally).

Developer + Bot Management

PREFIX /api/developer
POST   /api/developer/keys
GET    /api/developer/keys
DELETE /api/developer/keys/{key_id}
POST   /api/developer/bots
GET    /api/developer/bots
PUT    /api/developer/bots/{bot_id}
DELETE /api/developer/bots/{bot_id}
POST   /api/developer/channels/{room_name}/bots/{bot_id}
DELETE /api/developer/channels/{room_name}/bots/{bot_id}
GET    /api/developer/channels/{room_name}/bots
GET    /api/developer/bots/public

Use these endpoints to mint API keys and manage bot accounts/channels from your own backend tools.

Permission allowlist. User-issued keys may only carry permissions from the set { read, write, dm, bot }. Any other value (including admin) is rejected at create time — you cannot self-grant elevated scopes via the API.

Per-user key cap. Each user is limited to 20 active API keys. Delete unused keys before creating new ones, or rotate by deleting first.

Bot channel membership. Bots can only read or post in channels they've been installed into. Server replies with 403 Bot is not a member of this channel otherwise. Use GET /api/external/me/channels to discover where the bot is installed.

Building a Bot 🤖

A FrogTalk bot is any program that holds a bot_… API key and calls the External Token API. Bots run anywhere — your laptop, a VPS, a serverless function — they only need outbound HTTP to your FrogTalk server. Every message a bot posts is stamped is_bot: true on the wire, so clients render a BOT pill next to the author name in chat. There is no way to disguise a bot as a human.

1. Create the bot

  1. Open Settings → Developer → Bots in the FrogTalk app.
  2. Click + Create Bot and pick a unique handle. This handle is the name users will @-mention.
  3. Copy the bot_… token shown in the dialog — it's only displayed once.
  4. Click Edit on the bot row to set its avatar, description, and whether it appears in the public directory (botfather-style).
  5. Install the bot into at least one channel via Settings → Developer → Bots → Add to channel. Bots cannot post until installed — uninstalled bots receive 403 Bot is not a member of this channel.

2. Authenticate

Send the bot token on every request:

X-API-Key: bot_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# or
Authorization: Bearer bot_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

3. Poll for messages

Bots cannot open a WebSocket — they poll. Keep track of the highest message id you've seen, and act on anything newer:

import requests, time

BOT_TOKEN = "bot_…"
CHANNEL   = "general"
SERVER    = "https://frogtalk.app"

last_seen = 0
while True:
    r = requests.get(
        f"{SERVER}/api/external/channels/{CHANNEL}/messages",
        headers={"X-API-Key": BOT_TOKEN},
        params={"limit": 50},
        timeout=15,
    )
    for m in r.json()["messages"]:
        if m["id"] <= last_seen:
            continue
        last_seen = max(last_seen, m["id"])
        # … your logic …
    time.sleep(2)

4. Reply

requests.post(
    f"{SERVER}/api/external/channels/{CHANNEL}/messages",
    headers={"X-API-Key": BOT_TOKEN},
    json={"content": "hello, world!", "reply_to": m["id"]},
    timeout=15,
)

Setting reply_to threads the bot's reply under the trigger message so users see the quoted parent.

5. Profile (botfather-style)

PUT /api/developer/bots/{bot_id} replaces bot profile fields. Requires your user session (not the bot key) — the in-app Edit dialog is the usual path.

FieldTypeNotes
namestringunique handle (≤32 chars)
avatarstringURL or data: URL
descriptionstringshown in the directory (≤500 chars)
is_publicboollist publicly

Etiquette & limits

  • Channel membership: a bot must be installed in a channel before it can read or post there. Use GET /api/external/me/channels to discover where the bot is installed.
  • Rate limit: POST /channels/{name}/messages is capped at 30 req/min per bot key. Stay well under it.
  • No mention spam: only reply when the bot is explicitly addressed (an @mention in the message text, or a reply_to pointing at one of your own messages).
  • Don't impersonate: the BOT pill cannot be hidden.
  • Be transparent: set a useful description so users understand what data your bot processes.

Examples & SDK

Complete, runnable reference bots live in the FrogTalk repo under bot-examples/. Clone the repo, drop in your bot_… token, and run.

🤖 runpod-ai-bot — AI chat bot (Python)

A mention-driven AI chat bot powered by a RunPod serverless LLM endpoint. Polls public channels, replies when @-mentioned or when a user replies to its own message. ~200 lines, no async framework required. Sends the conversation to the worker as an OpenAI-style messages=[…] list so the vLLM worker applies the model's own chat template — swap in any modern instruct model (Qwen, Mistral-Small / MythoMax, Llama-3, etc.) with no code changes. Default endpoint runs Gryphe/MythoMax-L2-13b (uncensored Llama-2 roleplay finetune). Auto-falls-back to a raw Alpaca-style prompt if the worker reports the loaded model has no chat template registered. Includes a refusal-detection retry that catches canned RLHF safety output and re-rolls at higher temperature with a stronger jailbreak preface.

🚀 Quick start (60 seconds)

git clone https://github.com/deadinternetfox/frogtalk.git
cd frogtalk/bot-examples/runpod-ai-bot

# 1. Install deps
pip install -r requirements.txt

# 2. Configure
cp .env.example .env
$EDITOR .env   # paste your FROGTALK_BOT_TOKEN and RUNPOD_* values

# 3. Run
python bot.py

🧪 Minimal bot in 20 lines

Don't want the full example? Here's the smallest useful loop — reads new messages from a channel and replies to mentions:

import os, time, requests

TOKEN   = os.environ["FROGTALK_BOT_TOKEN"]    # bot_xxx from /api/developer/bots
BASE    = "https://frogtalk.app/api/external"
CHANNEL = "general"
HEADERS = {"X-API-Key": TOKEN}
seen = set()

while True:
    r = requests.get(f"{BASE}/channels/{CHANNEL}/messages?limit=20", headers=HEADERS, timeout=10)
    for m in r.json().get("messages", []):
        if m["id"] in seen: continue
        seen.add(m["id"])
        if "@mybot" in (m.get("content") or "").lower():
            requests.post(
                f"{BASE}/channels/{CHANNEL}/messages",
                headers=HEADERS,
                json={"content": f"hi @{m['nickname']} 👋", "reply_to": m["id"]},
                timeout=10,
            )
    time.sleep(5)

🟦 TypeScript / Node.js

const TOKEN   = process.env.FROGTALK_BOT_TOKEN!;
const BASE    = "https://frogtalk.app/api/external";
const CHANNEL = "general";
const headers = { "X-API-Key": TOKEN, "Content-Type": "application/json" };
const seen = new Set<number>();

async function tick() {
  const r = await fetch(`${BASE}/channels/${CHANNEL}/messages?limit=20`, { headers });
  const { messages = [] } = await r.json();
  for (const m of messages) {
    if (seen.has(m.id)) continue;
    seen.add(m.id);
    if ((m.content || "").toLowerCase().includes("@mybot")) {
      await fetch(`${BASE}/channels/${CHANNEL}/messages`, {
        method: "POST", headers,
        body: JSON.stringify({ content: `hi @${m.nickname} 👋`, reply_to: m.id }),
      });
    }
  }
}
setInterval(tick, 5000);

Want to contribute another reference bot (TypeScript, Go, Rust, a Discord-style command bot, an RSS poster…)? Open a PR against bot-examples/.

External Token API (for Devs and Bots)

PREFIX /api/external

Auth: X-API-Key: frog_… or bot_…, or Authorization: Bearer <key>. User keys (frog_…) come from /api/developer/keys; bot keys (bot_…) come from /api/developer/bots.

GET    /api/external/docs
GET    /api/external/health
GET    /api/external/me
GET    /api/external/me/channels
GET    /api/external/channels
GET    /api/external/channels/{name}/messages
POST   /api/external/channels/{name}/messages
GET    /api/external/users/{user_id}
POST   /api/external/dms/{user_id}
POST   /api/external/webhooks
curl -X GET https://frogtalk.app/api/external/me \
  -H "X-API-Key: frog_your_key_here"

Bot keys only: GET /api/external/me/channels returns the list of channels the bot has been installed in (via Settings → Bots → Add to channel). Auto-discovery clients should poll this every ~30s instead of hardcoding a channel list. Reference bots and setup guides live in the repo under bot-examples/ (see also Examples above).

Bridge API (Discord / Telegram)

PREFIX /api
GET    /api/bridges
GET    /api/rooms/{room_name}/bridge-outbound
POST   /api/bridges/create
POST   /api/bridges/prepare-code
GET    /api/bridges/check-code/{code}
POST   /api/bridges/claim-code
DELETE /api/bridges/{bridge_id}
POST   /api/bridges/{bridge_id}/toggle
POST   /api/bridges/{bridge_id}/direction
GET    /api/discord-bridges
GET    /api/discord-bridges/invite-meta
POST   /api/discord-bridges/validate-channel
POST   /api/discord-bridges/create
DELETE /api/discord-bridges/{bridge_id}
POST   /api/bridge/message

Bridges mirror public channel plaintext only. End-to-end private rooms cannot be bridged (403 on create). DMs are never bridged.

Federation, Network, Trust, Updates

PREFIX /api
GET    /api/network/status
GET    /api/network/servers
GET    /api/network/probe
GET    /api/network/auto-select
GET    /api/network/build/local
POST   /api/network/build/verify-peers
GET    /api/network/updates/latest
POST   /api/network/updates/publish
GET    /api/network/update/status
POST   /api/network/update/check
POST   /api/network/update/apply
POST   /api/network/servers/register
POST   /api/network/servers/sync-official
GET    /api/network/status/tor
POST   /api/network/servers/register-transport

GET    /api/identity/me
PUT    /api/identity/me/pubkey
GET    /api/identity/me/claim
GET    /api/identity/users/{user_id}/claim

POST   /api/federation/events/inbox
POST   /api/federation/manifests/register
GET    /api/federation/manifests/verify
GET    /api/federation/manifests/list
POST   /api/federation/events/emit
GET    /api/federation/events/outbox
GET    /api/federation/failover/status

Signed update channel

Update apply refuses to run unless the release manifest carries an Ed25519 signature from a pubkey listed in FROGTALK_RELEASE_SIGNERS. Auto-apply is off by default (FROGTALK_AUTO_UPDATE_ENABLED=0) — operators opt in explicitly.

POST /api/network/updates/publish
{
  "version": "1.6.41-alpha",
  "package_url": "https://frogtalk.app/releases/frogtalk-1.6.41-alpha.tar.gz",
  "package_sha256": "<sha256>",
  "build_hash": "<web-build-hash>",
  "signature": "<ed25519-sig-over-canonical-manifest>",
  "signer": "<ed25519-pubkey-hex>",
  "notes": "Signed release rollout"
}

The POST /api/network/update/apply endpoint verifies the signature, checks package_sha256, and enforces version monotonicity before running the apply step.

Inbox signing required by default

FROGTALK_FEDERATION_REQUIRE_SIGS defaults to 1. Events from origins with no pinned server_pubkey are rejected. Inbox idempotency uses a composite (origin_server_id, event_id) key so two peers cannot collide.

Pubkey pinning: GET /api/network/servers (directory) does not include federation_pubkey_pem. Nodes TOFU-pin each enabled peer from GET /api/network/status after directory sync and before outbox push. Operators can also read federation_pubkey_fingerprint there when verifying a peer manually.

Delivery: background inbox/outbox processors poll every FROGTALK_FEDERATION_INBOX_IDLE_SEC / OUTBOX_IDLE_SEC (default 8s when idle, 2s when busy). Clearnet hubs reach .onion peers via FROGTALK_TOR_SOCKS_PROXY.

Official production mesh

End-user directory and federation traffic target these production nodes (listed on GET /api/network/servers at the hub). Development or staging hosts are not part of this reference set.

NodeURLRole
FrogTalk Mainhttps://frogtalk.appProduction hub · official directory
FrogTalk Tor Mirror.onion (see network picker)Tor-only mirror when configured

Operator mesh config (self-hosted)

Directory rows and seed peers are not hardcoded in Python. Copy node/deploy/federation-mesh.example.json to federation-mesh.local.json on your VPS and set FROGTALK_FEDERATION_MESH_FILE. On the hub that publishes GET /api/network/servers, set FROGTALK_FEDERATION_DIRECTORY_HUB=1 and list your hostname under directory_hub_hosts. Use ensure_peers, clearnet_repairs, and fallback_peers as documented in node/deploy/README.md.

Foreign user and room materialization

Federation events referencing an unknown local nickname or room are dropped instead of silently creating a placeholder user or room. Encrypted-post recipient wraps only attach to existing local users rows (materialized earlier via signed dm.* / friend.* events).

Federated calls (call.* / voice.*)

Requires FROGTALK_FEDERATION_CALLS_ENABLED=1 and capability federation-calls-v1. Sensitive prefixes — pinned pubkey + signature. Targeted outbox to callee/home servers.

call.offer / call.answer / call.ice / call.end / call.reject
voice.session.join / voice.session.leave / voice.signal (offer|answer|ice)
GET  /api/network/ice-config?peer_server_id=<uuid>  (session auth required)

WebRTC media stays P2P/TURN; signaling only crosses federation. Mid-call call.offer with renegotiate supports screen share and camera-on after connect. GET /api/network/ice-config merges local TURN with the peer home node when peer_server_id is set. Tor Browser and strict privacy tabs block WebRTC in-page — clients show a toast; use a normal browser window or the mobile app for calls.

FrogSocial replication (social.*)

Sensitive prefix — requires pinned origin pubkey + valid Ed25519 signature. Outbox events are signed at push time; per-peer scoped payloads are re-signed when wraps are filtered for targeted delivery.

social.post.created              plaintext; privacy public|followers only
social.post.created.encrypted    ciphertext_b64 + wrapped_keys[] (by global_user_id)
social.post.keys.extended        add wraps to existing encrypted post
social.post.updated / social.post.deleted
social.comment.created / social.reaction.changed / social.repost.created
social.follow.changed            action: follow | unfollow
social.story.created / social.story.deleted

Targeted fan-out: encrypted posts and key extensions enqueue one outbox row per peer server_id that hosts at least one recipient (event_id suffix @<peer_server_id>). Peers receive only wraps for recipients homed on that node — not the full recipient list. Broadcast rows (target_server_id="") still go to every enabled peer (public posts, DMs, etc.).

Origin binding: actors must carry author_global_user_id / actor_global_user_id (UUID v4). Events whose gid is homed on a different server than origin_server_id are rejected (prevents cross-peer impersonation). Post update/delete require author ownership on the mapped local row.

Also signed under sensitive prefixes: user.*, dm.*, friend.* (e.g. friend.requested, friend.accepted, friend.request.removed). user.profile.updated mirrors avatar, status, mood, and sanitized custom_style to peers that host a local row for the gid.

Directory join (foreign node)

POST /api/rooms/{room_name}/join materializes a public channel shell from federation_channel_index only (no client-supplied metadata). Returns 409 with code: name_collision when a local community channel blocks mirroring the same name.

Inbox envelope (peer → peer)

POST /api/federation/events/inbox
{
  "events": [{
    "event_id": "evt_…",
    "event_type": "social.post.created.encrypted",
    "origin_server_id": "<uuid>",
    "origin_time": "2026-05-21T12:00:00Z",
    "signature": "<base64-ed25519>",
    "signer_pubkey_fingerprint": "…",
    "payload": {
      "global_post_id": "<uuid>",
      "author_global_user_id": "<uuid>",
      "nickname": "alice",
      "audience": "friends",
      "ciphertext_b64": "…",
      "wrapped_keys": [{
        "recipient_global_user_id": "<uuid>",
        "recipient_nickname": "bob",
        "wrapped_b64": "…"
      }]
    }
  }]
}

Peer pushes use Ed25519 request headers. With FROGTALK_FEDERATION_AUTH_MODE=dual (default), workers may also send X-Federation-Token. Set signed to require signatures only. Rate limit: 600 events / 60s per origin.

Admin + Server Admin

Server-sensitive endpoints.
POST   /api/admin/ban/{nickname}
POST   /api/admin/unban/{nickname}
POST   /api/admin/kick/{nickname}
POST   /api/admin/mute/{nickname}
POST   /api/admin/unmute/{nickname}

GET    /server
GET    /api/server-admin/config
GET    /api/server-admin/easter-egg
PUT    /api/server-admin/easter-egg
POST   /api/server-admin/easter-egg/upload
GET    /api/server/easter-egg
POST   /api/server-admin/login
POST   /api/server-admin/logout
GET    /api/server-admin/me
GET    /api/server-admin/stats
GET    /api/server-admin/online-users
GET    /api/server-admin/nodes
GET    /api/server-admin/nodes/{server_id}/probe
POST   /api/server-admin/nodes/{server_id}/block
POST   /api/server-admin/nodes/{server_id}/unblock
POST   /api/server-admin/control/sync-official-directory
POST   /api/server-admin/control/kick
POST   /api/server-admin/control/ban
POST   /api/server-admin/control/unban
POST   /api/server-admin/control/mute
POST   /api/server-admin/control/unmute

Danger zone — node self-destruct

Irreversible. Securely wipes this node — database, keys, uploads, Tor identity, the install itself — and powers off. Triple-gated: authenticated server-admin and a confirmation matching the node's own name and the admin's App PIN (or account password if no PIN is set). Rate-limited to 3/hour. See the operator guide on the run-a-node page and docs/NODE_SECURITY.md.
GET    /api/server-admin/nuke-info     # node name + whether the secure-wipe helper is installed
POST   /api/server-admin/nuke          # { confirm: "<node name>", secret: "<pin|password>", paranoid?: bool }

Health check & real-time

GET    /api/ping
GET    /invite/{code}          (public invite landing page)
WebSocket: /ws/{room_name}?token=SESSION_TOKEN

GET /api/ping — no auth; returns node health JSON. WebSocket — pass the same session token as X-Session-Token in the query string for live room chat, DMs, typing, calls, and push events. Bots should use the External API (polling), not WebSocket.