Nostraddeveloper documentationDeveloper consoleYour account

Connect an app to Nostrad

Nostrad gives your app AI models (images, video, speech, music, chat) and a file library. The people who use your app sign in with their Nostrad account, the way they would sign in with Google, and pay from their own Nostrad balance. Your app never handles their payment details and never holds a supplier key.

There are two parts, and an app uses both:

  1. Sign in with Nostrad at https://accounts.nostrad.ai: standard OAuth 2.1 (authorization code with PKCE). It gives your app an access token for one person.
  2. The Nostrad gateway at https://gateway.nostrad.ai: an HTTP API. Your app calls it with that person's access token to list models, run jobs and fetch the results.

1. Checklist: from nothing to a working app

  1. Have a Nostrad account (sign up at accounts.nostrad.ai), and open the developer console.
  2. Create app: give it a name. This is your app on Nostrad; its App ID never changes.
  3. Data access: tick the services your app uses.
  4. Credentials → Create client: one sign-in client per program that signs people in.
    • Web server if your app has its own server. You get a client secret, shown once. Put it in the server's secret store, never in code or a file.
    • Phone, desktop or browser-only if it has no server. No secret; PKCE alone.
    Enter the exact return address (redirect URI) your program uses, for example https://your-app.example/oauth/nostrad/callback.
  5. Set up: copy the client ID and addresses into your program. The For your coding agent box there holds everything below for your app, without the secret.
  6. Build the sign-in (section 3) and the gateway calls (section 4). A complete example is in section 8.
  7. Sign in to your own app with your Nostrad account and run a job. Your app is personal until it goes public: only you can sign in to it.

Nothing else is needed: no request to Nostrad, no key to ask for, no setting outside the console.

2. The developer console

The console is at accounts.nostrad.ai/developer/, with the same login as your Nostrad account. Each app has these pages:

PageWhat it holds
OverviewThe App ID, whether the app is personal or public, and its sign-in clients.
BrandingThe app's name and links (website, developer, source code, privacy policy, terms). People see them on the Allow page.
AudiencePersonal (only you can sign in) or public.
Data accessThe services the app may ask for.
CredentialsSign-in clients and their secrets.
Set upEvery value your program needs, with worked examples.

Client secrets

3. Sign in with Nostrad

Standard OAuth 2.1 authorization code flow. Any OAuth client library works. The discovery document lists everything below:

https://accounts.nostrad.ai/.well-known/oauth-authorization-server
Value
Issuerhttps://accounts.nostrad.ai
Authorization addresshttps://accounts.nostrad.ai/authorize
Token addresshttps://accounts.nostrad.ai/oauth/token (also revokes: POST token=<token>)
Resourcehttps://gateway.nostrad.ai (RFC 8707: the token is for the Nostrad gateway)
PKCERequired for every client, method S256
Access tokenLasts 1 hour
Refresh tokenLasts 30 days

Scopes: the services

Each service is an OAuth scope. Ask for the ones your app uses, separated by spaces. Asking for none means every service the app ticked under Data access.

ScopeWhat the person sees
imageMake images
videoMake videos
speechMake speech and use voices
musicMake music
chatChat and write text

On the Allow page the person can untick services and sets a monthly spending limit for your app (20 US dollars unless they change it). The token response's scope says which services they allowed. They can take access away later on their account page under Apps.

Step 1: send the person to sign in

Make a random code_verifier (43 to 128 characters) and a random state, keep both on your side, then send the browser to:

https://accounts.nostrad.ai/authorize
  ?response_type=code
  &client_id=<your client ID>
  &redirect_uri=<a return address registered on the client, exactly>
  &scope=image%20video
  &state=<state>
  &code_challenge=<BASE64URL(SHA-256(code_verifier))>
  &code_challenge_method=S256
  &resource=https%3A%2F%2Fgateway.nostrad.ai

Step 2: the person comes back

Nostrad sends the browser to your return address with code, state and iss. Check that state is the one you made and that iss is https://accounts.nostrad.ai (RFC 9207). If they chose Don't allow, you get error=access_denied instead.

Step 3: swap the code for tokens

Web server client, with its secret as HTTP Basic credentials (client ID and secret each form-encoded first, RFC 6749 §2.3.1). Sending client_id and client_secret in the form body also works.

curl https://accounts.nostrad.ai/oauth/token \
  -u "<client ID>:$NOSTRAD_CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code=<code> \
  -d redirect_uri=<the same return address> \
  -d code_verifier=<code_verifier>

Phone, desktop or browser-only client: no secret, send client_id in the form instead of -u.

The answer:

