---
title: Proxy setup
description: "Keep your token on the server: a small route that forwards browser calls to Nolgia with your PAT, in plain Node and in Express."
---

# Proxy setup

Browser source is public, so your PAT must stay on your server. A small proxy adds the bearer token and forwards an allow-list of routes, letting the browser quote a generation, submit it and follow its job without receiving the token.

## How it works

Your browser calls a route on your own origin under **/api/nolgia**. Your server checks that the upstream path is allowed, attaches `NOLGIA_TOKEN` from its environment, and sends the request to the Nolgia API. The server passes the upstream status and response body back to the browser.

![The browser calls your route; your server adds the PAT and calls the Nolgia API, then returns the response](../assets/diagrams/proxy-request.svg)

## Plain Node

Save this program as `proxy.mjs`. It is the fixture exercised against `GET /me` and `POST /jobs/cost` on 2026-09-21.

```js title="proxy.mjs"
// A key-keeping proxy: the browser calls this server, this server calls Nolgia with the PAT.
import { createServer } from "node:http";

const UPSTREAM = "https://api.nolgia.ai/v1";
const ALLOWED = [/^\/generate\/(image|video|audio|3d)$/, /^\/jobs\/[0-9a-f-]+(\/wait)?$/, /^\/jobs\/cost$/, /^\/me$/];

createServer(async (req, res) => {
  const url = new URL(req.url, "http://localhost");
  const path = url.pathname.replace(/^\/api\/nolgia/, "");
  if (!ALLOWED.some((rule) => rule.test(path))) {
    res.writeHead(404).end();
    return;
  }
  const upstream = await fetch(UPSTREAM + path + url.search, {
    method: req.method,
    headers: { Authorization: `Bearer ${process.env.NOLGIA_TOKEN}`, "Content-Type": req.headers["content-type"] ?? "application/json" },
    body: req.method === "GET" || req.method === "HEAD" ? undefined : req,
    duplex: "half",
  });
  res.writeHead(upstream.status, { "Content-Type": upstream.headers.get("content-type") ?? "application/json" });
  res.end(Buffer.from(await upstream.arrayBuffer()));
}).listen(3115, () => console.log("proxy on http://localhost:3115/api/nolgia"));
```

```bash
$ export NOLGIA_TOKEN=nol_...
$ node proxy.mjs
```

## Express

Use this equivalent route in an Express server. Save it as `express-proxy.mjs`; this fixture was exercised against the same two endpoints on 2026-09-21.

```bash
$ npm install express
```

```js title="express-proxy.mjs"
import express from "express";

const app = express();
app.use(express.json());
const UPSTREAM = "https://api.nolgia.ai/v1";

app.all(/^\/api\/nolgia\/(generate\/(image|video|audio|3d)|jobs\/cost|jobs\/[0-9a-f-]+(\/wait)?|me)$/, async (req, res) => {
  const path = req.path.replace(/^\/api\/nolgia/, "");
  const query = new URL(req.originalUrl, "http://localhost").search;
  const upstream = await fetch(UPSTREAM + path + query, {
    method: req.method,
    headers: { Authorization: `Bearer ${process.env.NOLGIA_TOKEN}`, "Content-Type": "application/json" },
    body: req.method === "GET" ? undefined : JSON.stringify(req.body),
  });
  res.status(upstream.status).type("application/json").send(Buffer.from(await upstream.arrayBuffer()));
});

app.listen(3115, () => console.log("proxy on http://localhost:3115/api/nolgia"));
```

```bash
$ export NOLGIA_TOKEN=nol_...
$ node express-proxy.mjs
```

> [!NOTE]
> Add your own user authentication in front of this route. These examples only keep the PAT off the client; they do not decide which of your users may spend its credits.

## Call it from the browser

Serve this browser code from the same origin as your proxy, or route **/api/nolgia** to that server in your application's development server. It quotes the exact image request, asks for confirmation, passes the returned token to the submit call, and repeats the wait when a `408` closes the wait window. The type-only import adds API types to your build and sends no PAT to the browser.

