External API Proxy
Complete example: proxy third-party API calls through your plugin backend to avoid CORS issues.
Overview#
This example demonstrates the complete pattern for calling an external API from your plugin frontend by proxying through your plugin backend. We'll build two real-world scenarios:
- JSON API proxy — Call a REST API (like Stripe or OpenAI) through your backend
- Binary/SDP proxy — Proxy non-JSON content like WebRTC SDP handshakes
Scenario 1: JSON API Proxy#
A plugin that calls the OpenAI API from the frontend, proxied through the backend to keep the API key server-side and avoid CORS.
Backend Setup#
| 1 | // backend/src/server.ts |
| 2 | import { createPluginServer, createExternalProxy } from '@naap/plugin-server-sdk'; |
| 3 | |
| 4 | const { router, start } = createPluginServer({ |
| 5 | name: 'ai-assistant', |
| 6 | port: 4020, |
| 7 | }); |
| 8 | |
| 9 | // Proxy OpenAI API calls |
| 10 | router.post( |
| 11 | '/assistant/openai-proxy', |
| 12 | ...createExternalProxy({ |
| 13 | allowedHosts: ['api.openai.com'], |
| 14 | targetUrlHeader: 'X-Target-URL', |
| 15 | contentType: 'application/json', |
| 16 | bodyLimit: '1mb', |
| 17 | timeout: 60_000, // LLM calls can be slow |
| 18 | forwardHeaders: { |
| 19 | // API key stays server-side — never reaches the browser |
| 20 | 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`, |
| 21 | }, |
| 22 | authorize: async (req) => { |
| 23 | // Only authenticated users can use the proxy |
| 24 | const userId = (req as any).user?.id; |
| 25 | if (!userId) return false; |
| 26 | |
| 27 | // Optional: rate limiting, usage tracking, etc. |
| 28 | return true; |
| 29 | }, |
| 30 | }) |
| 31 | ); |
| 32 | |
| 33 | start(); |
Frontend Hook#
| 1 | // frontend/src/hooks/useAICompletion.ts |
| 2 | import { useState, useCallback } from 'react'; |
| 3 | import { getPluginBackendUrl, getCsrfToken } from '@naap/plugin-sdk'; |
| 4 | |
| 5 | export function useAICompletion() { |
| 6 | const [loading, setLoading] = useState(false); |
| 7 | const [error, setError] = useState<string | null>(null); |
| 8 | |
| 9 | const complete = useCallback(async (prompt: string): Promise<string> => { |
| 10 | setLoading(true); |
| 11 | setError(null); |
| 12 | |
| 13 | try { |
| 14 | const proxyUrl = getPluginBackendUrl('ai-assistant', { |
| 15 | apiPath: '/api/v1/assistant/openai-proxy', |
| 16 | }); |
| 17 | |
| 18 | const response = await fetch(proxyUrl, { |
| 19 | method: 'POST', |
| 20 | headers: { |
| 21 | 'Content-Type': 'application/json', |
| 22 | 'X-Target-URL': 'https://api.openai.com/v1/chat/completions', |
| 23 | 'X-CSRF-Token': getCsrfToken() || '', |
| 24 | }, |
| 25 | body: JSON.stringify({ |
| 26 | model: 'gpt-4', |
| 27 | messages: [{ role: 'user', content: prompt }], |
| 28 | max_tokens: 500, |
| 29 | }), |
| 30 | }); |
| 31 | |
| 32 | if (!response.ok) { |
| 33 | const err = await response.json(); |
| 34 | throw new Error(err.error?.message || 'AI request failed'); |
| 35 | } |
| 36 | |
| 37 | const data = await response.json(); |
| 38 | return data.choices[0].message.content; |
| 39 | } catch (err) { |
| 40 | const msg = err instanceof Error ? err.message : 'Unknown error'; |
| 41 | setError(msg); |
| 42 | throw err; |
| 43 | } finally { |
| 44 | setLoading(false); |
| 45 | } |
| 46 | }, []); |
| 47 | |
| 48 | return { complete, loading, error }; |
| 49 | } |
React Component#
| 1 | // frontend/src/components/AIChat.tsx |
| 2 | import React, { useState } from 'react'; |
| 3 | import { useAICompletion } from '../hooks/useAICompletion'; |
| 4 | |
| 5 | export function AIChat() { |
| 6 | const { complete, loading, error } = useAICompletion(); |
| 7 | const [prompt, setPrompt] = useState(''); |
| 8 | const [response, setResponse] = useState(''); |
| 9 | |
| 10 | const handleSubmit = async (e: React.FormEvent) => { |
| 11 | e.preventDefault(); |
| 12 | if (!prompt.trim()) return; |
| 13 | |
| 14 | try { |
| 15 | const result = await complete(prompt); |
| 16 | setResponse(result); |
| 17 | } catch { |
| 18 | // Error is already in the hook's state |
| 19 | } |
| 20 | }; |
| 21 | |
| 22 | return ( |
| 23 | <div className="p-6 max-w-2xl mx-auto"> |
| 24 | <form onSubmit={handleSubmit} className="flex gap-2"> |
| 25 | <input |
| 26 | value={prompt} |
| 27 | onChange={(e) => setPrompt(e.target.value)} |
| 28 | placeholder="Ask anything..." |
| 29 | className="flex-1 px-4 py-2 border rounded-lg" |
| 30 | disabled={loading} |
| 31 | /> |
| 32 | <button |
| 33 | type="submit" |
| 34 | disabled={loading} |
| 35 | className="px-6 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50" |
| 36 | > |
| 37 | {loading ? 'Thinking...' : 'Ask'} |
| 38 | </button> |
| 39 | </form> |
| 40 | |
| 41 | {error && ( |
| 42 | <div className="mt-4 p-3 bg-red-50 text-red-700 rounded-lg"> |
| 43 | {error} |
| 44 | </div> |
| 45 | )} |
| 46 | |
| 47 | {response && ( |
| 48 | <div className="mt-4 p-4 bg-gray-50 rounded-lg whitespace-pre-wrap"> |
| 49 | {response} |
| 50 | </div> |
| 51 | )} |
| 52 | </div> |
| 53 | ); |
| 54 | } |
Scenario 2: WebRTC SDP Proxy (Non-JSON)#
The Daydream AI Video plugin proxies WebRTC WHIP SDP handshakes — plain text with Content-Type: application/sdp.
Backend#
| 1 | // backend/src/server.ts |
| 2 | import { createPluginServer, createExternalProxy } from '@naap/plugin-server-sdk'; |
| 3 | |
| 4 | const { router, start } = createPluginServer({ |
| 5 | name: 'daydream-video', |
| 6 | port: 4111, |
| 7 | }); |
| 8 | |
| 9 | router.post( |
| 10 | '/daydream/whip-proxy', |
| 11 | ...createExternalProxy({ |
| 12 | allowedHosts: ['ai.livepeer.com', 'livepeer.studio', 'api.daydream.live'], |
| 13 | targetUrlHeader: 'X-WHIP-URL', |
| 14 | contentType: 'application/sdp', // Not JSON! |
| 15 | exposeHeaders: [ |
| 16 | { from: 'Location', to: 'X-WHIP-Resource' }, |
| 17 | ], |
| 18 | timeout: 30_000, |
| 19 | authorize: async (req) => { |
| 20 | const userId = getUserId(req); |
| 21 | await getUserApiKey(userId); // Throws if no key |
| 22 | return true; |
| 23 | }, |
| 24 | }) |
| 25 | ); |
| 26 | |
| 27 | start(); |
Frontend#
| 1 | // frontend/src/hooks/useWHIP.ts |
| 2 | import { getPluginBackendUrl } from '@naap/plugin-sdk'; |
| 3 | |
| 4 | async function sendSdpOffer(whipUrl: string, sdpOffer: string): Promise<string> { |
| 5 | const proxyUrl = getPluginBackendUrl('daydream-video', { |
| 6 | apiPath: '/api/v1/daydream/whip-proxy', |
| 7 | }); |
| 8 | |
| 9 | const response = await fetch(proxyUrl, { |
| 10 | method: 'POST', |
| 11 | headers: { |
| 12 | 'Content-Type': 'application/sdp', |
| 13 | 'X-WHIP-URL': whipUrl, |
| 14 | 'Authorization': `Bearer ${getAuthToken()}`, |
| 15 | }, |
| 16 | body: sdpOffer, // Raw SDP text, not JSON |
| 17 | }); |
| 18 | |
| 19 | if (!response.ok) { |
| 20 | throw new Error(`WHIP failed: ${response.status}`); |
| 21 | } |
| 22 | |
| 23 | // Response is also raw SDP text |
| 24 | return response.text(); |
| 25 | } |
Key Differences by Content Type#
| Feature | JSON Proxy | SDP/Text Proxy |
|---|---|---|
| Content-Type | application/json | application/sdp (or any text type) |
| Body parser | express.json() | express.text() |
| Request body | JSON.stringify(data) | Raw text string |
| Response parsing | response.json() | response.text() |
| Use case | REST APIs (Stripe, OpenAI, etc.) | WebRTC, XML, CSV, etc. |
Adding a Custom Header to CORS#
If your proxy uses a custom targetUrlHeader (not one of the built-in headers), you need to add it to the shared CORS headers so the browser's preflight OPTIONS request succeeds:
| 1 | // packages/types/src/http-headers.ts |
| 2 | export const CUSTOM_HEADERS = [ |
| 3 | 'X-CSRF-Token', |
| 4 | 'X-Correlation-ID', |
| 5 | 'X-Plugin-Name', |
| 6 | 'X-Request-ID', |
| 7 | 'X-Trace-ID', |
| 8 | 'X-Team-ID', |
| 9 | 'X-WHIP-URL', // Already included |
| 10 | 'X-My-Custom-Header', // Add your custom header here |
| 11 | ] as const; |
This single change propagates to all plugin backends automatically via CORS_ALLOWED_HEADERS.
Testing Your Proxy#
Health check#
CORS preflight#
Expected:
Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: Content-Type,...,X-Target-URL,...Proxy request (with auth)#
Common Mistakes#
1. Calling external APIs directly from the browser#
| 1 | // BAD: Direct browser call — will fail with CORS error |
| 2 | const res = await fetch('https://api.stripe.com/v1/charges', { ... }); |
| 3 | |
| 4 | // GOOD: Route through your backend proxy |
| 5 | const res = await fetch(proxyUrl, { |
| 6 | headers: { 'X-Target-URL': 'https://api.stripe.com/v1/charges' }, |
| 7 | ... |
| 8 | }); |
2. Exposing API keys in the frontend#
| 1 | // BAD: API key in browser code |
| 2 | headers: { 'Authorization': `Bearer ${process.env.STRIPE_KEY}` } |
| 3 | |
| 4 | // GOOD: Use forwardHeaders in the backend proxy |
| 5 | forwardHeaders: { |
| 6 | 'Authorization': `Bearer ${process.env.STRIPE_SECRET_KEY}`, |
| 7 | } |
3. Using overly broad allowed hosts#
| 1 | // BAD: Too permissive |
| 2 | allowedHosts: ['.com'] |
| 3 | |
| 4 | // GOOD: Specific hosts only |
| 5 | allowedHosts: ['api.stripe.com'] |
Related#
- Proxying External APIs Guide — Step-by-step tutorial
- External Proxy API Reference — Full API docs
- Backend Development — General backend setup