> 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/documentation/what-we-do-not-do.md).

# What we do not do

{% hint style="warning" %}
**The API is switched off on this deployment.** `API_V1_ENABLED` is unset, so every `/v1` route answers `503 api_disabled`. It is answered before your key is even parsed, which is why a valid key and a typo look identical here, and why there is no key that gets past it. Everything below is what you will meet on the day it is on.
{% endhint %}

## There is no CORS, and that is not an oversight

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, and convenience is what makes people do it.

No `Access-Control-*` header of any kind is sent, on any response, refusal or success. A request that arrives carrying an `Origin` header is refused with `403 browser_origin_refused` and told to rotate the key — and that check runs before the key is read, so a valid key, a revoked key and a malformed one all get the same answer.

The preflight fails by a quieter route rather than by a denial. Measured against Next 16.3: an `OPTIONS` with no handler answers `204` with `Allow: GET, HEAD, OPTIONS` and no `Access-Control-*` header, so a browser fails to find permission rather than being told it has none.

**It is a smoke detector rather than a lock**, and you should know which one you are looking at. Browsers omit `Origin` on some same-origin GETs, and any non-browser client can simply not send it. What it reliably catches is somebody who has just pasted a key into `fetch()` in a React app, at the moment they do it rather than after the bill. If you are seeing this from a backend, your HTTP client is adding `Origin` by default — remove it.

The other half of the same position: v1 does not accept a Supabase JWT. `auray_sk_…` is the only credential this surface reads.

## 3D runs one API job at a time, worldwide

There are four GPU containers for the entire platform — `hunyuan.py:61` sets `max_containers=4` — and every tier ends up on them, the controlled ones included, because their texture stage lands there even when their geometry does not.

Those four are divided three to the browser and one to the API, with no float at all. It is the only product where neither side can borrow from the other, because four containers is the scarcest resource here. One API-originated 3D job at a time, for everybody, everywhere.

That is a slow API rather than a broken one, and it is worth learning from this sentence rather than from a `429 service_busy`. Nothing is charged when admission refuses: the check runs before the credit charge on all four queueing products.

The other three are wider, and not by much. Photo has six containers divided four / one / one, music ten divided eight / one / one, video eight divided six / one / one. Each of those leaves the API side two seats — a reserved one plus the float.

Your plan's own 3D allowance is one, two or three jobs in flight on `pro`, `max` and `ultimate`. The lane is what you will meet first.

{% hint style="info" %}
These caps are defaults rather than the last word. `admit_job` reads `public.platform_capacity` under its own advisory lock and prefers a row there, so an operator can move them without a deploy.
{% endhint %}

## Video cannot be waited on

`?wait=` on `POST /v1/video` is refused outright with `400 wait_unsupported`. Its cheapest clip takes longer than the whole request budget, so there is no honest way to hold the connection open.

Refused rather than ignored, and the difference matters: a caller who asked to wait and got an immediate `202` reads it as a finished job and goes looking for files that do not exist yet. This route accepted `?wait=30` and queued two real clips before the check existed.

Poll `GET /v1/jobs/{id}`, or register a webhook. Chat takes no `?wait=` either, for the opposite reason — a chat turn is streamed by the request that pays for it, so waiting is already what that route does.

## Asking to wait is not a promise of waiting

On photo, music and 3D, `?wait=` is one to 300 seconds and the request stays open. There is a limit on how many such requests can be open at once: twenty across the platform, seven per account.

When there is no seat you are not given an error. You get the ordinary `202` with `poll_after_seconds`, plus `wait_declined: "account_busy"` or `"platform_busy"` so a well-behaved client can back off instead of retrying into the same wall. The job is paid for and running either way; turning a waiting problem into a failure would tell you a generation broke when it is about to succeed.

The seven is the sum of the four products' API lanes, and it includes video's two — which can never be occupied, because video refuses to wait. Five is what you can actually hold.

`wait` bounds our polling loop rather than the response time. The answer can arrive one upstream poll later than you asked, at most twenty seconds. A wait that runs out is a `202` with `timed_out: true`, never a 408 and never a 504: a library that retries on those would resubmit and you would pay twice.

## A free key cannot generate, and retrying never helps

The free plan's `generate` bucket has a capacity of zero, which is not the same as being out of tokens. The refusal is `403 plan_has_no_api_generate`, not a `429`, so a retry loop never gets through — a bucket of zero never refills into one.

