---
title: "API Integration Examples"
description: "Practical patterns for calling Ring Platform REST APIs, Server Actions, and Tunnel realtime from client and server code"
locale: "en"
---
# API Integration Examples

Hands-on patterns for building on Ring Platform. The platform exposes **~244 App Router `/api` route handlers** (see [API Reference](/docs/api.md) and the RingApiTree widget); this page shows the integration paths developers actually use in alpha clones.

| Path | When to use |
|------|-------------|
| **Server Actions** | Forms and mutations inside the same Next.js app (preferred for entities, opportunities) |
| **Same-origin REST** | Client components that need JSON (`fetch('/api/...')` with session cookie) |
| **Tunnel + hooks** | Live UI updates (notifications, chat, discovery) |
| **MCP / external** | Automation against `/api/mcp/v1/*` with API keys — see [API Reference](/docs/api.md) |

## Prerequisites

1. User signed in via **Auth.js v5** (`useSession()` on client, `auth()` in Route Handlers).
2. For realtime: app wrapped in **`TunnelProvider`** (see `AppClientShell`).
3. Local dev base URL: `http://localhost:3000` — production: your clone hostname.

Ring route handlers authenticate via **session cookie**, not a manual `Bearer` token from browser code. Use `credentials: 'include'` (fetch default on same origin) so the Auth.js session is sent automatically.

## Quick smoke test (curl)

After signing in through the browser, copy the session cookie for curl, or test public GETs that allow anonymous access where documented.

```bash
# List opportunities (requires session cookie in production)
curl -s "http://localhost:3000/api/opportunities?limit=5" \
  -H "Cookie: authjs.session-token=YOUR_SESSION_COOKIE"

# Tunnel diagnostics (no auth)
curl -s "http://localhost:3000/api/tunnel/test"
```

Full endpoint lists: [Entities](/docs/api/entities.md), [Opportunities](/docs/api/opportunities.md), [Messaging](/docs/api/messaging.md), [Notifications](/docs/api/notifications.md).

---

## HTTP client helper

Use a small wrapper for consistent errors and JSON parsing. Keep paths under `/api` — do not hardcode a white-label hostname unless you are calling a **remote** clone.

{`export class APIError extends Error {
  constructor(
    public status: number,
    public statusText: string,
    public data?: unknown
  ) {
    super(\`API \${status}: \${statusText}\`)
    this.name = 'APIError'
  }
}

export async function ringFetch(
  path: string,
  init: RequestInit = {}
): Promise {
  const response = await fetch(path, {
    ...init,
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json',
      ...init.headers,
    },
  })

  if (!response.ok) {
    const data = await response.json().catch(() => null)
    throw new APIError(response.status, response.statusText, data)
  }

  return response.json() as Promise
}`}

### Typed helpers (optional)

{`import { ringFetch } from './ring-fetch'
import type { SerializedEntity } from '@/features/entities/types'

export const ringApi = {
  getEntities(params?: { limit?: number; startAfter?: string }) {
    const qs = new URLSearchParams()
    if (params?.limit) qs.set('limit', String(params.limit))
    if (params?.startAfter) qs.set('startAfter', params.startAfter)
    const query = qs.toString()
    return ringFetch<{ entities: SerializedEntity[]; lastVisible?: string }>(
      \`/api/entities\${query ? \`?\${query}\` : ''}\`
    )
  },

  createEntity(body: Record<string, unknown>) {
    return ringFetch<{ entity: SerializedEntity }>('/api/entities/create', {
      method: 'POST',
      body: JSON.stringify(body),
    })
  },

  deleteEntity(id: string) {
    return ringFetch<{ message: string; id: string }>(\`/api/entities/\${id}\`, {
      method: 'DELETE',
      body: JSON.stringify({ confirm: true }),
    })
  },

  getOpportunities(params?: { limit?: number; startAfter?: string }) {
    const qs = new URLSearchParams()
    if (params?.limit) qs.set('limit', String(params.limit))
    if (params?.startAfter) qs.set('startAfter', params.startAfter)
    const query = qs.toString()
    return ringFetch<{ opportunities: unknown[]; lastVisible?: string }>(
      \`/api/opportunities\${query ? \`?\${query}\` : ''}\`
    )
  },

  getConversations(params?: { limit?: number; page?: number }) {
    const qs = new URLSearchParams()
    if (params?.limit) qs.set('limit', String(params.limit))
    if (params?.page) qs.set('page', String(params.page))
    const query = qs.toString()
    return ringFetch<{ conversations: unknown[] }>(
      \`/api/conversations\${query ? \`?\${query}\` : ''}\`
    )
  },

  getMessages(conversationId: string, params?: { limit?: number; cursor?: string }) {
    const qs = new URLSearchParams()
    if (params?.limit) qs.set('limit', String(params.limit))
    if (params?.cursor) qs.set('cursor', params.cursor)
    const query = qs.toString()
    return ringFetch<{ messages: unknown[] }>(
      \`/api/conversations/\${conversationId}/messages\${query ? \`?\${query}\` : ''}\`
    )
  },

  sendMessage(conversationId: string, content: string) {
    return ringFetch<{ message: unknown }>(
      \`/api/conversations/\${conversationId}/messages\`,
      { method: 'POST', body: JSON.stringify({ content, type: 'text' }) }
    )
  },

  getNotifications(params?: { page?: number; limit?: number; unreadOnly?: boolean }) {
    const qs = new URLSearchParams()
    if (params?.page) qs.set('page', String(params.page))
    if (params?.limit) qs.set('limit', String(params.limit))
    if (params?.unreadOnly) qs.set('unreadOnly', 'true')
    const query = qs.toString()
    return ringFetch<{ notifications: unknown[]; unreadCount: number }>(
      \`/api/notifications\${query ? \`?\${query}\` : ''}\`
    )
  },

  markNotificationRead(id: string) {
    return ringFetch<{ success: boolean }>(\`/api/notifications/\${id}/read\`, {
      method: 'POST',
    })
  },
}`}

