X3DStudios

Generation status

Start a generation with /api/generate/start, poll GET /api/generate/status, and read the finished model URL back — with the real auth model and expiry rules.

A real generation takes longer than a proxy will hold a request open, so the working pattern is two calls: POST /api/generate/start hands you a jobId, and GET /api/generate/status?jobId= tells you where it got to. When the job finishes, its result field is the complete /api/generate response body, model URL included.

Same auth as generation — not an API key
Both endpoints take a signed-in session cookie or a Chrome extension token (x3d_ext_…) as Authorization: Bearer or x-api-key. x3d_live_ print keys are not accepted and get 401. There is no key you can mint for generation today.

Start a job#

POST/api/generate/startSigned in

Queue a generation and return immediately. Takes the same body as /api/generate.

The raw body and content type are captured and replayed against the generation pipeline in the background, so both the JSON text form and the multipart image form work unchanged. See /docs/api/generate for the fields. An empty body is 400 invalid body.

200
{ "jobId": "job_m2k1r8q_4f7a9c2e", "status": "queued" }
Starting a job does not spend a /api/generate call
start has its own bucket of 20 an hour, and the background run is authenticated internally, so it skips the generation rate limiter entirely. Credits are charged by the background run, not by start — a 200 from start does not mean you had the credits.

Poll it#

GET/api/generate/status?jobId=Signed in

Return the job record, including the full generation result once it is done.

Query
jobIdstringrequired
The id returned by /api/generate/start. Jobs are owned by the account that created them; asking for someone else's is 403.
200 — the job record
{
  "id": "job_m2k1r8q_4f7a9c2e",
  "userEmail": "[email protected]",
  "status": "done",
  "createdAt": 1757404800000,
  "startedAt": 1757404800412,
  "completedAt": 1757404838907,
  "result": { "modelUrl": "gs://…", "printStatus": "ready", "creditsCharged": 1, "…": "…" }
}

The three timestamps are epoch milliseconds. startedAt, completedAt, result and error are only present once they apply.

statusMeaningWhat is set
queuedAccepted, not picked up yet.createdAt only.
runningThe pipeline is working — enhancing, generating, downloading, repairing, measuring.startedAt.
doneFinished. Read result.completedAt, result.
errorThe generation failed, or the instance running it went away. Credits charged by a failed generation are refunded.completedAt, error.

error carries the message the generation route returned — an engine failure, a moderation refusal, a no_credits rejection — or "Generation was interrupted and did not finish." for a job swept after 30 minutes.

A polling loop#

Start, poll every 5s, print the model URL
JOB=$(curl -s https://x3dstudios.com/api/generate/start \
  -H "Authorization: Bearer $X3D_EXT_TOKEN" \
  -H "content-type: application/json" \
  -d '{"prompt":"a low-poly owl figurine","mode":"fast_draft"}' | jq -r .jobId)

while :; do
  BODY=$(curl -s "https://x3dstudios.com/api/generate/status?jobId=$JOB" \
    -H "Authorization: Bearer $X3D_EXT_TOKEN")
  case "$(echo "$BODY" | jq -r .status)" in
    done)  echo "$BODY" | jq -r '.result.modelUrl, .result.printStatus'; break ;;
    error) echo "$BODY" | jq -r .error; exit 1 ;;
  esac
  sleep 5
done

Five seconds is a sensible interval. Status reads are not rate-limited, but nothing changes faster than the pipeline's own two-second engine poll, and a text draft rarely finishes in under twenty seconds.

Getting the model#

result.modelUrl is a gs:// object URI in our storage, not a public link. Fetch it through the proxy, which checks that the signed-in account owns that generation.

Download the repaired mesh
curl -G "https://x3dstudios.com/api/model-proxy" \
  --data-urlencode "url=$MODEL_URL" \
  --data-urlencode "download=true" \
  -b "$COOKIE_JAR" -o model.glb
The proxy is session-only
/api/model-proxy authenticates with the session cookie alone — an x3d_ext_ token is not accepted there. A URL the signed-in account does not own is 403, and 401 with no session; models published to the community gallery are the one exception the proxy serves to anyone. Its format parameter (glb, stl, obj, 3mf) sets the response content type and the download filename; the bytes handed back are the stored GLB, so convert on your side if you need STL.
Field in resultLifetime
modelUrl with persisted: trueA gs:// object we wrote. No expiry. This is the repaired, welded, oriented mesh — the one to print.
modelUrl with persisted: falseStorage failed, so this is the engine's own pre-signed URL. It expires within the hour and the model is then unrecoverable. Download it now.
previewUrlThe engine's original textured mesh, stored alongside. Repair drops UVs, so this is what a viewer should render. Equal to modelUrl when nothing was repaired.

Is anything running?#

GET/api/generate/activeSigned in

The account's most recent queued or running job, if there is one.

Session cookie only — this one has no extension-token support. It never returns 401: signed out gives 200 {"active":false}. Signed in with a live job gives {"active":true,"jobId","status","createdAt"}. Jobs older than 30 minutes are ignored here, so a job orphaned by a lost instance cannot block the account forever.

Errors#

StatuserrorEndpoint
401Authentication requiredstart and status. No session and no valid x3d_ext_ token.
400invalid bodystart, when the request body is empty.
400jobId requiredstatus, when the query parameter is missing.
404not foundstatus, for an unknown job id.
403forbiddenstatus, when the job belongs to another account.
429Generation limit reached. Please try again later.start. 20 an hour per account, with a Retry-After header.
A failed generation is a 200 here
status only reports HTTP errors about the job record itself. A generation that failed comes back as 200 with status: "error" and the message in error. Check the body, not the status code.