It is honesty rather than punishment. Free grants no monthly credits at all, so the call could only ever have reached the wallet and been refused there one step later, after burning a rate-limit token on the way.

A free key is still a real key. Sixty reads a minute, twenty writes, 200 MB of egress a day: enough to list jobs, read them, and fetch the assets of work you created before.

## `upscale_2k` is refused on every plan, Ultimate included

`upscale_2k: true` answers `403 upscale_not_in_plan` on all four plans. This is not an upgrade prompt — there is no plan that currently accepts it.

The 2K pass is not GPU time we own. H3-Regenerate-2K was withheld from the open-weights release, so the pass is a call out to a hosted service, and this deployment has no `minimax-api` secret. The service therefore runs with `H3_ENABLE_2K_UPSCALE=0` and its own request schema refuses the field. Switched off rather than half-working.

The price is intact and unreachable: five credits per second of finished video, per variant, added after the tier multiplier rather than through it. It becomes reachable on the day the secret exists, and the two flags flip together rather than one at a time — that pair being out of step is exactly what this was.

It is also the one field in the video body that is not validated strictly. Every other value is refused rather than coerced; `upscale_2k` is read as a boolean, so anything that is not `true` is taken as false rather than rejected.

## Video over the API is one clip, from text

`POST /v1/video` renders a single clip from a prompt, up to four variants of it in one paid job. Storyboards, revisions, narration, stills and assembly are project-shaped and multi-step, and the composer's own state is their real contract, so they live on the desktop and not on v1. Shipping half of that would be worse than saying this.

The surface knows five products: `photo`, `music`, `video`, `threed`, `chat`. The desktop's Code app runs Modal sandboxes, and nothing here reaches them.

## We never fetch a URL you give us

3D starts from an image, and the only way to hand us one is `POST /v1/uploads`, a `PUT` of the bytes to the signed URL it returns, then a submit naming the path. Fetching a URL you named would be a request made from our network with our credentials, against a host you chose.

The uncomfortable part of that arrangement: the upload URL lives two hours. `createSignedUploadUrl` takes no TTL option and the token it mints carries a fixed expiry — decoding one gives `exp - iat = 7200` — so it is Supabase's number rather than ours, and it is longer than a write capability should live. Our signed reads are fifteen minutes. Two things bound it meanwhile: the URL reaches exactly one path inside your own folder, and overwriting an object nobody has submitted a job for accomplishes nothing.

## Nothing is proxied, and nothing is kept forever

Job responses describe assets and never contain URLs. A URL in a polling response is a live credential in a log, and it would be a dead link by the time a slow consumer read it. Ask for them, fetch them directly, and store what you need: signed read URLs live fifteen minutes, and this API does not stand between you and the bytes.

`GET /v1/jobs` does not poll the upstream services. A list is a list; statuses in it are as fresh as the last settle, and `GET /v1/jobs/{id}` is what reconciles one.

Output is not kept indefinitely. A stored object that has gone answers `410 expired`, and the rendering services keep their own records for a few days only — so an old id reads `upstream_lost`, which is the same answer as an id that never existed.

## Chat turns cannot be cancelled

`DELETE /v1/jobs/{id}` on a chat turn answers `cancel_unsupported`. The turn is streamed by the request that pays for it, so the way to stop it is to close that connection.

## A key cannot mint a key

There is deliberately no scope for managing keys. A key that can create keys makes revocation a suggestion.

Expiry is not optional and there is no permanent option: 30, 90, 180 or 365 days, and the column caps at 366. The secret half of a key is never stored — we keep a one-way hash — so a lost key cannot be recovered by anyone, us included.

## Where the usage figures are less sharp than they look

`GET /v1/usage` splits spend by product and by origin, so you can tell your script from your team's browser. A refund does not carry that distinction: `refund_credits_partial` copies `product` and not `origin`, so every refund is recorded as `app` whatever paid for it.

Origin is therefore meaningful on what was **spent**. The net figures work around it by joining a debit to its settlement through `job_id`, which is why they are computed that way rather than summed.

{% hint style="info" %}
**Stripe is in test mode on this deployment.** Checkout and cancellation are real flows, but only test cards are accepted and nothing is charged. Plan gating is not decorative even so — every route re-derives your plan from the database and enforces it, so a plan bought with a test card behaves exactly like one bought with a real one.
{% endhint %}


---

# 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/documentation/what-we-do-not-do.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.