Entity and opportunity **create/update** flows in production UI often use **Server Actions** (`app/_actions/*`) with `useActionState`, not raw POST from client components. Use REST when building a separate client or admin tool; use Server Actions when extending Ring pages.

---

## Entities

**Routes:** `GET /api/entities` · `POST /api/entities/create` · `GET /api/entities/{id}` · `DELETE /api/entities/{id}` (body: `{ confirm: true }`)

Responses use **`SerializedEntity`** (ISO date strings). See [Entities API](/docs/api/entities.md) for the full shape and role-based visibility.

{`'use client'

import { useEffect, useState } from 'react'
import { ringApi } from '@/lib/ring-api'
import type { SerializedEntity } from '@/features/entities/types'

export function EntityListClient() {
  const [entities, setEntities] = useState<SerializedEntity[]>([])
  const [error, setError] = useState(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    ringApi
      .getEntities({ limit: 20 })
      .then((res) => setEntities(res.entities))
      .catch((err) => setError(err instanceof Error ? err.message : 'Failed to load'))
      .finally(() => setLoading(false))
  }, [])

  if (loading) return Loading…
  if (error) return {error}

  return (
    
      {entities.map((entity) => (
        
          {entity.name} — {entity.shortDescription}
        
      ))}
    
  )
}`}

After mutations, list freshness is driven by `revalidatePath` + Tunnel `entity:*` events ([Discovery mutation sync](/docs/architecture/discovery-mutation-sync.md)).

---

## Opportunities

**Routes:** `GET`/`POST /api/opportunities` · `GET`/`PUT`/`PATCH`/`DELETE /api/opportunities/{id}` · see [Opportunities API](/docs/api/opportunities.md).

{`'use client'

import { useEffect, useState } from 'react'
import { ringApi } from '@/lib/ring-api'

interface OpportunityRow {
  id: string
  title: string
  type: string
  nature: 'offer' | 'request'
  shortDescription?: string
}

export function OpportunityListClient() {
  const [items, setItems] = useState<OpportunityRow[]>([])

  useEffect(() => {
    ringApi.getOpportunities({ limit: 12 }).then((res) => {
      setItems(res.opportunities as OpportunityRow[])
    })
  }, [])

  return (
    
      {items.map((opp) => (
        
          {opp.title}
          {opp.type} · {opp.nature}
        
      ))}
    
  )
}`}

Subscribe to channel `opportunities` with `useTunnel().subscribe()` if you need live list invalidation without a full page refresh.

---

## Messaging (REST + Tunnel)

**Routes:** `GET /api/conversations` · `GET|POST /api/conversations/{id}/messages` · `POST /api/conversations/{id}/read` · `POST /api/conversations/{id}/typing`

Persist messages through REST; receive live updates on `conversation:${id}` via Tunnel ([Messaging API](/docs/api/messaging.md), [Tunnel protocol](/docs/features/tunnel-protocol.md)).

