> For the complete documentation index, see [llms.txt](https://docs.auray.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.auray.ai/api-reference/servers-and-authentication.md).

# Servers & authentication

The reference beside this page describes every route. This page describes what is true of all of them: where they live, what a key is, what a key may do, and how fast you may ask.

## One server

```
https://api.auray.ai/v1
```

That is the only entry in `servers` in the OpenAPI document. There is no staging host, no regional host and no unversioned alias.

Paths are `/v1/...` with no `/api` in front. The API used to live inside the desktop deployment, where `app.auray.ai/api/v1/me` was the whole story; it moved to its own host, the prefix went with it, and the old address now 404s. A deployment whose every route is a handler does not need to say so in its paths, and the host already says `api`.

The machine-readable contract is at `https://api.auray.ai/v1/openapi.json`. It is a static file served by the API itself, answered before any key check — a spec you need a key to read is a spec nobody can generate a client from.

{% hint style="danger" %}
**The API is switched off on this deployment.** `API_V1_ENABLED` is unset, and it is checked before the `Authorization` header is even read, so every `/v1` route answers `503`:

```json
{
  "error": "api_disabled",
  "message": "The API is not enabled on this deployment.",
  "docs": "https://docs.auray.ai/api-reference/errors#api_disabled",
  "request_id": "37d99de9-d507-4aca-bb5b-b4fa3af97b96"
}
```

A valid key and an invented one look identical here. There is no key that gets past it, and nothing on your side to fix. The variable is unset rather than false on purpose: an unset switch means the surface is off rather than open, and turning it off in an incident is an environment change and a redeploy rather than a code change and a review.
{% endhint %}

## The key

`auray_sk_`, then a twelve-character id, an underscore, a forty-three-character secret and a six-character checksum. Seventy-one characters, every field a fixed width, parsed by offset rather than by splitting on the underscore — the underscore is there for a human reading a support ticket, and a parser that depends on a delimiter depends on a value it cannot check.

The id is public. It appears in our logs, in `GET /v1/me`, and in the Settings list, and it is the only part of a credential that belongs in a log line. The tail is the secret, and no column anywhere holds it: we keep a peppered HMAC-SHA-256 and nothing else. A lost key cannot be recovered by anyone, us included. Mint another.

The prefix is a scanner anchor. GitHub push protection and TruffleHog both want a fixed, distinctive string; a bare forty-three-character blob is indistinguishable from a hash and will never be detected by anything. `sk` rather than `key` reserves `auray_pk_` for a browser-safe credential that does not exist.

Keys are minted in **Settings → API Keys**, signed in, and nowhere else. There is no `keys:*` scope of any kind and no `/v1` route that issues one: a key that can mint keys makes revocation a suggestion, since you would revoke the one you found and the one it made would still be live.

`/v1` also refuses a Supabase session token, though telling the two apart is trivial — `auray_sk_` against a JWT's `eyJ`. Accepting both would mean an XSS-reachable browser token could drive the public API.

A free account may hold two live keys, a paid account twenty-five. Live means neither revoked nor expired; a key that lapsed on its own stops holding a slot.

## Sending it

A bearer token. A bare key with no `Bearer` prefix is accepted too.

{% tabs %}
{% tab title="curl" %}

```bash
curl https://api.auray.ai/v1/me \
  -H "Authorization: Bearer $AURA_API_KEY"
```

{% endtab %}

{% tab title="Node" %}

```javascript
const res = await fetch("https://api.auray.ai/v1/me", {
  headers: { Authorization: `Bearer ${process.env.AURA_API_KEY}` },
});

const me = await res.json();
console.log(me.plan, me.credits.balance, res.headers.get("X-RateLimit-Remaining"));
```

{% endtab %}

{% tab title="Python" %}

```python
import os
import requests

r = requests.get(
    "https://api.auray.ai/v1/me",
    headers={"Authorization": f"Bearer {os.environ['AURA_API_KEY']}"},
    timeout=30,
)
print(r.status_code, r.json()["plan"], r.headers["X-RateLimit-Remaining"])
```

{% endtab %}
{% endtabs %}

```json
{
  "object": "account",
  "id": "fa7dda30-…",
  "plan": "pro",
  "credits": { "balance": 479, "monthly_allowance": 600, "period": "2026-08" },
  "key": {
    "id": "RsLuoyBOjfLN",
    "scopes": ["jobs:read", "assets:read", "photo:write"],
    "credit_ceiling": 600,
    "credits_spent_period": 94
  },
  "request_id": "37d99de9-d507-4aca-bb5b-b4fa3af97b96"
}
```

`GET /v1/me` is the one route that checks no scope at all. It costs no credits, has no plan gate and no wait, so when it answers the only thing it has proved is that the key works — which is exactly what you need to know two minutes after creating one.

## What is checked, and in what order

{% stepper %}
{% step %}

### Is the API on

`API_V1_ENABLED` is read first, before anything else. `503 api_disabled`. This is where every request stops today.
{% endstep %}

{% step %}

### Did it come from a browser

If the request carries an `Origin` header at all, `403 browser_origin_refused` — before the key is read, so the answer is the same whether the key was valid or nonsense.
{% endstep %}

{% step %}

### Is there a credential

No `Authorization` header is `401 missing_authorization`, and the message tells you the shape to send rather than making you look it up.
{% endstep %}

{% step %}

### Is it well formed

Prefix, length, alphabet and checksum, all in process. Four rejections before any I/O, so a flood of malformed keys never reaches the database. `401 invalid_api_key`.
{% endstep %}

{% step %}

### Is this a runaway loop

A fuse in the serving instance, counting per key per bucket per second: 30 reads, 10 writes, 3 generates, 30 egress. Over that is `429` with `Retry-After: 1`.

It is not a rate limit and does not pretend to be one. The platform scales horizontally, so the effective ceiling is instances times these numbers, and the instance count grows precisely when you would want the limit to tighten. It under-counts deliberately; it exists only so a runaway client is refused before it costs a database query. It also collapses to one request a second for thirty seconds after the limiter has failed five times in ten, which turns a Postgres blip into backpressure instead of a retry storm.
{% endstep %}

{% step %}

### Who is this, and may they, and how fast

One Postgres call authenticates the key and moves the token bucket in the same statement. It returns the account, the plan, the scopes, the key's ceiling and the counters — or one of `401 key_revoked`, `401 key_expired`, `401 invalid_api_key`, `403 plan_has_no_api_generate`, `413 request_too_large`, `429 rate_limited`.

Unknown key and wrong secret collapse into the same `invalid_api_key` on purpose: telling them apart would confirm which ids exist.
{% endstep %}

{% step %}

### Does the key carry the scope

The route's own check, and it is last. `403 insufficient_scope`, with `required` naming the one that was missing and `scopes` listing what the key has.
{% endstep %}
{% endstepper %}

That order has one consequence worth knowing: the plan and the pace are decided before the scope is. A free key holding `photo:write` gets `plan_has_no_api_generate`, never `insufficient_scope`.

If the limiter itself cannot be reached you get `503 limiter_unavailable` and `Retry-After: 5`. That is fail-closed by construction rather than by choice — the limiter and the authenticator are the same call, so a failure means we do not know who you are, and an unauthenticated caller cannot be admitted to anything.

## There is no CORS

No `Access-Control-*` header is sent on any response, success or refusal, and the absence is the policy rather than an omission. A secret key in a browser is a leaked key: it is in the bundle, in the Network tab, and readable by every extension the person has installed. CORS is the only thing that makes putting one there convenient.

A request that arrives with an `Origin` header is refused with `403 browser_origin_refused` whatever key it carried, and the message tells you to rotate that key rather than to try again.

{% hint style="warning" %}
It is a smoke detector, not a lock, and you should know that before you rely on it. Browsers omit `Origin` on some same-origin GETs, and any non-browser client can simply not send one. It is worth its six lines because the population it catches — somebody who pasted a key into `fetch()` in a React app — is exactly the population that leaks keys, and it catches them at the moment it happens rather than after the bill.

Some HTTP clients set `Origin` by default outside a browser. If you are seeing this from a backend, remove the header.
{% endhint %}

## Scopes

A key carries only the scopes you named when you made it.

| Scope            | What it unlocks                                                                |
| ---------------- | ------------------------------------------------------------------------------ |
| `jobs:read`      | `GET /v1/jobs`, `GET /v1/jobs/{id}`, `GET /v1/usage`, `GET /v1/webhooks`       |
| `assets:read`    | `GET /v1/jobs/{id}/assets`                                                     |
| `photo:write`    | `POST /v1/photo`, and cancelling a photo job                                   |
| `music:write`    | `POST /v1/music`, and cancelling a music job                                   |
| `video:write`    | `POST /v1/video`, `POST /v1/uploads` with `product: "video"`, and cancelling   |
| `threed:write`   | `POST /v1/threed`, `POST /v1/uploads` with `product: "threed"`, and cancelling |
| `chat:write`     | `POST /v1/chat`, `POST /v1/chat/conversations`                                 |
| `webhooks:write` | `POST /v1/webhooks`, `POST /v1/webhooks/{id}`, `DELETE /v1/webhooks/{id}`      |

A key made without naming any scope gets `jobs:read` and `assets:read` — the two that cannot spend anything.

An unrecognised scope name is refused when you create the key rather than dropped from the list. Silently discarding one would hand back a key that looks like it can do something it cannot, and you would find out at the first `403` instead of at the moment you made it.

{% hint style="info" %}
**Listing webhooks needs `jobs:read`, not `webhooks:write`.** Reading which endpoints exist and how they have been going is a read; `webhooks:write` is for changing them. `GET /v1/usage` is the same — `jobs:read`.

`POST /v1/uploads` is the other one that surprises people: it takes the write scope of the product the upload is for, not a scope of its own.
{% endhint %}

Scopes cannot be added to an existing key. Mint a new one with what you need, and revoke the old one.

## Expiry

Every key expires, and there is no permanent option. The picker offers 30, 90, 180 and 365 days, defaults to 90, and the column itself refuses anything beyond 366 days from creation. A credential that never expires is a liability with nothing on the other side of it, and the account tier that costs nothing to create is the one this API is open to.

An expired key answers `401 key_expired`, and a revoked one `401 key_revoked`. Both are fixed the same way and neither comes back, which is why they are two codes rather than one.

Revocation is a stamp, not a delete. The row survives so that an id is never issued twice and so there is still a record of what the leaked key was allowed to do — the first thing anybody asks after finding one in a public repository. Revoking a key that is already revoked answers `200` with the original timestamp: somebody pressing that twice is worried, and the right answer to "is it dead" asked twice is yes, both times.

## The per-key credit ceiling

The wallet is per account. The key is not. Without a cap on the credential, a leaked Ultimate key is 5,000 credits of somebody else's money and the only limit is how fast a script can spend it.

So every key carries its own monthly budget, fixed when the key is minted at the plan's monthly grant:

| Plan at mint | Ceiling |
| ------------ | ------- |
| free         | 0       |
| pro          | 600     |
| max          | 2000    |
| ultimate     | 5000    |

A ceiling of zero means **no per-key cap**, not "spend nothing". The wallet is still the ceiling, and on a free plan that wallet is empty anyway. That reading was bought with a real failure: the first version read zero literally, so a key minted while the account was free stayed permanently dead after an upgrade, refusing every generate with `key_credit_ceiling` and advice to wait for the month to turn over — advice that could never have worked, because the cap was zero rather than spent.

The counter is a calendar month, reset in the same statement that increments it, so there is no monthly job to forget to run. The key's budget is reserved before the wallet is touched and released if the wallet then refuses: the key counter is cheap and reversible, and a wallet refund marks a debit rather than deleting it. Chat releases the part it did not use, because it reserves a worst case and settles at what the model actually produced.

Over the ceiling is `403 key_credit_ceiling`, carrying `ceiling`, `spent` and `required`. `GET /v1/me` reports the same two numbers as `key.credit_ceiling` and `key.credits_spent_period`.

{% hint style="warning" %}
**A ceiling cannot be changed after minting.** The create route is the only writer of that column, the owner's own grant on the table is read-only, and there is no route that updates it. A key minted on Pro keeps a 600-credit ceiling after the account moves to Max.

Mint a new key on the current plan and revoke the old one. That is the whole fix, and it is why minting is cheap. The refusal used to advise raising the ceiling in Settings, which sent people looking for a control that has never existed; it now names the replacement.
{% endhint %}

A key that should spend nothing is made by withholding the write scopes. That is the control that already exists for saying no; expressing it as a zero budget was making one mechanism do two jobs.

## Rate limits

Four token buckets per key, refilling continuously rather than resetting on a schedule. **Capacity** is the burst you can spend at once from a full bucket; the sustained rate is the refill.

| Bucket     | free             | pro               | max                | ultimate            |
| ---------- | ---------------- | ----------------- | ------------------ | ------------------- |
| `read`     | 20 burst, 60/min | 60 burst, 300/min | 120 burst, 600/min | 200 burst, 1200/min |
| `write`    | 10 burst, 20/min | 20 burst, 60/min  | 40 burst, 120/min  | 60 burst, 240/min   |
| `generate` | none             | 5 burst, 20/min   | 10 burst, 60/min   | 20 burst, 120/min   |
| `egress`   | 200 MB/day       | 5 GB/day          | 20 GB/day          | 50 GB/day           |

These are set against availability rather than against GPU cost, which is the opposite of the instinct. What a free key can do is make unbounded reads, and every read is a Postgres query against the same connection pool the desktop product uses. The failure these numbers prevent is the desktop going down for paying users.

Which bucket a call spends:

* **`read`** — every GET.
* **`write`** — changes that cost no credits: uploads, webhook registration and deletion, cancelling a job, opening a chat conversation.
* **`generate`** — anything that can reach the credit ledger: photo, music, video, 3D, chat.
* **`egress`** — `GET /v1/jobs/{id}/assets`, priced in whole megabytes rather than in requests, because the cost of a download is its size and not its count.

That last route is admitted twice on the same key: once against `read` to look the job up, then against `egress` for the size of what it is about to sign. Megabytes are rounded up with a minimum of one, so four 200 KB thumbnails still cost a megabyte — rounding down would make small files free for ever. The counters you get back are the second admission's, because those are the ones that just moved.

{% hint style="info" %}
**A free plan's `generate` bucket has a capacity of zero, and zero never refills into one.** The refusal is `403 plan_has_no_api_generate` with `plan` and `bucket`, not a `429`, so retrying can never help and the message points at the pricing page instead.

That is honesty rather than punishment. The free plan includes no monthly credits, so the call could only ever have reached the wallet and been refused there; refusing it at the limiter costs one index probe instead of a plan read, a grant, two counts and a credit transaction.
{% endhint %}

Asking for more of a bucket than the bucket can ever hold is `413 request_too_large` rather than a `429` — a 250 MB asset list against free's 200 MB egress capacity is refused now and would be refused after any amount of waiting.

### The headers

Every response carries both families, successes included, so a client can pace itself before it is refused:

```http
RateLimit-Limit: 5
RateLimit-Remaining: 4
RateLimit-Reset: 3
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 4
X-RateLimit-Reset: 1787720029
```

`RateLimit-*` is the IETF draft and is what a future client will read; `X-RateLimit-*` is what client libraries in the wild read today. The conventions differ in a way that is easy to get backwards and expensive to debug: **`RateLimit-Reset` is delta-seconds, `X-RateLimit-Reset` is a unix timestamp.**

`Limit` is your plan's capacity for the bucket that call spent, after the warm-up multiplier below. `Reset` is how long until the bucket is full again — not how long until your next request would be admitted. For that, read `Retry-After` on the `429`, which is computed from what you asked for against what has refilled. It is always whole seconds: `1` from the in-process fuse, the bucket's own arithmetic from the limiter, `5` from `limiter_unavailable`.

Every response also carries `X-Request-Id`, the same value as `request_id` in the body. It is what to quote if you write in.

### A new account runs at a fifth

For the first 24 hours, every limit above is multiplied by 0.2. Capacity is floored at one token for any non-zero limit, so a new account is slow rather than broken; a capacity that is already zero stays zero, because that one is a refusal by design.

It is measured from when the **account** was created, not from when the key was — otherwise minting a fresh key would reset the warm-up, which is one line of script.

Every other limit here is per account, and email addresses are free, so without a second dimension all of them are really per email address. Somebody evaluating the platform reads the docs on day one and ships on day three; somebody farming accounts wants throughput in the first hour. Charging the first day at a fifth of the rate is invisible to the first and expensive to the second.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.auray.ai/api-reference/servers-and-authentication.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