```ts title="browser.ts"
import type { components } from "@nolgia/sdk";

type Job = components["schemas"]["Job"];
type Problem = components["schemas"]["Error"];
type Quote = components["schemas"]["JobCostQuote"];

async function finish(id: string, timeout: number) {
  for (;;) {
    const response = await fetch(`/api/nolgia/jobs/${id}/wait?timeout_seconds=${timeout}`);
    if (response.status === 408) continue; // the wait window closed; the job is still running
    if (!response.ok) {
      const error: Problem = await response.json();
      throw new Error(`${error.title}: ${error.detail ?? ""}`);
    }
    const data: Job = await response.json();
    if (data?.status === "succeeded" && data.asset) return data.asset;
    throw new Error(`job ${id} ended ${data?.status ?? response.status}`);
  }
}

const image = {
  model: "flux-pro",
  prompt: "a paper-cut mountain range at dawn",
  num_images: 1,
};
const quoteResponse = await fetch("/api/nolgia/jobs/cost", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ kind: "image", image }),
});
if (!quoteResponse.ok) {
  const error: Problem = await quoteResponse.json();
  throw new Error(`${error.title}: ${error.detail ?? ""}`);
}
const quote: Quote = await quoteResponse.json();
if (!window.confirm(`Generate this image for ${quote.credits} credits?`)) {
  throw new Error("Generation declined");
}
const submitResponse = await fetch("/api/nolgia/generate/image", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ ...image, confirmation_token: quote.confirmation_token }),
});
if (!submitResponse.ok) {
  const error: Problem = await submitResponse.json();
  throw new Error(`${error.title}: ${error.detail ?? ""}`);
}
const job: Job = await submitResponse.json();
const asset = await finish(job.id, 120);
console.log(asset.signed_url);
```

The quote response is captured production JSON; the confirmation token is redacted. Use the actual token from your own quote when you submit.

```json title="200: quote"
{
  "balance_credits": 2573,
  "basis": "one_generation",
  "confirmation_token": "…",
  "credits": 4,
  "expires_at": "2026-09-21T03:43:14.171728407Z",
  "kind": "image",
  "model": "flux-pro",
  "settings": [
    {
      "label": "Model",
      "value": "flux-pro"
    }
  ],
  "sufficient_credits": true
}
```

<!-- gen:fields schema=JobCostQuote -->
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | `JobCostKind` | Yes |  |
| `model` | string | Yes | The model id the quote is for, after defaulting — not necessarily the one you sent. |
| `credits` | integer | Yes | Display credits. This is the number the submit will hold, computed by the same pricer the submit path uses, for exactly these settings. It is what to show the customer. |
| `basis` | one of `one_generation`, `set_run` | Yes | Whether `credits` covers one generation or a whole set run. |
| `members` | integer, nullable | No | Number of set members `credits` covers. Present only when `basis` is `set_run`. |
| `duration_seconds` | integer, nullable | No | The billed duration the price is for, on a video quote. |
| `quality` | string, nullable | No | The resolved quality tier the price is for, when the model has one. |
| `settings` | array of `JobCostSetting` | Yes | The settings that made this price, in the order a dialog should show them. Human-readable; do not parse it. |
| `balance_credits` | integer, nullable | No | The wallet balance this quote was compared against, when one could be read. |
| `sufficient_credits` | boolean | Yes | Whether the balance covers `credits` right now. Advisory — the submit re-checks. |
| `confirmation_token` | string | Yes | Short-lived, single-request proof that this price was quoted. Send it back as `confirmation_token` on the matching generate request. Opaque: do not parse or construct it. |
| `expires_at` | string | Yes | After this the token is refused and a submit carrying it fails with `confirmation_rejected`. Re-quote. |
<!-- /gen -->

The submit call returns a job immediately. These captured job responses come from the fixture's lighthouse prompt rather than the browser example's mountain prompt.

```json title="202: queued"
{
  "created_at": "2026-09-21T03:33:14.622149Z",
  "id": "4a9b2242-f732-4a83-ba41-17448f5aa15f",
  "modality": "image",
  "model": "flux-pro",
  "status": "queued",
  "updated_at": "2026-09-21T03:33:14.622149Z",
  "user_id": "dad27b53-a85b-4e3d-8fd6-b152c803a27c"
}
```

The wait returns a terminal job. A successful response contains the finished asset; the signed URLs here are shortened for display.