{ "access_token": "...", "token_type": "bearer", "expires_in": 3600,
  "refresh_token": "...", "scope": "image video" }

Keep both tokens on your server, tied to that person's session in your app. Never send them to the browser.

Step 4: refresh

curl https://accounts.nostrad.ai/oauth/token \
  -u "<client ID>:$NOSTRAD_CLIENT_SECRET" \
  -d grant_type=refresh_token \
  -d refresh_token=<refresh token>

Use the new refresh token from each answer. If refreshing fails, the person signs in again.

4. The Nostrad gateway

Address https://gateway.nostrad.ai. Every command needs the person's access token:

Authorization: Bearer <access token>

Your app sees only its own jobs and files for the signed-in person. The gateway is for servers: it sends no CORS headers, so a browser page calls your own server, which calls the gateway.

CommandWhat it does
GET /v1/modelsThe models this token may use, with each model's inputs and settings
GET /v1/voices?model=<id>The voices a speech model takes as voice_id
POST /v1/jobsRun a model: { model, inputs, params }. 200 when done at once, 202 while it runs
GET /v1/jobs/<id>One job
GET /v1/jobs?limit=<n>This app's recent jobs for this person (up to 200)
GET /files/<name>A file this app made or uploaded for this person (Range supported)
POST /v1/filesUpload one file of up to 100 MB as the request body
GET /v1/filesThis app's files for this person, newest first (limit, cursor, bin=1)
GET /v1/files/<name>One file's record
DELETE /v1/files/<name>Move a file to the bin
POST /v1/files/<name>/restoreTake it out of the bin
GET /v1/storageThe person's space: limit, used, remaining
GET /v1/changes?cursor=What changed among this app's files since the cursor
POST /v1/uploadsStart an upload in parts, for a file over 100 MB
PUT /v1/uploads/<id>/<n>Part n
GET /v1/uploads/<id>Which parts arrived (to resume after a dropped connection)
POST /v1/uploads/<id>/completeFinish it
DELETE /v1/uploads/<id>Cancel it

Models

curl https://gateway.nostrad.ai/v1/models -H "Authorization: Bearer $TOKEN"
{ "models": [ { "id": "...", "label": "...", "modality": "image",
    "inputs": [ { "key": "prompt", "kind": "text", "required": true } ],
    "params": [ { "key": "aspect_ratio", "kind": "choice", "options": ["1:1", "16:9"], "default": "1:1" } ],
    "available": true } ] }

5. Running a job

curl https://gateway.nostrad.ai/v1/jobs \
  -H "Authorization: Bearer $TOKEN" \
  -H "content-type: application/json" \
  -d '{"model": "<id from /v1/models>", "inputs": {"prompt": "a lighthouse at dawn"}, "params": {}}'

The answer is the job:

{ "job_id": "job_...", "model": "...", "modality": "image", "state": "done",
  "outputs": [ { "url": "/files/job_..._0.png", "kind": "image", "mime": "image/png" } ],
  "error": null, "note": null, "usage": { ... }, "cost_usd": 0.04,
  "created_at": 1759650000, "finished_at": 1759650012 }
stateMeaning
queued, processingStill running. Ask GET /v1/jobs/<job_id> again every few seconds (video can take minutes). note may say where it is.
doneFinished. outputs holds the results; cost_usd is what the person was charged.
failedIt didn't work and nothing was charged. error says why (see Errors).

6. Files and the library

Every Nostrad account has one library across all its apps. Your app sees only the files it made or uploaded for that person.

Upload one file (up to 100 MB)

curl https://gateway.nostrad.ai/v1/files \
  -H "Authorization: Bearer $TOKEN" \
  -H "content-type: image/png" \
  -H "x-file-name: holiday%20photo.png" \
  --data-binary @photo.png

Bigger files, in parts (up to 5 GB)

  1. POST /v1/uploads with { "type": "video/mp4", "size": <bytes>, "name": "clip.mp4" } (optional part_size, a multiple of 4 MiB, default 8 MiB; optional content_hash). The answer gives upload_id, part_size and parts.
  2. PUT /v1/uploads/<upload_id>/<n> for n = 1 to parts, each exactly part_size bytes except the last.
  3. POST /v1/uploads/<upload_id>/complete. The answer (201) is the file's record.
  4. Connection dropped: GET /v1/uploads/<upload_id> lists the missing parts; send only those. An unfinished upload is dropped after 7 days.

Bin, space and keeping in step

7. Errors

A refused request answers JSON { "detail": "..." } (some also carry an error code). A job that fails answers the job, with state: "failed" and its error. Nothing is charged for a failed job.

