Working with the API
Authentication
Keys, scopes, spend ceilings, and why this API refuses to talk to a browser.
Every request carries a key as a bearer token.
curl https://api.auray.ai/v1/me \
-H "Authorization: Bearer auray_sk_…"A key is 71 characters, starts with auray_sk_, and is shown once, when it is
minted. There is no way to read it back. A malformed one is refused as invalid_api_key with a
sentence that says so:
{
"error": "invalid_api_key",
"error_type": "AuthenticationError",
"message": "That is not a well-formed Auray API key. Check it was copied whole.",
"retry": "never",
"charged": "no",
"docs": "https://auray.ai/docs/api/errors#invalid_api_key",
"request_id": "a4696db0-4bc8-4bff-9cfd-071f86bbaf5b"
}#Scopes
There are 19 of them, and a key reaches only what it was minted for. A write
scope names what a key may make, wherever it is made: image:write reaches every image model,
at whichever address.
Every write scope starts ticked, so a new key works straight away; unticking one costs nothing and
limits the blast radius if the key leaks. chat:read starts unticked: it reads what a person typed. So
does community:write: it speaks in public under the account's profile. And so do studio:read and
studio:write: a Cinema Studio project is private, and they open every file it keeps.
| Scope | Lets the key |
|---|---|
jobs:read | Read jobs, list them, poll a status, and list webhook endpoints |
assets:read | Fetch the signed asset URLs a finished job produced |
image:write | Submit to every address that makes an image, and upload the files those addresses take, reading back through such an address the account's files of the kinds it takes |
video:write | Submit to every address that makes video, and upload the files those addresses take, reading back through such an address the account's files of the kinds it takes. It opens the withdrawn product: "video" upload only beside external:write, as an older key holds it |
audio:write | Submit to every address that makes audio — music, speech, sound — and upload their files, reading back through such an address the account's files of the kinds it takes |
3d:write | Submit to every address that makes a 3D model, and upload the files those addresses take, reading back through such an address the account's files of the kinds it takes, or our 3D's photographs with none |
text:write | Use the chat endpoints and every address that answers in text, and upload the files those addresses take — images, video, audio and documents — reading back through such an address the account's files of the kinds it takes. It does not read a chat thread back |
embeddings:write | Use the embeddings endpoint |
webhooks:write | Create and delete webhook endpoints |
chat:read | Read back the account's chat threads and what was typed in them, the Messages app's included — the one scope that does. Off unless you tick it |
community:write | Act in the Community as the account: publish a result and unpublish it, like, follow, comment, collect, report, and claim and edit the public profile; and read back what it published, liked and collected. Spends nothing. Off unless you tick it. Reading the Community needs no key |
studio:read | Read the account's Cinema Studio — its projects, folders, items, Trash, briefs, members, elements and saved presets — each file as a link signed for 15 minutes. Spends nothing. Off unless you tick it |
studio:write | Change the account's Cinema Studio: start, change and delete projects and folders, file results and uploads, move, copy and trash items, save briefs, make elements and presets. It reads too, since every write answers what it changed. Spends nothing — a generation pays through its own model's scope. Off unless you tick it |
What an address makes is its category's output half — image-to-3d makes 3D, text-to-speech makes
audio, llm and vision answer in text — and GET /v1/models/{address} prints the scope each
address asks for.
What an address takes is another matter, and often not the kind it makes: a language model takes
images, video, audio and documents, an audio model can take a photograph, a 3D model a mesh. A write
scope uploads files of every kind some address of its kind takes, and reads the account's files of
those kinds back through such an address or an upload's read link — not only files of its own kind,
so text:write alone reads back the account's uploaded photographs. Only a file of a kind no such
address takes stays out of reach: a mesh, for a key without 3d:write.
video:write was once the name of our own video address alone. A key minted with that meaning and
without external:write now shows auray-ai-video:write instead, which reaches that one address, so
the key did not quietly gain the catalogue's video models when video:write became the video kind. A key
that held video:write beside external:write keeps it: it already reached every catalogue model. Either
way the key keeps the withdrawn product: "video" upload and its ?product=video read; a key minted
with the kinds does not get them, because nothing it can submit reads that bucket.
#Older names
A key minted before Account offered the kinds above carries the names scopes had then, and they keep working for as long as the key does: each still reaches exactly what it reached, no more and no less. Account no longer offers them.
| Scope | Lets the key |
|---|---|
photo:write | Submit to our image address, auray-ai/krea-2-turbo/text-to-image |
music:write | Submit to our music address |
auray-ai-video:write | Submit to our video address, auray-ai/minimax-h3/text-to-video, and upload to and read back from the withdrawn product: "video" bucket — video:write as an older key meant it |
threed:write | Submit to our 3D address, and upload the photograph it starts from, and read it back |
chat:write | Use the chat endpoints: submit turns and start threads. Reading a thread back takes chat:read |
external:write | Submit to every address outside the auray-ai/ namespace, whatever it makes, and upload inputs for them and read them back |
Reading jobs and assets is always included and cannot be turned off: a key that cannot read a job cannot collect what it just paid for. Everything else is yours to leave on or take off, and a key with none of it is read-only — the pane says so on the key's own row.
Scopes are fixed at mint time
They cannot be added to a key that already exists. Mint a new key with the scope you need.
One asymmetry worth knowing: listing webhook endpoints needs jobs:read, not
webhooks:write. Reading is reading.
A request that reaches a route its scope does not cover is refused with insufficient_scope
before anything is charged. The body tells you both halves — accepted is every scope that would
have done, required the one to mint a new key with:
{
"error": "insufficient_scope",
"error_type": "PermissionError",
"retry": "never",
"charged": "no",
"required": "3d:write",
"accepted": ["3d:write", "threed:write"],
"scopes": ["jobs:read", "assets:read", "photo:write"],
"docs": "https://auray.ai/docs/api/errors#insufficient_scope",
"request_id": "54b4754a-3e17-4629-adb1-7d3f067e97d3"
}Two required values are not scopes to mint. One is external:write, the older name Account no
longer offers. It comes from a door that names no model — a read of an upload that names neither
address nor product, a cancel of a catalogue job by a key holding no kind — and the way out is
naming the model: the address on the read, as the message says, or a key with the kind the job's
model makes. The deprecated product: "external" upload and its ?product=external read know the
file's kind, so they name a kind first — 3d:write for a mesh. The other is auray-ai-video:write,
from the withdrawn product: "video" upload and its read, which answer only the keys that used them —
that name, or video:write beside external:write, as the message says. Account offers neither, and
no address a key can submit to reads that bucket: a file for a video model goes up through its
address.
#Expiry
Every key expires. You choose 30, 90, 180 or 365 days at mint time; there is no "never".
An expired key returns key_expired, a revoked one returns key_revoked, and neither comes back.
Account prints each key's date on its row and marks it once fewer than
14 days are left; a key that has expired stays on the list, marked
expired and holding no slot, for 30 days, so the key a script
stopped on is the one you can see. Set a reminder before the date rather than after.
#Ceilings
A key can carry a cap on what it may spend in one calendar month, counted in UTC. Once reached, that key stops generating until the next month begins, and the rest of the account is untouched.
A key either follows your plan or holds a fixed figure:
- Leave the cap blank when you mint it and the key follows the plan: its cap is your plan's monthly credits in force, a promotion's lift included, read again every time the key is charged. Upgrade, downgrade or let a promotion end and the cap moves with it; nothing is minted again.
- Type a figure and the key holds that figure until you change it. It cannot be more than the plan's
monthly credits while the plan grants some; a figure above it is refused with
ceiling_too_high, which names the most you can set inceiling. While the plan grants none, a figure may be up to 1,000,000.
Either can be changed later: Cap on the key's row in Account → API Keys switches it to the plan
or to another figure, looser as well as tighter, within the same bound. When our support team sets a key's
cap for you, it is a fixed figure that binds it: you may lower it and raise it back as far as that
figure, but switching the key to the plan, or to a figure above it, is refused with ceiling_held,
which names the figure in ceiling. A plan that grants no monthly credits gives a following key no
monthly cap at all — its spend is bounded by the balance alone.
A key made before this choice existed follows the plan only if, on the day it arrived, its cap was the plan's monthly credits in force, a promotion's lift included; every other one keeps the cap it held that day — or none, if it held none — until you change it.
A fixed figure is worth setting on any key that goes somewhere you do not fully control — a contractor's laptop, a CI runner, a demo.
#This API refuses browsers
If your request carries an Origin header it is refused with browser_origin_refused, and no
Access-Control-* header is ever returned.
That is deliberate rather than missing. A key in front-end code is a key that has been published — anyone who opens the network tab has it, and it cannot be scoped narrowly enough to make that safe. Call from your server. The desktop itself does exactly that: its Generate button goes through a server route that holds a short-lived session key, never the browser.
Some HTTP clients set Origin by default even outside a browser. If you are seeing this from a
backend, remove the header.
#Rotating
Mint the new key, deploy it, then revoke the old one. Both work at once, so there is no window where neither does.
#What a key can tell you about itself
curl https://api.auray.ai/v1/me \
-H "Authorization: Bearer $AURAY_API_KEY"import { createClient } from "@auray/client";
const auray = createClient(); // reads AURAY_API_KEY
console.log(await auray.platform.me());from auray_client import Client
auray = Client() # reads AURAY_API_KEY
print(auray.platform.me()){
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_account",
"arguments": {}
}
}const res = await fetch("https://api.auray.ai/v1/me", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.AURAY_API_KEY}`,
},
});
console.log(res.status, await res.json());import os, requests
r = requests.get(
"https://api.auray.ai/v1/me",
headers={"Authorization": f"Bearer {os.environ['AURAY_API_KEY']}"},
)
r.raise_for_status()
print(r.json())package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.auray.ai/v1/me", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("AURAY_API_KEY"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}<?php
$ch = curl_init("https://api.auray.ai/v1/me");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("AURAY_API_KEY"),
],
]);
$response = curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response, PHP_EOL;require "net/http"
uri = URI("https://api.auray.ai/v1/me")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{ENV.fetch("AURAY_API_KEY")}"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, res.bodyimport java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class Main {
public static void main(String[] args) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.auray.ai/v1/me"))
.header("Authorization", "Bearer " + System.getenv("AURAY_API_KEY"))
.GET()
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}using System.Net.Http.Headers;
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("AURAY_API_KEY"));
using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.auray.ai/v1/me");
using var response = await client.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");Comes back with the balance, the key's scopes, its ceiling and what it has spent this period — enough to check a deployment is using the key you think it is. Credits shows the body.
Every response, success or refusal, carries request_id in the body and X-Request-Id in the
headers. Quote it if you ask us about a specific request.