```json title="200: succeeded"
{
  "asset": {
    "created_at": "2026-09-21T03:33:21.706691Z",
    "display_name": "Lighthouse on a cliff",
    "expires_at": "2026-09-21T05:00:00Z",
    "favorite": false,
    "has_audio": false,
    "id": "f7bc037c-d7d7-434a-8843-26675574de0d",
    "mime_type": "image/png",
    "modality": "image",
    "model": "flux-pro",
    "prompt": "a lighthouse on a cliff, paper-cut style",
    "signed_url": "https://storage.googleapis.com/nolgia-generations-prod/dad27b53-a85b-4e…",
    "size_bytes": 418584,
    "status": "ready",
    "tags": [],
    "thumbnail_url": "https://storage.googleapis.com/nolgia-generations-prod/dad27b53-a85b-4e…",
    "user_id": "dad27b53-a85b-4e3d-8fd6-b152c803a27c"
  },
  "completed_at": "2026-09-21T03:33:21.805937Z",
  "created_at": "2026-09-21T03:33:14.622149Z",
  "id": "4a9b2242-f732-4a83-ba41-17448f5aa15f",
  "modality": "image",
  "model": "flux-pro",
  "status": "succeeded",
  "updated_at": "2026-09-21T03:33:21.805937Z",
  "user_id": "dad27b53-a85b-4e3d-8fd6-b152c803a27c"
}
```

<!-- gen:fields schema=Job only=id,status,model,modality,progress,asset,failure,created_at,completed_at -->
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes |  |
| `modality` | `Modality` | Yes |  |
| `model` | string | Yes |  |
| `status` | `JobStatus` | Yes |  |
| `asset` | `Asset` | No |  |
| `failure` | `JobFailure` | No |  |
| `progress` | number, nullable | No |  |
| `created_at` | string | Yes |  |
| `completed_at` | string, nullable | No |  |
<!-- /gen -->

### Parameters reference

The browser sends the same request fields as a direct client. The proxy removes **/api/nolgia** from its local path and preserves query parameters, including the wait window.

<!-- gen:fields schema=JobCostRequest -->
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | `JobCostKind` | Yes |  |
| `image` | `GenerateImageRequest` | No |  |
| `video` | `GenerateVideoRequest` | No |  |
| `audio` | `GenerateAudioRequest` | No |  |
| `three_d` | `Generate3DRequest` | No |  |
| `set` | `GenerateSetRequest` | No |  |
<!-- /gen -->

<!-- gen:fields schema=GenerateImageRequest only=model,prompt,num_images,confirmation_token -->
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `confirmation_token` | string | No | Optional proof that this exact request and price were shown to the customer, from `POST /jobs/cost`.… |
| `model` | `ImageModel` | Yes |  |
| `prompt` | string | No | What to generate.… |
| `num_images` | integer | No | How many images to render from this one request. Each one is billed, so four images cost four times one. The ceiling is per-model (`image.num_images_max`); most models allow four. |
<!-- /gen -->

<!-- gen:params op=waitForJob -->
| Parameter | In | Required | Description |
| --- | --- | --- | --- |
| `id` | path | Yes | Job UUID. |
| `timeout_seconds` | query | No |  |
<!-- /gen -->

## What to allow

Keep the allow-list limited to the operations your application needs. The two programs above forward these route shapes; they do not proxy the whole API.

| Route | Why the examples forward it |
| --- | --- |
| `POST /jobs/cost` | Show the exact credit quote before generation. |
| `POST /generate/image`, `POST /generate/video`, `POST /generate/audio`, `POST /generate/3d` | Submit one of the supported generation modalities. |
| `GET /jobs/{id}` | Read the accepted job's current state. |
| `GET /jobs/{id}/wait` | Wait for a terminal state, within the requested wait window. |
| `GET /me` | Inspect the upstream account and generation limits during integration. |

| Keep outside the browser proxy | Why |
| --- | --- |
| `/pat` | Token management belongs in a trusted account flow. |
| `/billing/*` | Billing operations should not inherit a shared server PAT through a generic route. |
| `/organizations/*` | Workspace administration requires its own authorization decisions. |

The fixtures forward the content type and server-side bearer token. They do not forward browser-supplied `Authorization`, `Idempotency-Key`, `X-Nolgia-Surface`, or request-id headers; add an explicit server policy if your product needs those headers. They buffer the upstream response, so use the wait endpoint here; these programs are not SSE stream proxies.

## Error responses

An unlisted route receives `404` from the proxy with an empty body. An allowed route preserves the upstream HTTP status and body: `400` / `validation` means the generation arguments need correction, `401` means the server's token is invalid, and `408` from the wait endpoint means the job is still running. See [Errors](./errors.html) for other submit refusals and terminal job failures.

> [!WARNING]
> Download an asset's `signed_url` promptly. It is time-limited; store the asset id and read the asset again when you need a fresh URL.

## Next steps

:::cards
- [Client setup](./client-setup.html): Configure the authenticated client on your server.
- [Platform headers](./headers.html): Understand idempotency, attribution and the wait and stream query parameters.
:::
