← Developer guides

Node.js server integration

Two patterns for a Node.js backend: mint session tokens for a browser frontend, or call the API directly from server-only code.

Node.js on the backend covers two different jobs, both on this page: (1) minting session tokens so a browser frontend (plain webpage or React) can call the API safely, and (2) calling the API directly from server-only code — a cron job, a webhook handler, a CLI, anything with no browser involved. Pick the section that matches what you’re building.

Minting tokens for a browser frontend

This is the pattern from Choose your auth mode made concrete: a token-minting endpoint on your server, and a browser page (see Plain webpage integration or React SPA integration for the browser side) that calls it before calling the SDK.

1. Add a token endpoint to your backend

The recommended way to mint a session JWT is to have your backend call the platform’s own POST /auth/token endpoint, authenticating with the client_id/client_secret pair from Get your API key. Your backend never signs anything itself — it just forwards the request and hands the returned token to the browser.

const CLIENT_ID = process.env.QURAVIN_CLIENT_ID;         // from the Console
const CLIENT_SECRET = process.env.QURAVIN_CLIENT_SECRET; // from the Console, one-time reveal

app.get("/ai-token", requireUserLogin, async (req, res) => {
  const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");

  const r = await fetch("https://api.quravin.com/auth/token", {
    method: "POST",
    headers: {
      Authorization: `Basic ${basic}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      subject: req.user.id,     // your app's user id — becomes the JWT `sub`
      ttl_seconds: 900,         // optional; 60-3600, default 900 (15 min)
      // pipelines: ["translate-string"],  // optional — omit to get your app's full allow-list
    }),
  });
  if (!r.ok) return res.status(502).json({ error: "token_mint_failed" });

  const { token, expires_in } = await r.json();
  res.json({ token, expires_in });
});

POST /auth/token responds with:

{ "token": "eyJhbGciOi...", "token_type": "Bearer", "expires_in": 900 }

The pipelines you request are intersected with your Application’s allowed_pipelines (set in the Console — see Get your API key); omit the field to get whatever your app is allowed to call. requireUserLogin is your own auth middleware — the token only ever goes to a user your server has already authenticated.

Alternate: self-signing a JWT (legacy)

Some apps instead hold their own per-app signing secret (AI_PLATFORM_SIGNING_SECRET) and mint the JWT locally with a library like jsonwebtoken, instead of calling POST /auth/token. This is a real, supported mechanism, but it’s the legacy path — prefer POST /auth/token above unless you have a specific reason to self-sign (e.g. you already run this way and don’t want to change).

import jwt from "jsonwebtoken";

const SECRET = process.env.AI_PLATFORM_SIGNING_SECRET;   // NOT the client_secret — see below
const APP_ID = "your-app-id";                             // your app's registered id

app.get("/ai-token", requireUserLogin, (req, res) => {
  const token = jwt.sign(
    {
      iss: APP_ID,
      sub: req.user.id,                    // your app's user id
      aud: "ai-platform",
      pipelines: ["translate-string"],     // which pipelines this user may call
      rate_limit: { rpm: 30 },
    },
    SECRET,
    { algorithm: "HS256", expiresIn: "15m" },
  );
  res.json({ token, expires_in: 15 * 60 });
});

This uses a different credential than client_id/client_secret. The signing secret isn’t issued by the Console — it’s provisioned out-of-band by the platform operator, over a secure channel, specifically for apps that need to self-sign. If you don’t already have one, use the POST /auth/token path above instead of requesting one.

iss differs between the two minting paths — don’t assume a JWT payload you’re debugging always has the same issuer. A token minted by POST /auth/token always has iss: "ai-platform" (the platform itself signs it). A self-signed token (this section) has iss: <your-app-id> instead — the platform still accepts it, keyed by that app id’s registered signing secret. Both use aud: "ai-platform".

Which secret is which

Three different secrets show up across these auth docs, and their names overlap enough to cause mix-ups. This is the full list:

SecretEnv varWho holds itUsed for
Client secretQURAVIN_CLIENT_SECRET (yours to name)Your backendAuthorization: Basic client_id:client_secret on POST /auth/token — the recommended path above.
Per-app signing secretAI_PLATFORM_SIGNING_SECRETYour backend (only if self-signing)Locally signing JWTs yourself, bypassing POST /auth/token — the legacy path in this section.
Platform JWT signing secret(platform-internal, not exposed)The platform operator only, never youWhat the platform itself uses to sign tokens it mints via POST /auth/token. You never see or configure this.

If you’re only ever calling POST /auth/token (the recommended path), you only need to think about the client secret — the other two are internal/legacy concerns.

2. Your frontend fetches the token and calls the SDK

fetch("/ai-token", { credentials: "include" }) is a same-origin call to your own backend route (step 1) — no CORS involved, just your usual cookie-based session. The SDK’s own call to our API is a separate, cross-origin request the SDK handles itself; you don’t configure CORS for it. The full browser-side code (plain webpage or React) lives in Plain webpage integration and React SPA integration — this endpoint is the piece those two guides assume already exists.

That’s the whole loop: your server mints tokens, the browser never sees a secret, and every call is attributed to the user who made it.

Calling the API directly from server-only code

No browser involved — a cron job, a webhook handler, a CLI, a batch script. Use apiKey mode directly; there’s no token to mint, no per-request round trip to POST /auth/token.

There’s no window for a <script> tag to attach to here, and the SDK’s own import { Quravin } from "@quravin/sdk" ergonomics aren’t on the public npm registry yet (ask your platform operator if you need that path) — so for server-only Node.js code today, call the API directly over HTTP. Node 18+ ships fetch globally, no extra dependency needed:

const API_BASE = process.env.QURAVIN_API_BASE;
const API_KEY = process.env.QURAVIN_API_KEY; // from the Console — see "Get your API key"

async function runPipeline(pipelineId, inputs) {
  const submit = await fetch(`${API_BASE}/tickets`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "x-api-key": API_KEY },
    body: JSON.stringify({ mode: "pipeline", pipeline_id: pipelineId, inputs }),
  });
  const { ticket_id } = await submit.json();

  while (true) {
    await new Promise((r) => setTimeout(r, 1000));
    const poll = await fetch(`${API_BASE}/tickets/${ticket_id}`, {
      headers: { "x-api-key": API_KEY },
    });
    const ticket = await poll.json();
    if (ticket.status === "DONE") return ticket.result;
    if (ticket.status === "FAILED") throw new Error(ticket.error);
  }
}

const out = await runPipeline("translate-string", { text: "Hello", target_language: "de" });
console.log(out.translation); // "Hallo"

See Direct API integration for the full request/response shapes and error tables this loop is built on.

This is the same x-api-key from Get your API key’s “What about a static x-api-key?” section — safe to hold in server-only code (an env var, a secrets manager) since it never ships to a browser. Every call is attributed to the app, not a specific user; if you need per-user attribution even from server-only code, mint a session JWT per user instead (same POST /auth/token call as above, just triggered by your own server logic instead of an incoming browser request — then send it as Authorization: Bearer <jwt> instead of x-api-key).

Handling errors

A quota or rate-limit hit returns a typed error — surface it however fits your context (a user message in a browser flow, a retry/alert in a batch job):

try {
  const out = await runPipeline("translate-string", { text: "Hello", target_language: "de" });
} catch (err) {
  console.error("Translation failed:", err.message);
}

See Tool reference’s Errors section for the full list of status codes and what each one means for the authenticated path.

Going further

The SDK also supports a few patterns not shown above:

ai.runMany(...), ai.button(...), and the troubleshooting table are documented in the SDK’s fuller Integration Guide — it isn’t public, so ask your platform operator for a copy if you need it.