StatusMeaningWhat your app does
400, 409, 413, 415, 422Something in the request: a missing input, a value out of range, too large, a file type not takenFix the request; detail says what
401No token, or it ran out or was taken awayRefresh the token; if that fails, sign the person in again
402Not enough credit, or the monthly limit for your app is reachedTell the person; they top up, or change your app's limit under Apps on accounts.nostrad.ai
403A service the person didn't allow, access taken away, or the model refused the contentShow detail to the person
404An unknown model, or something that isn't this app'sCheck the id
410A file that has left the libraryDon't ask again
429Too many storage requestsWait retry-after seconds, longer after each refusal
502"Nostrad error": something went wrong on Nostrad's side, not with the request. Nothing was chargedTry again later
503The model is switched off or busy right nowTry again later, or another model

8. Example: a server that signs people in and runs a job

Plain JavaScript using only fetch and Web Crypto, so it runs on Cloudflare Workers, Deno and Node 18 or later. store is your own server-side storage (a key-value store or database); env holds NOSTRAD_CLIENT_ID (public) and NOSTRAD_CLIENT_SECRET (a secret).

const NOSTRAD = {
  issuer: "https://accounts.nostrad.ai",
  authorize: "https://accounts.nostrad.ai/authorize",
  token: "https://accounts.nostrad.ai/oauth/token",
  gateway: "https://gateway.nostrad.ai",
};
const RETURN_TO = "https://your-app.example/oauth/nostrad/callback"; // registered on the client
const b64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)))
  .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const random = (n) => b64url(crypto.getRandomValues(new Uint8Array(n)));

// 1. Sign in: send the browser here.
async function signInAddress(env, store) {
  const verifier = random(32), state = random(16);
  const challenge = b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
  await store.put("nostrad-flow:" + state, verifier, { ttlSeconds: 600 });
  return NOSTRAD.authorize + "?" + new URLSearchParams({
    response_type: "code", client_id: env.NOSTRAD_CLIENT_ID, redirect_uri: RETURN_TO,
    scope: "image video", state, code_challenge: challenge, code_challenge_method: "S256",
    resource: NOSTRAD.gateway,
  });
}

function tokenCall(env, fields) {
  const basic = btoa(encodeURIComponent(env.NOSTRAD_CLIENT_ID) + ":" + encodeURIComponent(env.NOSTRAD_CLIENT_SECRET));
  return fetch(NOSTRAD.token, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded", authorization: "Basic " + basic },
    body: new URLSearchParams(fields),
  }).then(async (r) => { if (!r.ok) throw new Error("token " + r.status); return r.json(); });
}

// 2. The return address: swap the code for tokens; keep them with your own session.
async function callback(env, store, url) {
  const q = url.searchParams;
  if (q.get("error")) throw new Error("not allowed: " + q.get("error"));
  if (q.get("iss") !== NOSTRAD.issuer) throw new Error("wrong issuer");
  const key = "nostrad-flow:" + q.get("state");
  const verifier = await store.get(key);
  if (!verifier) throw new Error("unknown or expired sign-in");
  await store.delete(key);
  const t = await tokenCall(env, { grant_type: "authorization_code", code: q.get("code"),
    redirect_uri: RETURN_TO, code_verifier: verifier });
  return { access: t.access_token, refresh: t.refresh_token, until: Date.now() + t.expires_in * 1000, scope: t.scope };
}

// 3. A gateway call, refreshing the token when it has run out.
async function gateway(env, session, path, init = {}) {
  if (Date.now() > session.until - 60_000) {
    const t = await tokenCall(env, { grant_type: "refresh_token", refresh_token: session.refresh });
    Object.assign(session, { access: t.access_token, refresh: t.refresh_token, until: Date.now() + t.expires_in * 1000 });
    // save the session again here
  }
  return fetch(NOSTRAD.gateway + path, { ...init,
    headers: { ...init.headers, authorization: "Bearer " + session.access } });
}

// 4. Run a job and wait for it.
async function makeImage(env, session, model, prompt) {
  let job = await (await gateway(env, session, "/v1/jobs", { method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ model, inputs: { prompt } }) })).json();
  while (job.state === "queued" || job.state === "processing") {
    await new Promise((r) => setTimeout(r, 3000));
    job = await (await gateway(env, session, "/v1/jobs/" + job.job_id)).json();
  }
  if (job.state === "failed") throw new Error(job.error);
  return gateway(env, session, job.outputs[0].url); // the image bytes
}

On Cloudflare, put the secret with npx wrangler secret put NOSTRAD_CLIENT_SECRET and the client ID as a plain variable in wrangler.jsonc. Elsewhere, use your host's secret store.

9. Not available yet