Proxying External APIs
How to safely call third-party APIs from your plugin without CORS issues, using the built-in createExternalProxy middleware.
The Problem#
When your plugin frontend needs to communicate with a third-party API (e.g., Livepeer, Stripe, OpenAI), the browser will block the request with a CORS error if the external server doesn't include Access-Control-Allow-Origin in its response headers.
Access to fetch at 'https://api.external.com/...' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.You cannot fix this from your frontend code. The external server controls its own CORS headers. The solution is to route the request through your plugin's backend, which acts as a transparent proxy.
The Solution: Backend Proxy#
Browser ──fetch()──> Plugin Backend ──fetch()──> External API
(same origin, (server-to-server,
no CORS issue) no CORS restriction)The @naap/plugin-server-sdk provides a reusable createExternalProxy middleware that handles this pattern with zero boilerplate. It includes:
- SSRF protection — only allows requests to whitelisted hosts
- Content type handling — works with JSON, SDP, XML, or any content type
- Error forwarding — propagates external API errors cleanly
- Timeout handling — configurable request timeouts
- Authorization — optional auth callback before proxying
- Header exposure — selectively expose response headers to the browser
Quick Start#
1. Add the proxy route to your backend#
| 1 | // backend/src/server.ts |
| 2 | import { createPluginServer, createExternalProxy } from '@naap/plugin-server-sdk'; |
| 3 | |
| 4 | const { router, start } = createPluginServer({ |
| 5 | name: 'my-plugin', |
| 6 | port: 4020, |
| 7 | }); |
| 8 | |
| 9 | // Proxy requests to an external API |
| 10 | router.post( |
| 11 | '/my-prefix/external-proxy', |
| 12 | ...createExternalProxy({ |
| 13 | allowedHosts: ['api.example.com', 'api.stripe.com'], |
| 14 | targetUrlHeader: 'X-Target-URL', |
| 15 | }) |
| 16 | ); |
| 17 | |
| 18 | start(); |
2. Call the proxy from your frontend#
| 1 | // frontend/src/lib/api.ts |
| 2 | import { getPluginBackendUrl } from '@naap/plugin-sdk'; |
| 3 | |
| 4 | async function callExternalApi(targetUrl: string, body: object) { |
| 5 | const proxyUrl = getPluginBackendUrl('my-plugin', { |
| 6 | apiPath: '/api/v1/my-prefix/external-proxy', |
| 7 | }); |
| 8 | |
| 9 | const response = await fetch(proxyUrl, { |
| 10 | method: 'POST', |
| 11 | headers: { |
| 12 | 'Content-Type': 'application/json', |
| 13 | 'X-Target-URL': targetUrl, // Tell the proxy where to forward |
| 14 | 'Authorization': `Bearer ${authToken}`, |
| 15 | }, |
| 16 | body: JSON.stringify(body), |
| 17 | }); |
| 18 | |
| 19 | return response.json(); |
| 20 | } |
That's it. The proxy validates the target URL, forwards the request, and returns the response — all without CORS issues.
How It Works#
- Frontend sends a POST to your backend with the target URL in a custom header
- Backend proxy validates the URL against the allowed hosts list (SSRF protection)
- Backend proxy forwards the request body and content type to the external API
- External API responds to the backend (server-to-server, no CORS)
- Backend proxy returns the external API's response to the browser
Configuration Options#
| Option | Type | Default | Description |
|---|---|---|---|
allowedHosts | string[] | (required) | Hostnames the proxy can forward to. Uses suffix matching, so 'stripe.com' allows api.stripe.com |
targetUrlHeader | string | 'X-Target-URL' | Request header containing the target URL |
contentType | string | 'application/json' | Content type for the proxied request/response |
bodyLimit | string | '1mb' | Maximum body size |
exposeHeaders | { from, to }[] | [] | External response headers to expose to the browser |
forwardHeaders | Record | Function | — | Additional headers to send to the external API |
timeout | number | 30000 | Request timeout in milliseconds |
authorize | (req) => boolean | — | Optional authorization check before proxying |
logger | { info, error } | console | Custom logger |
Real-World Example: WebRTC WHIP Proxy#
The Daydream AI Video plugin uses createExternalProxy to proxy WebRTC WHIP SDP handshakes to Livepeer's ingest server:
| 1 | // plugins/daydream-video/backend/src/server.ts |
| 2 | import { createPluginServer, createExternalProxy } from '@naap/plugin-server-sdk'; |
| 3 | |
| 4 | router.post( |
| 5 | '/daydream/whip-proxy', |
| 6 | ...createExternalProxy({ |
| 7 | // Only allow Livepeer WHIP endpoints |
| 8 | allowedHosts: ['ai.livepeer.com', 'livepeer.studio', 'api.daydream.live'], |
| 9 | targetUrlHeader: 'X-WHIP-URL', |
| 10 | contentType: 'application/sdp', // WebRTC SDP, not JSON |
| 11 | exposeHeaders: [ |
| 12 | { from: 'Location', to: 'X-WHIP-Resource' }, // WHIP resource URL |
| 13 | ], |
| 14 | timeout: 30_000, |
| 15 | authorize: async (req) => { |
| 16 | // Only allow users with a valid API key |
| 17 | const userId = getUserId(req); |
| 18 | await getUserApiKey(userId); |
| 19 | return true; |
| 20 | }, |
| 21 | }) |
| 22 | ); |
Frontend usage:
| 1 | // plugins/daydream-video/frontend/src/hooks/useWHIP.ts |
| 2 | const proxyUrl = getPluginBackendUrl('daydream-video', { |
| 3 | apiPath: '/api/v1/daydream/whip-proxy', |
| 4 | }); |
| 5 | |
| 6 | const response = await fetch(proxyUrl, { |
| 7 | method: 'POST', |
| 8 | headers: { |
| 9 | 'Content-Type': 'application/sdp', |
| 10 | 'X-WHIP-URL': whipUrl, // External WHIP endpoint |
| 11 | 'Authorization': `Bearer ${token}`, |
| 12 | 'X-Plugin-Name': 'daydream-video', |
| 13 | }, |
| 14 | body: sdpOffer, // WebRTC SDP offer (plain text, not JSON) |
| 15 | }); |
| 16 | |
| 17 | const answerSdp = await response.text(); |
SSRF Protection#
The proxy validates every target URL against the allowedHosts list using hostname suffix matching. This prevents Server-Side Request Forgery (SSRF) attacks where an attacker could trick your backend into making requests to internal services.
| 1 | // This configuration: |
| 2 | allowedHosts: ['api.stripe.com', 'livepeer.com'] |
| 3 | |
| 4 | // Allows: |
| 5 | 'https://api.stripe.com/v1/charges' // ✅ matches api.stripe.com |
| 6 | 'https://ai.livepeer.com/live/whip' // ✅ ends with livepeer.com |
| 7 | |
| 8 | // Blocks: |
| 9 | 'http://localhost:3000/admin' // ❌ not in allowed list |
| 10 | 'http://169.254.169.254/metadata' // ❌ AWS metadata endpoint |
| 11 | 'https://evil.com?host=api.stripe.com' // ❌ not a suffix match |
Adding Custom Headers to External Requests#
If the external API requires authentication or custom headers:
| 1 | router.post( |
| 2 | '/my-prefix/api-proxy', |
| 3 | ...createExternalProxy({ |
| 4 | allowedHosts: ['api.openai.com'], |
| 5 | targetUrlHeader: 'X-Target-URL', |
| 6 | // Static headers |
| 7 | forwardHeaders: { |
| 8 | 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`, |
| 9 | }, |
| 10 | // OR dynamic headers based on request |
| 11 | forwardHeaders: (req) => ({ |
| 12 | 'Authorization': `Bearer ${getUserApiKey(req)}`, |
| 13 | 'OpenAI-Organization': getOrgId(req), |
| 14 | }), |
| 15 | }) |
| 16 | ); |
Error Handling#
The proxy forwards HTTP errors from the external API with structured error responses:
| 1 | { |
| 2 | "success": false, |
| 3 | "error": { |
| 4 | "message": "External API returned 429: Rate limit exceeded" |
| 5 | } |
| 6 | } |
| Scenario | HTTP Status | Error Message |
|---|---|---|
| Missing target URL header | 400 | Missing X-Target-URL header |
| Invalid URL format | 400 | Invalid URL in X-Target-URL header |
| Host not in allowed list | 400 | Host "evil.com" is not in the allowed list |
| Empty request body | 400 | Request body is required |
| Auth callback returns false | 403 | Proxy request not authorized |
| External API returns error | (forwarded) | External API returned {status}: {body} |
| Request timeout | 504 | External API request timed out |
| Network error | 500 | External proxy failed |
Best Practices#
- Always proxy external calls through your backend — Never make direct
fetch()from the browser to APIs you don't control - Whitelist specific hosts — Don't use overly broad host patterns; list exactly the domains you need
- Add authorization — Use the
authorizecallback to ensure only authenticated users can use the proxy - Set appropriate timeouts — Match the timeout to the expected response time of the external API
- Keep API keys server-side — Use
forwardHeadersto inject API keys that never reach the browser - Log sparingly — The proxy logs the target hostname and path by default; avoid logging request bodies that may contain sensitive data
When NOT to Use the Proxy#
- Same-origin API calls — Calls to your own backend don't need proxying (no CORS issue)
- iframe embeds — Loading a URL in an
<iframe>doesn't trigger CORS restrictions - WebRTC media streams — Only the SDP handshake needs proxying; the actual media goes peer-to-peer
- STUN/TURN servers — WebRTC ICE servers use their own protocol, not HTTP CORS
- Image/font/CSS loading — Browsers relax CORS for some resource types