Utility APIs
ryOS provides several utility API endpoints for common operations like fetching link previews, checking iframe embeddability, sharing applets, Sync v2, and admin operations. These endpoints are implemented as Node.js API functions.
Many utility endpoints now use the shared apiHandler + request-auth utilities for consistent CORS/auth/method handling.
Link Preview
Fetches metadata from URLs for rich link previews. Supports Open Graph, Twitter Cards, and standard meta tags with special handling for YouTube URLs.
Endpoint
| Method | Path | Description |
|---|---|---|
| GET | /api/link-preview | Fetch URL metadata for previews |
Request
Query Parameters:| Parameter | Type | Required | Description |
|---|---|---|---|
url | string | Yes | The URL to fetch metadata from (must be HTTP or HTTPS) |
Response
Success Response (200):{
"url": "https://example.com/page",
"title": "Page Title",
"description": "Page description from meta tags",
"image": "https://example.com/og-image.jpg",
"siteName": "Example Site"
}
Response Fields:
| Field | Type | Description |
|---|---|---|
url | string | The original URL |
title | string? | Page title (from <title>, og:title, or twitter:title) |
description | string? | Page description (from og:description, twitter:description, or meta description) |
image | string? | Preview image URL (from og:image or twitter:image) |
siteName | string? | Site name (from og:site_name or hostname) |
| Status | Description |
|---|---|
| 400 | Missing or invalid URL |
| 403 | Unauthorized origin |
| 405 | Method not allowed |
| 408 | Request timeout |
| 429 | Rate limit exceeded |
| 503 | Network error |
Rate Limits
- Global limit: 10 requests per minute per IP
- Per-host limit: 5 requests per minute per IP per target hostname
Example
// Fetch link preview
const response = await fetch('/api/link-preview?url=https://github.com');
const metadata = await response.json();
console.log(metadata);
// {
// url: "https://github.com",
// title: "GitHub: Let's build from here",
// description: "GitHub is where over 100 million developers...",
// image: "https://github.githubassets.com/images/modules/site/social-cards/campaign-social.png",
// siteName: "GitHub"
// }
Notes
- YouTube URLs are handled specially using the oEmbed API for reliable metadata
- Responses are cached for 1 hour (
Cache-Control: public, max-age=3600) - HTML entities in metadata are automatically decoded
- Relative image URLs are converted to absolute URLs
iFrame Check
Checks if a URL allows iframe embedding and optionally proxies content to bypass embedding restrictions. Also supports retrieving cached historical versions of pages.
Endpoint
| Method | Path | Description |
|---|---|---|
| GET | /api/iframe-check | Check iframe embeddability or proxy content |
Request
Query Parameters:| Parameter | Type | Required | Description |
|---|---|---|---|
url | string | Yes | The URL to check or proxy |
mode | string | No | Operation mode: check, proxy, ai, or list-cache (default: proxy) |
year | string | No | Year for Wayback Machine or AI cache (e.g., "2020", "1000 BC") |
month | string | No | Month for Wayback Machine (e.g., "01" for January) |
theme | string | No | Theme name - font overrides are skipped for "macosx" theme |
Modes
check Mode
Performs a header-only check to determine if the URL allows iframe embedding.
Response (200):{
"allowed": true,
"reason": null,
"title": "Page Title"
}
{
"allowed": false,
"reason": "X-Frame-Options: DENY",
"title": "Page Title"
}
proxy Mode (Default)
Proxies the content with embedding-blocking headers removed. Injects:
<base>tag for relative URL resolution- Click interceptor script for navigation handling
- History API patch for cross-origin compatibility
- Font override styles (unless theme is "macosx")
| Header | Description |
|---|---|
X-Proxied-Page-Title | URL-encoded page title (if available) |
X-Wayback-Cache | "HIT" if content was served from Wayback cache |
ai Mode
Retrieves AI-generated historical page versions from cache.
Additional Parameters:| Parameter | Type | Required | Description |
|---|---|---|---|
year | string | Yes | Historical year (e.g., "1995", "500 BC", "1 CE", "current") |
Returns the cached HTML content with header X-AI-Cache: HIT.
{
"aiCache": false
}
list-cache Mode
Lists all available cached years (both AI-generated and Wayback Machine) for a URL.
Response (200):{
"years": ["current", "2020", "2015", "2010", "1999", "500 BC"]
}
Auto-Proxy Domains
The following domains are automatically proxied regardless of mode:
wikipedia.org(and subdomains)wikimedia.org(and subdomains)wikipedia.comcursor.com
Rate Limits
| Mode | Global Limit | Per-Host Limit |
|---|---|---|
check / proxy | 300/min per IP | 100/min per IP per host |
ai / list-cache | 120/min per IP | N/A |
Example
// Check if URL can be embedded
const checkResponse = await fetch('/api/iframe-check?url=https://example.com&mode=check');
const { allowed, reason } = await checkResponse.json();
if (!allowed) {
// Use proxy mode instead
const iframeSrc = `/api/iframe-check?url=https://example.com&mode=proxy`;
iframe.src = iframeSrc;
}
// List cached historical versions
const cacheResponse = await fetch('/api/iframe-check?url=https://example.com&mode=list-cache');
const { years } = await cacheResponse.json();
// years: ["2020", "2015", "2010"]
// Load Wayback Machine version
const waybackSrc = `/api/iframe-check?url=https://example.com&mode=proxy&year=2015&month=06`;
Share Applet
Manages sharing of user-created applets (mini HTML/JS applications). Supports creating, retrieving, updating, and deleting shared applets.
Endpoint
| Method | Path | Description |
|---|---|---|
| GET | /api/share-applet | Retrieve applet by ID or list all applets |
| POST | /api/share-applet | Save or update an applet |
| DELETE | /api/share-applet | Delete an applet (admin only) |
| PATCH | /api/share-applet | Update applet metadata (admin only) |
Authentication
Most operations require authentication via headers:
| Header | Description |
|---|---|
Authorization | Bearer token: Bearer <token> |
X-Username | Username of the authenticated user |
GET - Retrieve Applet
Query Parameters:| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | No | Applet ID to retrieve |
list | string | No | Set to "true" to list all applets |
*One of id or list=true is required.
{
"content": "<html>...</html>",
"title": "My Applet",
"name": "my-applet",
"icon": "game",
"windowWidth": 400,
"windowHeight": 300,
"createdAt": 1704067200000,
"createdBy": "username",
"featured": false
}
Response - List Applets (200):
{
"applets": [
{
"id": "abc123...",
"title": "Featured Applet",
"name": "featured-applet",
"icon": "star",
"createdAt": 1704067200000,
"featured": true,
"createdBy": "ryo"
},
{
"id": "def456...",
"title": "My Applet",
"name": "my-applet",
"icon": "game",
"createdAt": 1704000000000,
"featured": false,
"createdBy": "user123"
}
]
}
POST - Save Applet
Requires authentication.
Request Body:| Field | Type | Required | Description |
|---|---|---|---|
content | string | Yes | HTML content of the applet |
title | string | No | Display title |
name | string | No | Applet name/slug |
icon | string | No | Emoji or icon |
windowWidth | number | No | Default window width |
windowHeight | number | No | Default window height |
shareId | string | No | Existing ID to update (author must match) |
{
"id": "abc123def456...",
"shareUrl": "https://os.ryo.lu/applet-viewer/abc123def456...",
"updated": false,
"createdAt": 1704067200000
}
Response Fields:
| Field | Type | Description |
|---|---|---|
id | string | Unique applet ID (32 characters) |
shareUrl | string | Full URL to view the applet |
updated | boolean | Whether an existing applet was updated |
createdAt | number | Timestamp of creation/update |
DELETE - Delete Applet (Admin Only)
Requires admin authentication (user "ryo" with valid token).
Query Parameters:| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Applet ID to delete |
{
"success": true
}
PATCH - Update Applet (Admin Only)
Currently only supports updating the featured status.
Query Parameters:| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Applet ID to update |
{
"featured": true
}
Response (200):
{
"success": true,
"featured": true
}
Example
// Save a new applet
const response = await fetch('/api/share-applet', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'X-Username': username
},
body: JSON.stringify({
content: '<html><body><h1>Hello World</h1></body></html>',
title: 'Hello World Applet',
icon: '👋',
windowWidth: 400,
windowHeight: 300
})
});
const { id, shareUrl } = await response.json();
console.log(`Applet shared at: ${shareUrl}`);
// Retrieve an applet
const applet = await fetch(`/api/share-applet?id=${id}`).then(r => r.json());
// List all applets
const { applets } = await fetch('/api/share-applet?list=true').then(r => r.json());
Stocks API
Provides real-time stock quote data for the Dashboard Stocks widget. Uses Yahoo Finance for quote and chart data.
Endpoint
| Method | Path | Description |
|---|---|---|
| GET | /api/stocks | Fetch stock quotes and optional chart data |
Request
Query Parameters:| Parameter | Type | Required | Description |
|---|---|---|---|
symbols | string | Yes | Comma-separated stock symbols (max 20) |
chart | string | No | Symbol to fetch chart data for |
range | string | No | Chart time range: 1d, 5d, 1mo, 3mo, 6mo (default), 1y, 2y |
Response
Success (200):{
"quotes": [
{
"symbol": "AAPL",
"price": 198.50,
"change": 2.30,
"changePercent": 1.17,
"name": "Apple Inc."
}
],
"chart": [
{
"timestamp": 1704067200000,
"close": 195.20
}
]
}
Response Fields:
| Field | Type | Description |
|---|---|---|
quotes | StockQuote[] | Array of stock quotes |
chart | ChartPoint[] | Optional chart data (only when chart param provided) |
| Status | Description |
|---|---|
| 400 | Missing symbols parameter or no valid symbols |
| 500 | Failed to fetch stock data |
Caching
Responses include Cache-Control: public, max-age=60, stale-while-revalidate=300.
Example
const response = await fetch('/api/stocks?symbols=AAPL,GOOGL,MSFT&chart=AAPL&range=1mo');
const { quotes, chart } = await response.json();
Currency Rate
Proxies foreign-exchange rates for currency conversion features. Uses Frankfurter as the primary source and open.er-api.com as a fallback.
Endpoint
| Method | Path | Description |
|---|---|---|
| GET | /api/currency-rate | Fetch the exchange rate for a currency pair |
Request
Query Parameters:| Parameter | Type | Required | Description |
|---|---|---|---|
from | string | Yes | 3-letter base currency code (e.g. USD) |
to | string | Yes | 3-letter quote currency code (e.g. EUR) |
Response
Success (200):{
"rate": 0.92,
"rateDate": "2026-02-29",
"source": "frankfurter"
}
| Field | Type | Description |
|---|---|---|
rate | number | Units of to per 1 unit of from |
rateDate | string | Date the rate is valid for (YYYY-MM-DD) |
source | string | frankfurter, open.er-api, or identity (when from === to) |
| Status | Description |
|---|---|
| 400 | from/to are not valid 3-letter currency codes |
| 429 | Rate limit exceeded |
| 502 | Could not load exchange rate from either source |
Rate Limits
| Scope | Limit | Window |
|---|---|---|
| Burst | 30 requests | 1 minute |
| Daily | 2000 requests | 24 hours |
Apple Token APIs
Mint short-lived Apple-issued ES256 JWTs for client-side Apple SDKs. Both endpoints sign and cache a token in memory, returning the same { token, expiresAt } shape and a Cache-Control: private header. They return 500 (with the missing env vars) when not configured.
Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /api/mapkit-token | MapKit JS developer token (Maps app + AI maps tools) |
| GET | /api/musickit-token | MusicKit JS developer token (iPod "Apple Music" mode) |
Response
Success (200):{
"token": "eyJ...",
"expiresAt": 1704067200000
}
| Endpoint | Token TTL | Response cache |
|---|---|---|
/api/mapkit-token | 30 minutes | 5 minutes |
/api/musickit-token | 7 days | 30 minutes |
musickit-token falls back to the MAPKIT_* key material when the MUSICKIT_* variants are unset (see Environment Variables).
TV Create Channel
AI-assisted creation of a themed YouTube "TV channel" for the TV app. Given a short description, an AI planner produces a channel name, tagline, and 2–4 diverse YouTube search queries, then fans those out against the YouTube Data API and returns a de-duplicated video lineup. Requires authentication.
Endpoint
| Method | Path | Description |
|---|---|---|
| POST | /api/tv/create-channel | Generate a themed channel + video lineup |
Request
{
"description": "lofi study beats"
}
| Field | Type | Required | Description |
|---|---|---|---|
description | string | Yes | Channel idea (2–280 characters) |
Response
Success (200):{
"name": "Lofi Desk",
"description": "mellow beats to study to",
"queries": ["lofi hip hop study", "chillhop instrumental", "jazzy lofi mix"],
"videos": [
{ "id": "abc123", "url": "https://youtu.be/abc123", "title": "Lofi Mix", "artist": "Channel Name" }
]
}
Error Responses:
| Status | Description |
|---|---|
| 404 | No videos found for the channel idea |
| 429 | Rate limit exceeded (burst or daily) |
| 500 | YouTube API not configured |
| 502 | AI failed to plan the channel |
Rate Limits
Limited per username (and per IP as a backstop):
| Scope | Limit | Window |
|---|---|---|
| Burst | 5 requests | 1 minute |
| Daily | 30 requests | 24 hours |
Analytics Events
First-party product-analytics ingestion. Accepts a batch of client events; malformed events are dropped per-event and the batch is capped server-side. Auth is optional (a username is recorded when present). The endpoint resolves a coarse country bucket from the request but never persists the raw IP.
Endpoint
| Method | Path | Description |
|---|---|---|
| POST | /api/analytics/events | Record a batch of analytics events |
Request
{
"events": [
{ "name": "app_open", "ts": 1704067200000, "props": { "app": "finder" } }
]
}
Only events (an array) is required; individual event shapes are validated/sanitized downstream.
Response
Success:204 No Content (empty body).
Sync APIs
Cloud sync uses a journal-based delta protocol (v2): all synced state is a per-user map of key → document, changes travel as ops with hybrid-logical- clock timestamps, and a single integer cursor (seq) answers "am I up to date?". Conflicts resolve per key via last-writer-wins; losing writes receive the winning entry inline (no 409s, no retry loops). See docs/proposals/cloud-sync-v2.md for the full design.
Endpoint Summary
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /api/sync/v2/ops | Apply a batch of ops (per-key LWW) | Required |
| GET | /api/sync/v2/changes?since={seq} | Ops after the cursor, or snapshotRequired | Required |
| GET | /api/sync/v2/snapshot | Full key-value state + current seq | Required |
| POST | /api/sync/v2/blobs | Batch blob upload prepare (sha256 dedupe) + download URL signing | Required |
| GET | /api/cron/sync-maintenance | Maintenance cron: blob GC + user-record healing | CRON_SECRET |
| GET/PUT | /api/sync/auto-sync-preference | Cross-device Auto Sync toggle | Required |
POST /api/sync/v2/ops
Request:
{
"clientId": "c4f2a9d1e8b30067",
"ops": [
{ "k": "settings/theme", "v": { "current": "macosx" }, "t": "01718180000000-0000-c4f2a9d1e8b30067" },
{ "k": "stickies/note:abc", "del": true, "t": "01718180000200-0001-c4f2a9d1e8b30067" }
]
}
Response (200 even when individual ops lose):
{
"ok": true,
"seq": 1042,
"results": [
{ "k": "settings/theme", "accepted": true, "seq": 1042 },
{ "k": "stickies/note:abc", "accepted": false,
"winner": { "v": { "...": "..." }, "t": "01718180000500-0000-other", "seq": 1040 } }
]
}
Accepted ops are appended to the user's journal and broadcast on the private-sync-{username} channel as a sync-ops event with the ops inlined when small (receivers apply them with zero HTTP requests). Client HLC timestamps are clamped to serverNow + 5min.
GET /api/sync/v2/changes?since={seq}
Returns { ok, seq, ops: [...] } with the ops after since, { ops: [] } when up to date, or { seq, snapshotRequired: true } when the journal no longer covers the cursor (bounded retention; fall back to the snapshot). The ops are coalesced per key — only the newest op for each key in the window is returned (under LWW the client only needs each key's latest value to converge), while seq still reflects the server cursor.
GET /api/sync/v2/snapshot
Returns { ok, seq, entries } where entries maps every key to { v?, del?, t, seq }. Used for new-device bootstrap, journal-gap recovery, and force download. Supports ?prefix= for partial reads.
POST /api/sync/v2/blobs
Binary content (images, trash, applets, wallpapers) is content-addressed at sync/{username}/blobs/{sha256}.gz. One batched request prepares any number of uploads — blobs the server already knows are skipped entirely — and signs download URLs for the user's own objects:
{
"upload": [{ "sha256": "ab12…", "size": 48211 }],
"download": ["s3://bucket/sync/alice/blobs/cd34….gz"]
}
Response: per-digest { exists: true, url } (skip upload) or { exists: false, upload: { …storage instruction… } }, plus signed downloads aligned with the request array (null for foreign URLs).
GET /api/cron/sync-maintenance
Scheduled maintenance (daily via an external scheduler such as a Coolify cron task; authenticated with Authorization: Bearer ${CRON_SECRET}, like the Telegram heartbeat cron). Each run processes a bounded batch of users, walking the whole user base across runs via a scan cursor persisted at sync:maintenance:cursor:
- Blob garbage collection — content-addressed blobs whose digest is no
- User record healing — user records persist forever; stale
Response:
{
"success": true,
"usersProcessed": 25,
"scanComplete": false,
"blobsMarked": 4,
"blobsUnmarked": 0,
"blobsDeleted": 2,
"userRecordsPersisted": 3,
"errors": 0
}
Admin API
Administrative endpoints for user, memory, analytics, Cursor-agent, Redis-browser, and moderation operations. All operations require admin authentication (user ryo with valid token).
The endpoint is rate-limited to 30 admin requests/minute.
Endpoint
| Method | Path | Description |
|---|---|---|
| GET | /api/admin | Query users and statistics |
| POST | /api/admin | Perform admin actions |
Authentication
All admin endpoints require:
| Header | Description |
|---|---|
Authorization | Bearer token: Bearer <token> |
X-Username | Must be "ryo" |
Unauthorized requests return 403 Forbidden.
GET Operations
Query Parameters:| Parameter | Type | Required | Description |
|---|---|---|---|
action | string | Yes | Operation to perform |
username | string | For some actions | Target username |
limit | number | No | Result limit (default: 50) |
| Action | Description |
|---|---|
getStats | System-wide user, room, and message statistics |
getAllUsers | Registered users and ban status |
getUserProfile | Detailed user profile, rooms, and message counts |
getUserMessages | Recent messages for a user |
getUserMemories | Long-term memories and daily notes for a user |
getUserHeartbeats | Recent global-presence heartbeats for a user |
getServerInfo | Deployment, Redis, storage, realtime, and environment health |
getAnalytics | API/product analytics summary or detail (detail=true) |
getCursorAgentRuns | Recent Cursor cloud-agent run records |
listRedisKeys | Redis browser key scan with summaries |
getRedisKey | Redis key document preview |
backupRedisKeys | Export matching Redis key documents |
getAuditLog | Append-only admin action audit log |
getStats Action
Get system-wide statistics.
Response:{
"totalUsers": 150,
"totalRooms": 12,
"totalMessages": 5430
}
getAllUsers Action
List all registered users.
Response:{
"users": [
{
"username": "alice",
"lastActive": 1704067200000,
"banned": false
},
{
"username": "bob",
"lastActive": 1704000000000,
"banned": true
}
]
}
getUserProfile Action
Get detailed profile for a specific user.
Additional Parameters:| Parameter | Type | Required | Description |
|---|---|---|---|
username | string | Yes | Target username |
{
"username": "alice",
"lastActive": 1704067200000,
"banned": false,
"banReason": null,
"bannedAt": null,
"messageCount": 42,
"rooms": [
{ "id": "general", "name": "General Chat" },
{ "id": "random", "name": "Random" }
]
}
getUserMessages Action
Get recent messages from a specific user.
Additional Parameters:| Parameter | Type | Required | Description |
|---|---|---|---|
username | string | Yes | Target username |
limit | number | No | Maximum messages to return (default: 50) |
{
"messages": [
{
"id": "msg123",
"roomId": "general",
"roomName": "General Chat",
"content": "Hello everyone!",
"timestamp": 1704067200000
}
]
}
getUserMemories Action
Get long-term memories plus recent daily notes for a user.
Additional Parameters:| Parameter | Type | Required | Description |
|---|---|---|---|
username | string | Yes | Target username |
{
"memories": [
{
"key": "work",
"summary": "Design lead at Cursor",
"content": "...",
"createdAt": 1704067200000,
"updatedAt": 1704153600000
}
],
"dailyNotes": [
{
"date": "2026-02-29",
"entries": [{ "timestamp": 1704067200000, "content": "..." }],
"processedForMemories": false
}
]
}
POST Operations
Request Body:| Field | Type | Required | Description |
|---|---|---|---|
action | string | Yes | Operation to perform |
targetUsername | string | Yes | User to act on |
reason | string | No | Reason for action (for bans) |
| Action | Description |
|---|---|
deleteUser | Permanently delete a user account |
banUser | Ban a user and invalidate sessions |
unbanUser | Remove a user's ban |
clearUserMemories | Delete long-term memories for a user |
forceProcessDailyNotes | Reprocess recent daily notes into memories |
startCursorAgent | Start a Cursor cloud-agent run from Admin |
deleteRedisKey | Delete one Redis key after key confirmation |
deleteUser Action
Permanently delete a user account (cannot delete admin "ryo").
Request:{
"action": "deleteUser",
"targetUsername": "spammer"
}
Response:
{
"success": true
}
Effects:
- Deletes user record
- Deletes password hash
- Invalidates all authentication tokens
banUser Action
Ban a user from the chat system (cannot ban admin "ryo").
Request:{
"action": "banUser",
"targetUsername": "troublemaker",
"reason": "Spamming in chat"
}
Response:
{
"success": true
}
Effects:
- Sets
banned: trueon user record - Stores ban reason and timestamp
- Invalidates all authentication tokens (forces logout)
unbanUser Action
Remove ban from a user.
Request:{
"action": "unbanUser",
"targetUsername": "reformed-user"
}
Response:
{
"success": true
}
clearUserMemories Action
Delete all long-term memories for a user.
{
"action": "clearUserMemories",
"targetUsername": "alice"
}
Response:
{
"success": true,
"deletedCount": 12,
"message": "Cleared 12 memories for alice"
}
forceProcessDailyNotes Action
Reset processed flags and force reprocessing of recent daily notes into long-term memories.
{
"action": "forceProcessDailyNotes",
"targetUsername": "alice"
}
Response:
{
"success": true,
"notesReset": 7,
"notesProcessed": 6,
"memoriesCreated": 2,
"memoriesUpdated": 1,
"dates": ["2026-02-23", "2026-02-24"],
"skippedDates": []
}
Example
const adminHeaders = {
'Authorization': `Bearer ${adminToken}`,
'X-Username': 'ryo'
};
// Get system statistics
const stats = await fetch('/api/admin?action=getStats', {
headers: adminHeaders
}).then(r => r.json());
// Get all users
const { users } = await fetch('/api/admin?action=getAllUsers', {
headers: adminHeaders
}).then(r => r.json());
// Get user profile
const profile = await fetch('/api/admin?action=getUserProfile&username=alice', {
headers: adminHeaders
}).then(r => r.json());
// Ban a user
await fetch('/api/admin', {
method: 'POST',
headers: {
...adminHeaders,
'Content-Type': 'application/json'
},
body: JSON.stringify({
action: 'banUser',
targetUsername: 'spammer',
reason: 'Repeated spam violations'
})
});
Telegram Link API
Manages linking ryOS accounts to Telegram for cross-platform notifications and chat integration. All endpoints require authentication.
Endpoint Summary
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /api/telegram/link/create | Create a Telegram link code | Required |
| GET | /api/telegram/link/status | Check link status | Required |
| POST | /api/telegram/link/disconnect | Disconnect Telegram account | Required |
POST /api/telegram/link/create
Generates a link code for connecting a Telegram account. If a pending link already exists, returns the existing code.
Response (200):{
"code": "abc123",
"expiresIn": 300,
"botUsername": "ryos_bot",
"deepLink": "https://t.me/ryos_bot?start=link_abc123",
"linkedAccount": null
}
If already linked, linkedAccount is populated:
{
"code": "abc123",
"expiresIn": 300,
"botUsername": "ryos_bot",
"deepLink": "https://t.me/ryos_bot?start=link_abc123",
"linkedAccount": {
"telegramUserId": 123456789,
"telegramUsername": "alice_tg",
"firstName": "Alice",
"lastName": "Smith",
"linkedAt": 1704067200000
}
}
GET /api/telegram/link/status
Check whether the authenticated user has a linked Telegram account or a pending link session.
Response (200):{
"linked": true,
"account": {
"telegramUserId": 123456789,
"telegramUsername": "alice_tg",
"firstName": "Alice",
"lastName": "Smith",
"linkedAt": 1704067200000
},
"pendingLink": null
}
When not linked but a link is pending, account is null and pendingLink contains { code, expiresIn, botUsername, deepLink }.
POST /api/telegram/link/disconnect
Disconnect a linked Telegram account.
Response (200):{
"success": true
}
AirDrop API
Peer-to-peer file sharing between online ryOS users, modeled on AirDrop. Presence is tracked in a shared lobby (60-second TTL) and transfers are relayed through Redis with realtime notifications. All endpoints require authentication.
Endpoint Summary
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /api/airdrop/heartbeat | Announce presence in the AirDrop lobby | Required |
| GET | /api/airdrop/discover | List other nearby (online) users | Required |
| POST | /api/airdrop/send | Offer a file to a recipient | Required |
| POST | /api/airdrop/respond | Accept or decline a transfer | Required |
POST /api/airdrop/heartbeat
Adds the authenticated user to the lobby and broadcasts airdrop-presence. Response (200): { "success": true }.
GET /api/airdrop/discover
Prunes stale entries and returns online usernames other than the caller.
{
"users": ["bob", "carol"]
}
POST /api/airdrop/send
Offers a file to an online recipient. The transfer is stored for 5 minutes and an airdrop-request event is delivered to the recipient.
{
"recipient": "bob",
"fileName": "notes.md",
"fileType": "text",
"content": "..."
}
recipient, fileName, and content are required. content is capped at 2 MB (413 if exceeded). Returns 404 if the recipient is not currently available.
Response (200):
{
"success": true,
"transferId": "uuid"
}
POST /api/airdrop/respond
The recipient accepts or declines a pending transfer.
{
"transferId": "uuid",
"accept": true
}
On accept, the file payload is returned and the sender is notified (airdrop-accepted):
{
"success": true,
"fileName": "notes.md",
"fileType": "text",
"content": "...",
"sender": "alice"
}
On decline, the sender is notified (airdrop-declined):
{
"success": true,
"declined": true
}
Returns 404 if the transfer expired/not found, and 403 if the transfer is not addressed to the caller.
Error Handling
All utility APIs return consistent error responses:
{
"error": "Error message description"
}
Common HTTP Status Codes:
| Status | Description |
|---|---|
| 400 | Bad request (missing/invalid parameters) |
| 401 | Unauthorized (authentication required) |
| 403 | Forbidden (insufficient permissions or blocked origin) |
| 404 | Not found |
| 405 | Method not allowed |
| 408 | Request timeout |
| 429 | Rate limit exceeded |
| 500 | Internal server error |
| 503 | Service unavailable |
CORS
All utility APIs implement CORS with origin validation. Allowed origins are configured server-side. Requests from unauthorized origins receive 403 Forbidden.
Related
- API Reference - General API architecture
- Rooms API - Chat room management
- Messages API - Chat messaging
- AI System - AI-powered features