# 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.

Source: https://x3dstudios.com/docs/api/generation-status  ·  Updated: 2026-09-09

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/start`

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

Auth: session

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**

```json
{ "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=`

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

Auth: session

**Query**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `jobId` | string | yes | 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**

```json
{
  "id": "job_m2k1r8q_4f7a9c2e",
  "userEmail": "you@example.com",
  "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.

| status | Meaning | What is set |
| --- | --- | --- |
| queued | Accepted, not picked up yet. | createdAt only. |
| running | The pipeline is working — enhancing, generating, downloading, repairing, measuring. | startedAt. |
| done | Finished. Read result. | completedAt, result. |
| error | The 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**

```bash
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**

```bash
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 result | Lifetime |
| --- | --- |
| modelUrl with persisted: true | A gs:// object we wrote. No expiry. This is the repaired, welded, oriented mesh — the one to print. |
| modelUrl with persisted: false | Storage 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. |
| previewUrl | The 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/active`

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

Auth: session

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

| Status | error | Endpoint |
| --- | --- | --- |
| 401 | Authentication required | start and status. No session and no valid x3d_ext_ token. |
| 400 | invalid body | start, when the request body is empty. |
| 400 | jobId required | status, when the query parameter is missing. |
| 404 | not found | status, for an unknown job id. |
| 403 | forbidden | status, when the job belongs to another account. |
| 429 | Generation 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.

---

- [Text and image to 3D](https://x3dstudios.com/docs/api/generate) — Every parameter, the credit cost, and the full response body.
- [Webhooks](https://x3dstudios.com/docs/api/webhooks) — What X3D does and does not fire callbacks for.
- [Rate limits](https://x3dstudios.com/docs/api/rate-limits) — The per-account hourly buckets.

## Frequently asked

**Are there webhooks for finished generations?**

No. There is no generation webhook anywhere in X3D. Completion is discovered by polling GET /api/generate/status?jobId=. The only webhooks in the product are Stripe payment webhooks, which have nothing to do with generation.

**Does the model URL expire?**

It depends on persisted. When persisted is true the URL is a gs:// object in our storage and does not expire. When persisted is false, storage failed and you are holding the engine's own pre-signed URL, which expires within the hour — download it immediately.

**How long can a generation take?**

The pipeline polls its engine every two seconds with a 15-minute deadline, plus download, repair and validation afterwards. Most draft text generations finish in well under a minute. A job still queued or running 30 minutes after it was created is swept to error.