{`'use client'

import { useEffect, useState } from 'react'
import { useTunnel } from '@/hooks/use-tunnel'
import { ringApi } from '@/lib/ring-api'

interface ChatMessage {
  id: string
  content: string
  senderId: string
  createdAt: string
}

export function ConversationThread({ conversationId }: { conversationId: string }) {
  const [messages, setMessages] = useState<ChatMessage[]>([])
  const [draft, setDraft] = useState('')
  const { subscribe, isConnected } = useTunnel()

  useEffect(() => {
    ringApi.getMessages(conversationId, { limit: 50 }).then((res) => {
      setMessages(res.messages as ChatMessage[])
    })
  }, [conversationId])

  useEffect(() => {
    if (!isConnected) return
    return subscribe(\`conversation:\${conversationId}\`, (message) => {
      if (message.event === 'message:new') {
        setMessages((prev) => [...prev, message.payload as ChatMessage])
      }
    })
  }, [conversationId, isConnected, subscribe])

  async function handleSend(e: React.FormEvent) {
    e.preventDefault()
    if (!draft.trim()) return
    await ringApi.sendMessage(conversationId, draft.trim())
    setDraft('')
  }

  return (
    
      
        {messages.map((m) => (
          {m.content}
        ))}
      
      
         setDraft(e.target.value)}
          className="flex-1 rounded border px-3 py-2"
          placeholder="Message…"
        />
        
          Send
        
      
    
  )
}`}

---

## Notifications (REST + Tunnel)

**Routes:** `GET /api/notifications` · `POST /api/notifications/{id}/read` · `POST /api/notifications/read-all` · FCM: `POST /api/notifications/fcm/register`

Production UI uses **`useUnreadCount`** (wraps `useSync` on `notifications:unread`). For a custom notification center, combine list fetch with the same tunnel channel.

{`'use client'

import { useEffect, useState } from 'react'
import { useUnreadCount } from '@/hooks/use-unread-count'
import { ringApi } from '@/lib/ring-api'

interface NotificationItem {
  id: string
  title: string
  message: string
  status: 'unread' | 'read'
  createdAt: string
}

export function NotificationBell() {
  const { unreadCount, refresh: refreshCount } = useUnreadCount({ enableTunnel: true })
  const [open, setOpen] = useState(false)
  const [items, setItems] = useState<NotificationItem[]>([])

  useEffect(() => {
    if (!open) return
    ringApi.getNotifications({ limit: 20 }).then((res) => {
      setItems(res.notifications as NotificationItem[])
    })
  }, [open, unreadCount])

  async function markRead(id: string) {
    await ringApi.markNotificationRead(id)
    setItems((prev) =>
      prev.map((n) => (n.id === id ? { ...n, status: 'read' as const } : n))
    )
    await refreshCount()
  }

  return (
    
       setOpen((v) => !v)}>
        Notifications {unreadCount > 0 ? `(${unreadCount})` : ''}
      
      {open && (
        
          {items.map((n) => (
            
              {n.title}
              {n.status === 'unread' && (
                 markRead(n.id)}>
                  Mark read
                
              )}
            
          ))}
        
      )}
    
  )
}`}

Server-side, new notifications call `publishToUserTunnel(userId, 'notifications:unread', { count })` — see [Notifications API](/docs/api/notifications.md).

---

## Error handling

Route handlers return JSON `{ error: string, ... }` with standard HTTP status codes. Notifications are **rate-limited** (429 with `Retry-After`).

{`'use client'

import { useCallback } from 'react'
import { APIError } from '@/lib/ring-fetch'

export function useApiError() {
  return useCallback((error: unknown) => {
    if (error instanceof APIError) {
      switch (error.status) {
        case 401:
          window.location.href = '/auth/signin'
          break
        case 403:
          console.warn('Forbidden', error.data)
          break
        case 429:
          console.warn('Rate limited', error.data)
          break
        default:
          console.error(error.message, error.data)
      }
      return
    }
    console.error('Unexpected error', error)
  }, [])
}`}

Use with `try/catch` around `ringApi` calls or pass to `useSync` `onError`.

---

## Server-side integration

In Server Components, Server Actions, or Route Handlers, call services directly — no `fetch` loopback required:

```typescript
import { auth } from '@/auth'
import { initializeDatabase, getDatabaseService } from '@/lib/database/DatabaseService'
import { publishToUserTunnel } from '@/lib/tunnel/publisher'

export async function notifyUser(userId: string, count: number) {
  await initializeDatabase()
  const db = getDatabaseService()
  // … persist notification via db.create('notifications', …)
  await publishToUserTunnel(userId, 'notifications:unread', { count })
}
```

---

## Related documentation

- [API Reference](/docs/api.md) — full route index
- [Authentication](/docs/api/authentication.md) — Auth.js providers and session
- [Tunnel protocol](/docs/features/tunnel-protocol.md) — SSE, poll, env vars
- [Web3 integration](/docs/examples/web3-integration.md) — wallet and on-chain flows
- [Real-world apps](/docs/examples/real-world.md) — end-to-end clone patterns
