Start here
Somewhere to PUT a file for an address
/v1/uploadson https://api.auray.ai/v1Returns a signed URL to upload to and where the file goes afterwards.
Returns a signed URL to upload to and where the file goes afterwards. Bytes never come through this API: the platform's request limit is smaller than the files 3D takes, and two orders of magnitude smaller than the meshes a catalogue model takes.
address names the model the file is for, and everything else follows from it: the bucket, the kinds of file its fields take (an image model takes no audio), the ceiling (max_bytes), and the scope — any scope that may submit to that address. send_as lists every field of the address this file can go in, each saying whether to send the path this call answers (our 3D, auray-ai/hunyuan-3d/image-to-3d, and our video, auray-ai/minimax-h3/text-to-video, neither of which fetches a URL) or the signed url that GET /uploads/{path} answers once the bytes are there (every catalogue model's _url field). An address none of whose fields takes a file is refused with upload_not_accepted.
product is the older spelling and still answers every key it answered, with a Deprecation header and a Link to the guide; send address instead. For the catalogue models' bucket, external, it also takes a kind's scope, for the kinds of file some address of that kind takes — an image with any kind's scope, a mesh with 3d:write — so a key minted today still uploads through a client that can name only the product; a file of a kind outside them is refused 403 insufficient_scope, naming the scopes that would reach it. video is no longer offered — a picture for our video goes through its address, which stores it under a name of its own (api-<idempotency_key>) and reads back only those — and answers only a key that used it: one holding auray-ai-video:write, or video:write beside external:write, never the video kind alone. The upload itself is deleted seven days after it landed.
What one account uploads is held under a daily figure of its plan, counted over the last 24 hours: past it, this answers 429 rate_limited with reason upload_bytes, bucket uploads and a Retry-After for when room comes back, and signs nothing.
#Authentication
Authorization: Bearer auray_sk_… — A scope that may submit to the address the file is for — the kind it makes (3d:write, image:write, …) or the older product name. product is deprecated: threed takes 3d:write or threed:write, external takes external:write or a kind's scope for the kinds of file that kind's addresses take.
#Request
#Headers
| Name | Type | Required | Description |
|---|---|---|---|
Idempotency-Key | string | no | Read when the body names no idempotency_key, and the same thing: it names the object. A header outside the pattern is folded into a key inside it (h- and its SHA-256) rather than refused, so the same header names the same object every time. |
#Body
application/json, required.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
address | string | no | — | The model the file is for, as its address (hitem3d/hi3d/split) or its slug. Send this or product. |
product | string | no | — | Deprecated: name the address instead. Which bucket: threed takes 3d:write or threed:write; external takes external:write for any file, or a kind's scope for the kinds of file some address of that kind takes. One of "threed", "external". |
content_type | string | yes | — | The file's type. An address accepts the kinds its fields take; the refusal lists the types. One of "application/json", "application/octet-stream", "application/pdf", "application/zip", "audio/flac", "audio/mp4", "audio/mpeg", "audio/ogg", "audio/wav", "audio/x-wav", "image/jpeg", "image/png", "image/webp", "model/gltf+json", "model/gltf-binary", "model/obj", "model/stl", "text/plain", "video/mp4", "video/quicktime", "video/webm". |
idempotency_key | string | no | — | Required here, unlike on a submit — in the body or as the Idempotency-Key header, and the body's wins when both are sent; with neither the request is 400 invalid_idempotency_key. It names the object, so a retry lands on the same one instead of failing as already-existing: a resend with the same key answers the same path and read link with a fresh upload_url, and a second PUT replaces the bytes. |
#Example request
curl -X POST https://api.auray.ai/v1/uploads \
-H "Authorization: Bearer $AURAY_API_KEY" \
-H "content-type: application/json" \
-d '{
"address": "auray-ai/hunyuan-3d/image-to-3d",
"content_type": "image/png",
"idempotency_key": "0b6f3c52-8f7e-4c1d-9a3b-5e2f1d7c4a90"
}'import { readFile } from "node:fs/promises";
import { createClient } from "@auray/client";
const auray = createClient(); // reads AURAY_API_KEY
const upload = await auray.storage.upload({
address: "auray-ai/hunyuan-3d/image-to-3d",
contentType: "image/png",
data: await readFile("photo.png"),
idempotencyKey: "0b6f3c52-8f7e-4c1d-9a3b-5e2f1d7c4a90",
});
// send_as names the field that takes the file, and whether it wants the path or the signed url
for (const { field, value } of upload.send_as ?? []) {
console.log(field, value === "read_url" ? upload.url : upload.path);
}from auray_client import Client
auray = Client() # reads AURAY_API_KEY
upload = auray.storage.upload_file("photo.png", address="auray-ai/hunyuan-3d/image-to-3d", idempotency_key="0b6f3c52-8f7e-4c1d-9a3b-5e2f1d7c4a90")
# send_as names the field that takes the file, and whether it wants the path or the signed url
for target in upload["send_as"] or []:
print(target["field"], upload["url"] if target["value"] == "read_url" else upload["path"]){
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "upload_file",
"arguments": {
"address": "auray-ai/hunyuan-3d/image-to-3d",
"content_type": "image/png",
"bytes_base64": "<base64>",
"idempotency_key": "0b6f3c52-8f7e-4c1d-9a3b-5e2f1d7c4a90"
}
}
}const res = await fetch("https://api.auray.ai/v1/uploads", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.AURAY_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
"address": "auray-ai/hunyuan-3d/image-to-3d",
"content_type": "image/png",
"idempotency_key": "0b6f3c52-8f7e-4c1d-9a3b-5e2f1d7c4a90"
}),
});
console.log(await res.json());import os, requests
r = requests.post(
"https://api.auray.ai/v1/uploads",
headers={"Authorization": f"Bearer {os.environ['AURAY_API_KEY']}"},
json={
"address": "auray-ai/hunyuan-3d/image-to-3d",
"content_type": "image/png",
"idempotency_key": "0b6f3c52-8f7e-4c1d-9a3b-5e2f1d7c4a90"
},
)
r.raise_for_status()
print(r.json())package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
)
func main() {
body := []byte(`{
"address": "auray-ai/hunyuan-3d/image-to-3d",
"content_type": "image/png",
"idempotency_key": "0b6f3c52-8f7e-4c1d-9a3b-5e2f1d7c4a90"
}`)
req, err := http.NewRequest("POST", "https://api.auray.ai/v1/uploads", bytes.NewReader(body))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("AURAY_API_KEY"))
req.Header.Set("Content-Type", "application/json")
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/uploads");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("AURAY_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"address" => "auray-ai/hunyuan-3d/image-to-3d",
"content_type" => "image/png",
"idempotency_key" => "0b6f3c52-8f7e-4c1d-9a3b-5e2f1d7c4a90",
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response, PHP_EOL;require "net/http"
require "json"
uri = URI("https://api.auray.ai/v1/uploads")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{ENV.fetch("AURAY_API_KEY")}"
req["Content-Type"] = "application/json"
req.body = {
"address" => "auray-ai/hunyuan-3d/image-to-3d",
"content_type" => "image/png",
"idempotency_key" => "0b6f3c52-8f7e-4c1d-9a3b-5e2f1d7c4a90"
}.to_json
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 {
String body = """
{
"address": "auray-ai/hunyuan-3d/image-to-3d",
"content_type": "image/png",
"idempotency_key": "0b6f3c52-8f7e-4c1d-9a3b-5e2f1d7c4a90"
}""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.auray.ai/v1/uploads"))
.header("Authorization", "Bearer " + System.getenv("AURAY_API_KEY"))
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}using System.Net.Http.Headers;
using System.Text;
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("AURAY_API_KEY"));
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.auray.ai/v1/uploads");
var body = """
{
"address": "auray-ai/hunyuan-3d/image-to-3d",
"content_type": "image/png",
"idempotency_key": "0b6f3c52-8f7e-4c1d-9a3b-5e2f1d7c4a90"
}
""";
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");#Responses
#200 OK
OK
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
object | string | no | — | One of "upload". |
upload_url | string | no | — | PUT the bytes here with Content-Type set to what you declared. Send no Auray credential: the signature in the URL is the authorisation. A URL. |
path | string | no | — | The object's key, <your account id>/<idempotency_key>.<ext> — <your account id>/api-<idempotency_key>.<ext> for our video's address. Where send_as says path, send it as it is. |
bucket | string | no | — | |
address | string | no | — | The address's published path, when the request named one. |
max_bytes | integer | no | — | The bucket's ceiling; a larger PUT is refused by storage. |
expires_at | string | no | — | Two hours. Supabase fixes this and exposes no option. |
read | string | no | — | Where to ask for the read URL once the bytes are there: GET /uploads/{path}?address=… (or ?product=… for the deprecated spelling), absolute. Use this address as given. A URL. |
send_as | object[] | no | — | Every field of the address this file can go in. Where the model would make the person in the file speak or move, copy the voice in it, put them into a clip or keep a clip's own sound as a voice, the entry also carries asks: an upload in that field is taken once your account has accepted, one time, that you have the right to the faces and voices you use, and refused 403 acceptance_required until then, before anything is charged — and, only while using uploads that way is paused, refused, when it is refused 403 instead. A file one of your jobs made is taken there. Null when only the deprecated product named an unknown address. May be null. |
send_as[].field | string | yes | — | The field's name, as GET /models/{address} lists it. |
send_as[].kind | string | yes | — | One of "image", "video", "audio", "file", "mesh". |
send_as[].list | boolean | yes | — | True when the field takes a list, of which this file is one element. |
send_as[].value | string | yes | — | path: send the path above. read_url: send the url that read answers. One of "path", "read_url". |
send_as[].asks | object[] | no | — | Present only on a field where this model would make the person in the file speak or move, copy the voice in it, put them into a clip or keep a clip's own sound as a voice: each way an upload there asks your account's one acceptance that you have the right to the faces and voices you use — taken once accepted, and refused 403 acceptance_required until then, before anything is charged. Accept in the Auray app (Account → Privacy), or send acceptance with the request; an assistant cannot accept for you. A file one of your jobs made asks nothing, and nor does a clip you upload with no sound track where only its sound is asked. |
send_as[].asks[].acceptance | string | yes | — | What is asked: the key to send in the request's acceptance field — real_people, the faces and voices of real people. One of "real_people". |
send_as[].asks[].words_version | string | yes | — | The version of the words in force: the value to send under that key, else acceptance_invalid. |
send_as[].asks[].when_also_sent | string[][] | yes | — | The other fields, named as field names them, whose presence beside this one makes the ask: any one list, every field in it. A list that is empty means whatever else the request sends. |
send_as[].asks[].message | string | yes | — | The same sentence the field's description says on the address's own reference. |
send_as[].refused | object[] | no | — | Present only while using an upload this way is paused, on a field whose entry asks: each way an upload there is refused instead, 403, before anything is charged, whatever your account accepted. A file one of your jobs made is taken there. |
send_as[].refused[].error | string | yes | — | The error the request is answered with while this is paused, 403. One of "likeness_not_made_here", "voice_sample_not_made_here". |
send_as[].refused[].when_also_sent | string[][] | yes | — | The other fields, named as field names them, whose presence beside this one makes the refusal: any one list, every field in it. A list that is empty means whatever else the request sends. |
send_as[].refused[].message | string | yes | — | The same sentence the field's description says on the address's own reference. |
Headers:
| Header | Description |
|---|---|
RateLimit-Limit | Burst capacity. |
RateLimit-Remaining | |
RateLimit-Reset | Seconds until refill. |
X-RateLimit-Limit | |
X-RateLimit-Remaining | |
X-RateLimit-Reset | Unix seconds. The X- spelling is what client libraries read. |
X-Request-Id | |
Deprecation | Present only when the request named product rather than address: the day that spelling was deprecated, as @<unix seconds> (RFC 9745). |
Link | Beside Deprecation: the guide section naming the replacement, rel="deprecation". |
#400 Bad Request
#401 Unauthorized
#403 Forbidden
#413 Payload Too Large
#429 Too Many Requests
#503 Service Unavailable
#Status codes
| Status | Meaning |
|---|---|
200 | OK |
400 | Bad Request |
401 | Unauthorized |
403 | Forbidden |
413 | Payload Too Large |
429 | Too Many Requests |
503 | Service Unavailable |
Every refusal names its code, and every code is explained at Errors.