@spicyapi/proxy
v0.2.0
Published
Server-side proxy for SpicyAPI: let browser, mobile and desktop apps call the API without ever holding an API key.
Readme
@spicyapi/proxy
Let a browser, mobile or desktop app call SpicyAPI without ever holding an API key.
A key compiled into a client is a public key. Anyone can decompile the app or watch one request, and then spend your balance until it runs out — while your dashboard just shows a busy day. The fix is not obfuscation. It is to never put the key there: the app calls your server, and your server adds the credential.
This package is that server-side hop.
Install
npm install @spicyapi/proxySet SPICY_API_KEY in the server environment. It is never sent to the client.
Next.js (App Router)
// app/api/spicy/proxy/route.ts
export { GET, POST, PUT, PATCH, DELETE } from "@spicyapi/proxy/nextjs";
export const runtime = "nodejs";runtime = "nodejs" is not optional. Edge runtimes do not see server-only environment variables, so
the proxy would answer 500 in production while working perfectly on your machine.
To pass options, build the handlers yourself instead of re-exporting the defaults:
// app/api/spicy/proxy/route.ts
import { createRoute } from "@spicyapi/proxy/nextjs";
export const { GET, POST, PUT, PATCH, DELETE } = createRoute({ timeoutMs: 60_000 });
export const runtime = "nodejs";Express
import { createExpressHandler } from "@spicyapi/proxy/express";
app.all("/api/spicy/proxy", createExpressHandler());Mount it before any body parser. express.json() consumes the request body, and the proxy would
then forward an empty one — which surfaces upstream as a missing-parameter error that points nowhere
near your middleware order.
Any fetch runtime
import { createProxyHandler } from "@spicyapi/proxy";
const handle = createProxyHandler();
// handle(request: Request) => Promise<Response>Options
| Option | Default | Meaning |
| ---------------- | ----------------------------- | ------------------------------------------------------------------ |
| apiKey | process.env.SPICY_API_KEY | A string, or a function read on every request (for rotation) |
| allowedOrigins | ["https://api.spicyapi.ai"] | Origins it may forward to; widen only for local development |
| timeoutMs | 120000 | How long to wait for the upstream response headers (see below) |
How a client uses it
Point the client at your own route and put the real target in x-spicy-target-url:
await fetch("/api/spicy/proxy", {
method: "POST",
headers: {
"content-type": "application/json",
"x-spicy-target-url": "https://api.spicyapi.ai/api/v1/jobs/createTask",
"idempotency-key": crypto.randomUUID(),
},
body: JSON.stringify({ model, input }),
});Any path and method on the allow-listed origin is forwarded — the media task routes under /api/v1,
the chat routes under /v1 and /v1beta, uploads, and whatever the API adds next. The proxy
decides where a request may go, not which calls your users may make; see Limits.
Streaming chat
Chat models stream through the same route. The response body is passed on as it arrives, unbuffered:
const response = await fetch("/api/spicy/proxy", {
method: "POST",
headers: {
"content-type": "application/json",
"x-spicy-target-url": "https://api.spicyapi.ai/v1/chat/completions",
"idempotency-key": crypto.randomUUID(),
},
body: JSON.stringify({
model, // a chat model from GET /v1/models?modality=text
stream: true,
messages: [{ role: "user", content: "Explain a rainbow in one sentence." }],
}),
});
// response.body is the server-sent event stream; read it as it arrives.timeoutMs covers only the wait for the response headers. Once a stream has started it runs until
the model finishes or the client disconnects, however long the model reasons. That is deliberate:
SpicyAPI bills a stream the caller abandons for the usage the model reports, so a proxy that cut
long streams off would make users pay for text they never saw. If something in front of this route
(a reverse proxy, a CDN) buffers or times out responses, configure it to pass streams through.
Uploading a file from the browser
An upload is three requests, and only two of them go through the proxy:
async function viaProxy(target: string, body?: unknown) {
const response = await fetch("/api/spicy/proxy", {
method: "POST",
headers: { "content-type": "application/json", "x-spicy-target-url": target },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const json = await response.json();
if (json.code !== 200) throw new Error(json.msg);
return json.data;
}
// `file` is a File, for example from <input type="file">.
// 1. A ticket, through the proxy. contentType must be a type the API accepts (image/png, video/mp4, …).
const ticket = await viaProxy("https://api.spicyapi.ai/api/v1/common/upload-url", {
contentType: file.type,
bytes: file.size,
});
// 2. The bytes, straight to storage - NOT through the proxy.
const headers = new Headers(ticket.headers);
headers.delete("Content-Length"); // browsers derive it from the File
const put = await fetch(ticket.uploadUrl, { method: ticket.method, headers, body: file });
if (!put.ok) throw new Error(`upload failed with HTTP ${put.status}`);
// 3. Commit, through the proxy. Its `uri` (spicy://f/...) goes into task input or a chat media part.
const { uri } = await viaProxy(`https://api.spicyapi.ai/api/v1/files/${ticket.fileId}/commit`);Step 2 must not go through the proxy, and the proxy refuses it anyway: the presigned uploadUrl
points at a storage host, not at https://api.spicyapi.ai, and it is a credential in its own right.
Adding your SpicyAPI key to that request would hand the key to the storage host.
The committed uri works wherever a model takes a file: in a media task's input, or in a chat
message part such as {"type": "image_url", "image_url": {"url": uri}} for chat models whose schema
accepts images (some also accept video_url and audio_url parts).
GET /v1/videos/{id}/content answers with a redirect to a signed download link. The proxy passes
the redirect back as it is, so the browser downloads the file directly from storage, without your
key.
What it refuses
| Situation | Answer |
| -------------------------------- | ------ |
| No x-spicy-target-url | 400 |
| Target origin not allow-listed | 403 |
| No key configured on the server | 500 |
| Upstream could not be reached | 502 |
| Upstream sent no headers in time | 504 |
Refusals use the platform's {code, msg, request_id} envelope, so a client needs no separate
parsing path for them.
The allow-list is the whole point. Forwarding to whatever the header says would mean a stranger
can make your server hand your key to a host they control — one request, and the traffic looks
entirely normal because it came from you. The comparison is on the exact origin, never a prefix:
https://api.spicyapi.ai.attacker.example starts with our domain too.
Client-supplied authorization, x-api-key, x-goog-api-key and cookie headers are dropped
before forwarding, so the proxy cannot be turned into an open relay for someone else's credentials.
Headers it rewrites
- The client's
accept-encodingis not forwarded. The server-side fetch negotiates compression itself and decodes the reply, so the upstreamcontent-encodingis not passed back either — it would label an already decoded body as compressed, and the browser would fail to read it. - Hop-by-hop headers (
connection,transfer-encoding,host,content-lengthand the like) are recomputed for each hop rather than copied. - Everything else —
content-type,idempotency-key,x-spicy-retention,accept-languageand the response headers such asx-spicy-task-idandretry-after— passes through unchanged.
Limits
This hop authenticates to SpicyAPI. It does not authenticate your users — anyone who can reach the route can spend your balance. Put your own session check, rate limit and per-user quota in front of it, exactly as you would for any endpoint that costs money.
