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:
- 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. - 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
- Have a Nostrad account (sign up at accounts.nostrad.ai), and open the developer console.
- Create app: give it a name. This is your app on Nostrad; its App ID never changes.
- Data access: tick the services your app uses.
- 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.
https://your-app.example/oauth/nostrad/callback. - 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.
- Build the sign-in (section 3) and the gateway calls (section 4). A complete example is in section 8.
- 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:
| Page | What it holds |
|---|---|
| Overview | The App ID, whether the app is personal or public, and its sign-in clients. |
| Branding | The app's name and links (website, developer, source code, privacy policy, terms). People see them on the Allow page. |
| Audience | Personal (only you can sign in) or public. |
| Data access | The services the app may ask for. |
| Credentials | Sign-in clients and their secrets. |
| Set up | Every value your program needs, with worked examples. |
Client secrets
- A Web server client can have up to two secrets at once. Add secret makes a new one while the old one keeps working; move your server to the new one, then Disable the old one (Enable brings it back) and Delete it.
- A secret is shown once. Afterwards the console shows its last four characters and when it was last used.
- Lost or leaked secret: Add secret. The client ID stays the same, so nothing people installed stops working.
- A deleted client can be restored for 30 days. Its tokens stop working while it is deleted.
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 | |
|---|---|
| Issuer | https://accounts.nostrad.ai |
| Authorization address | https://accounts.nostrad.ai/authorize |
| Token address | https://accounts.nostrad.ai/oauth/token (also revokes: POST token=<token>) |
| Resource | https://gateway.nostrad.ai (RFC 8707: the token is for the Nostrad gateway) |
| PKCE | Required for every client, method S256 |
| Access token | Lasts 1 hour |
| Refresh token | Lasts 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.
| Scope | What the person sees |
|---|---|
image | Make images |
video | Make videos |
speech | Make speech and use voices |
music | Make music |
chat | Chat 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.
| Command | What it does |
|---|---|
GET /v1/models | The 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/jobs | Run 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/files | Upload one file of up to 100 MB as the request body |
GET /v1/files | This 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>/restore | Take it out of the bin |
GET /v1/storage | The person's space: limit, used, remaining |
GET /v1/changes?cursor= | What changed among this app's files since the cursor |
POST /v1/uploads | Start 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>/complete | Finish 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 } ] }
- Use a model's
idexactly as listed. Don't hard-code a list: read it, since models are added. available: falsemeans the model can't run right now.- Each field has a
keyand akind:text,choice(one ofoptions),numberorinteger(betweenminandmax),flag(true or false),media(one file),media-list(several files) ormessages(a chat history). A field with adefaultcan be left out; arequiredone can't. Keys a model doesn't list are ignored. - A media value is a
data:URL (base64) or anhttps://address, up to 25 MB each. A whole job request is at most 40 MB. - A speech model with a
voice_idsetting lists its voices atGET /v1/voices?model=<id>:{ "voices": [ { "id", "label", "type", ... } ] }. Send a voice'sidasvoice_id. Whenvoice_idis achoice, itsoptionsare the voices. - Chat models take
prompt, ormessagesas[{ "role": "user", "content": "..." }](rolessystem,user,assistant), and an optionalsystem.
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 }
| state | Meaning |
|---|---|
queued, processing | Still running. Ask GET /v1/jobs/<job_id> again every few seconds (video can take minutes). note may say where it is. |
done | Finished. outputs holds the results; cost_usd is what the person was charged. |
failed | It didn't work and nothing was charged. error says why (see Errors). |
- A file output's
urlis a path on the gateway: fetchhttps://gateway.nostrad.ai/files/<name>with the same token, from your server. To show it in a page, serve it from your server or copy it to your own storage. - A chat output is
{ "kind": "text", "mime": "text/plain", "text": "..." }. - If the person has library space, results are kept there. If not, a result is kept for 12 hours after your app first downloads it (7 days if it never does), so download it promptly.
- The person pays for each job from their Nostrad balance, within the monthly limit they set for your app. Before a job runs, the most it can cost is held; the job is charged what it used, never more.
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
content-lengthis required.x-file-nameis optional and percent-encoded.- Optional
x-content-hash: the file's Dropbox-style content hash (SHA-256 of each 4 MiB block, then SHA-256 of those digests joined, as 64 hex characters). If it doesn't match what arrived, nothing is kept (400content_hash_mismatch). - The answer (201) is the file's record:
name,url,title,mime,size,source,job_id,kept,content_hash,created_at,binned_at,leaves_at. - With no library space an upload is kept for 12 hours, as an input for a job.
leaves_atsays when a file leaves;nullmeans it stays.
Bigger files, in parts (up to 5 GB)
POST /v1/uploadswith{ "type": "video/mp4", "size": <bytes>, "name": "clip.mp4" }(optionalpart_size, a multiple of 4 MiB, default 8 MiB; optionalcontent_hash). The answer givesupload_id,part_sizeandparts.PUT /v1/uploads/<upload_id>/<n>for n = 1 toparts, each exactlypart_sizebytes except the last.POST /v1/uploads/<upload_id>/complete. The answer (201) is the file's record.- 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
DELETE /v1/files/<name>moves a file to the bin for 30 days;POST /v1/files/<name>/restorebrings it back. Apps can't delete a file for good; the bin empties itself after 30 days.GET /v1/storage:limit,usage,remaining,state(normal,nearing,critical,exceeded),usage_in_bin,app_usage. Files in the bin count.GET /v1/changeswith no cursor gives the current cursor. Later,GET /v1/changes?cursor=<cursor>gives what changed since:changes(each{ name, what, at, removed, file }, wherefileis the file's record, ornullwithremoved: trueonce it has left), a newcursor, andmore(ask again with the new cursor while it is true). A cursor older than 30 days answers 410: list the files again from the start.- The storage commands allow 600 requests a minute per person per app; over that they answer 429 with
retry-after.
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.
| Status | Meaning | What your app does |
|---|---|---|
| 400, 409, 413, 415, 422 | Something in the request: a missing input, a value out of range, too large, a file type not taken | Fix the request; detail says what |
| 401 | No token, or it ran out or was taken away | Refresh the token; if that fails, sign the person in again |
| 402 | Not enough credit, or the monthly limit for your app is reached | Tell the person; they top up, or change your app's limit under Apps on accounts.nostrad.ai |
| 403 | A service the person didn't allow, access taken away, or the model refused the content | Show detail to the person |
| 404 | An unknown model, or something that isn't this app's | Check the id |
| 410 | A file that has left the library | Don't ask again |
| 429 | Too many storage requests | Wait retry-after seconds, longer after each refusal |
| 502 | "Nostrad error": something went wrong on Nostrad's side, not with the request. Nothing was charged | Try again later |
| 503 | The model is switched off or busy right now | Try 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
- Public apps. Every app is personal for now: only its creator can sign in. Going public will need the website, privacy policy and terms under Branding, and a check by Nostrad.
- API keys. Keys that call the gateway and spend the key owner's own credit, for scripts and services. Until then, sign in to your own app with your own account.
- Sign-in on devices without a browser (a code typed on another device).