# text2shader API v1

Prompt in; a six-second animation out as a live WebGPU embed, an MP4 and a poster. It loops unless its manifest says loop: false. Base URL https://text2shader.com/v1. JSON in, JSON out. Every error is { "error": { "code", "message", "details"? } }.

HTML: https://text2shader.com/docs. Plain text: https://text2shader.com/docs.md and https://text2shader.com/llms-full.txt. API keys: https://text2shader.com/app/api. Pricing: https://text2shader.com/pricing.

Sections: 1 authenticate, 2 create a render, 3 poll until ready, 4 embed it, 5 list and delete, 6 MCP tools, 7 keys and scopes, 8 limits and errors.

## 1. authenticate

- `GET /v1/me` (scope `account:read`)

Every call carries one secret API key. Create keys in the signed-in app with the scopes and expiry you need; the full secret is shown once and never again. Keep it on your server: never in browser code, an embed, a URL, a query string or source control.

- **header**: Authorization: Bearer <secret>. x-api-key: <secret> also works. Send exactly one credential header and no cookies: two credentials, or a key with cookies, return 400, and an invalid key never falls back to a session.
- **returns**: email and credits, the total you can spend now. Optional fields: free_allowance { allowance, remaining, next_refill_at }, unlimited, and eligibility when the signed-in app needs a confirmation first.
- **credits**: The app and the API share one balance; one credit pays for one render. Free credits refill every seven days from signup and don't carry over. Purchased credits, when available, don't expire. Unlimited accounts aren't charged. Creating a key grants no credits and starts no plan.

```sh
curl --fail-with-body https://text2shader.com/v1/me \
  -H "Authorization: Bearer $T2S_API_KEY"
```

Response `200 OK`: credits is the balance now. What one render cost is cost_credits on its manifest.

```json
{
  "email": "you@example.com",
  "credits": 17,
  "free_allowance": {
    "allowance": 20,
    "remaining": 17,
    "next_refill_at": "2026-10-03T14:12:04.000Z"
  }
}
```

| status | code | when |
|---|---|---|
| 400 | invalid_request | two credentials, or a key sent with cookies |
| 401 | unauthorized | missing, invalid, expired or revoked key |
| 403 | forbidden | the key lacks account:read |

## 2. create a render

- `POST /v1/renders` (scope `renders:create`)

Turns a prompt into a short animation: a live WebGPU embed, an MP4 and a poster. It answers at once with a queued manifest and renders in the background, which can take a few minutes.

- **body**: JSON { "prompt": string, "style_id"?: string }, prompt 1 to 500 characters after trimming. A style ID must belong to your account. No assets, scene JSON or shader source.
- **prompts**: Abstract motion, backgrounds, loaders, simple geometric widgets and simple animated data shapes. Text, logos, supplied assets, characters, photorealism, audio, narration and multiple scenes aren't supported.
- **style**: Send { "prompt": "a red cube", "style_id": "st_0123456789abcdef" } to use a saved style. The prompt decides what is shown and wins on conflicts; the style is visual guidance and cannot change policy or output format. Only renders:create is required. The manifest has style_id (null without a style). Later style edits or deletion do not change an admitted render; regeneration carries that frozen style.
- **output**: Six seconds at 1280x720 and 30 fps. Most renders loop; the ready manifest states the size, duration and loop it delivered.
- **retries**: Idempotency-Key is an optional UUID, one per deliberate create. After a lost response, send the same key, prompt and style ID: within 24 hours that returns 200 and the same render with no second charge, even after a style edit or deletion. Changing the prompt or style ID returns 409.
- **cost**: 202 proves admission only, not a finished render. A credit-funded account pays 1 credit at admission and gets it back if the render fails; Unlimited and builder accounts pay 0. cost_credits on the manifest is the actual charge.

```sh
T2S_REQUEST_ID="$(uuidgen)"
curl --fail-with-body https://text2shader.com/v1/renders \
  -H "Authorization: Bearer $T2S_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $T2S_REQUEST_ID" \
  --data '{"prompt":"a slow aurora over a dark sea"}'
```

Response `202 Accepted`: Keep the id and poll it. A replay of the same key, prompt and style ID returns 200.

```json
{
  "id": "r_8f3k2a0b1c2d3e4f",
  "version": 1,
  "status": "queued",
  "prompt": "a slow aurora over a dark sea",
  "style_id": null,
  "created_at": "2026-09-26T14:12:04.000Z",
  "cost_credits": 1
}
```

| status | code | when |
|---|---|---|
| 400 | invalid_request | invalid prompt, malformed st_ style ID, or unknown fields |
| 400 | invalid_idempotency_key | Idempotency-Key isn't a UUID |
| 402 | insufficient_credits | no credits; /v1/me has next_refill_at |
| 403 | forbidden | the key lacks renders:create, or the app needs an eligibility confirmation |
| 404 | not_found | style missing, deleted or not yours; or a deleted render replay |
| 409 | idempotency_conflict | the key was used with another prompt or style ID |
| 429 | rate_limited | wait Retry-After seconds, same key |
| 503 | internal | paused or at capacity; retry later, same key |

## 3. poll until ready

- `GET /v1/renders/{id}` (scope `renders:read`)

Returns the render's manifest. Read it about every two seconds while status is queued or rendering, and stop at ready or failed: both are final and complete. A failed HTTP request is not a failed render; keep polling the same id.

- **progress**: A pending manifest may carry progress { completed, activity }. completed is an ordered prefix of plan_accepted, candidate_checked, review_completed and media_rendered. activity is planning, creating, checking, reviewing, rendering, publishing, repairing, retrying or unknown. Neither is a percentage or an estimate of time left.
- **ready**: embed, mp4, poster, size, duration_s and loop are all present, and nothing is still being written. style_id is the selected style or null; the style text stays private.
- **failed**: error.code is out_of_scope or render_failed, and error.message is safe to show as is. Credit settlement is complete, and there are no media URLs.

```sh
curl --fail-with-body https://text2shader.com/v1/renders/r_8f3k2a0b1c2d3e4f \
  -H "Authorization: Bearer $T2S_API_KEY"
```

Response `200 OK`: The last manifest you read is the one to keep.

```json
{
  "id": "r_8f3k2a0b1c2d3e4f",
  "version": 1,
  "status": "ready",
  "prompt": "a slow aurora over a dark sea",
  "style_id": null,
  "created_at": "2026-09-26T14:12:04.000Z",
  "cost_credits": 1,
  "embed": "https://cdn.text2shader.com/v1/r_8f3k2a0b1c2d3e4f.js",
  "mp4": "https://cdn.text2shader.com/v1/r_8f3k2a0b1c2d3e4f/1.mp4",
  "poster": "https://cdn.text2shader.com/v1/r_8f3k2a0b1c2d3e4f/1.png",
  "duration_s": 6,
  "loop": true,
  "size": [
    1280,
    720
  ]
}
```

| status | code | when |
|---|---|---|
| 401 | unauthorized | missing, invalid, expired or revoked key |
| 403 | forbidden | the key lacks renders:read |
| 404 | not_found | unknown id, or not yours |

## 4. embed it

- `<script type="module">` (scope `no key`)

Paste the ready manifest's embed URL as a module script where the graphic should appear. The URL is public and needs no key; use it exactly as returned. Copy embed in the app gives the same tag.

- **plays**: Live WebGPU where the browser has it, drawn at the render's size and scaled to the page width. Without WebGPU it plays the MP4. Both repeat when the manifest's loop is true and stop on the last frame when it's false. With reduced motion it shows the poster.
- **static**: PDFs, email and slides can't run scripts: use the mp4 or poster URL from the same manifest.
- **scene**: An embed holds a version 1 JSON scene descriptor and portable WGSL; the runtime owns all JavaScript. Time is the frame number divided by the frame rate, and named parameters have explicit numeric types. Descriptor settings aren't API controls.
- **deletion**: Deleting the render removes its embed, video and poster. Copies already loaded or downloaded may remain.

```html
<script type="module" src="https://cdn.text2shader.com/v1/r_8f3k2a0b1c2d3e4f.js"></script>
```

Response `scene descriptor`: What the embed runs, with portable WGSL. Read sizes from the ready manifest.

```json
{
  "format": 1,
  "duration_s": 6,
  "fps": 30,
  "loop": true,
  "size": [
    1280,
    720
  ],
  "seed": 0,
  "params": {}
}
```

## 5. list and delete

- `GET /v1/renders` (scope `renders:read`)
- `DELETE /v1/renders/{id}` (scope `renders:delete`)
- `GET /v1/styles` (scope `styles:read`)
- `POST /v1/styles` (scope `styles:write`)
- `GET /v1/styles/{id}` (scope `styles:read`)
- `PATCH /v1/styles/{id}` (scope `styles:write`)
- `DELETE /v1/styles/{id}` (scope `styles:write`)

List and delete renders, or save visual styles for later renders. Render lists are newest first, each a full manifest. Deleting a render removes its hosted files.

- **query**: limit 1 to 50, default 20. Pass next_cursor unchanged as cursor for the next page; it's absent on the last page.
- **delete**: 204 means cleanup finished. 503 with Retry-After means it's still running: repeat the same DELETE. A 404 from GET alone doesn't prove cleanup or a refund.
- **styles**: POST { "name": string, "prompt": string } returns 201. Name is 1 to 64 characters and prompt 1 to 500 after trimming. Up to 50 active styles per account; the next POST returns 400 invalid_request. GET /v1/styles returns { styles: [...] }, newest first; GET one returns the style.
- **style shape**: { "id": string, "name": string, "prompt": string, "created_at": string, "updated_at": string }. PATCH with name, prompt or both returns 200 and the updated style; DELETE returns 204 and frees a slot. Timestamps are UTC.

```sh
curl --fail-with-body "https://text2shader.com/v1/renders?limit=20" \
  -H "Authorization: Bearer $T2S_API_KEY"

curl --fail-with-body -X DELETE https://text2shader.com/v1/renders/r_8f3k2a0b1c2d3e4f \
  -H "Authorization: Bearer $T2S_API_KEY"

curl --fail-with-body https://text2shader.com/v1/styles \
  -H "Authorization: Bearer $T2S_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"name":"Mono","prompt":"brutalist monochrome, hard shadows"}'
```

Response `200 OK ; 204 No Content`: A failed render lists with its error and cost_credits 0.

```json
{
  "renders": [
    {
      "id": "r_0123456789abcdef",
      "version": 1,
      "status": "failed",
      "prompt": "a company logo spinning",
      "created_at": "2026-09-26T13:40:00.000Z",
      "cost_credits": 0,
      "error": {
        "code": "out_of_scope",
        "message": "That request is outside the kinds of scenes this app can create. Your credit is back."
      }
    }
  ],
  "next_cursor": "OPAQUE_CURSOR"
}
```

| status | code | when |
|---|---|---|
| 400 | invalid_request | bad limit or cursor, invalid style body, or 50 active styles |
| 401 | unauthorized | missing, invalid, expired or revoked key |
| 403 | forbidden | the key lacks the scope |
| 404 | not_found | render or style missing, deleted or not yours |
| 503 | internal | cleanup pending; retry after Retry-After |

## 6. MCP tools

- `POST /mcp` (scope `per-tool scopes`)

Streamable HTTP on this Worker, using one scoped API key per request. Cookies and JSON-RPC batches are refused. MCP clients may use the current protocol or ordinary legacy stateless calls; OAuth is not available yet.

- **create_render**: Requires renders:create. Send prompt and optional owned style_id; an explicit render prompt overrides the style's visual guidance. The queued manifest is followed with wait_for_render. Reuse the same UUID idempotency_key, prompt and style_id (including omission) for uncertain retries within 24 hours.
- **list_styles**: Requires styles:read and returns your saved styles with ID, name and guidance prompt (plus timestamps). A key with only renders:create may use an owned style_id but cannot list styles. Style creation, editing and deletion are REST-only.
- **other tools**: get_render, wait_for_render and list_renders require renders:read; delete_render requires renders:delete; get_account requires account:read.
- **errors**: Tool errors set isError and include the REST error code and message in structuredContent. Unknown or deleted styles are not_found; malformed style IDs are invalid_request. Retry-After seconds appear as error.retry_after_s.

```sh
curl --fail-with-body https://text2shader.com/mcp \
  -H "Authorization: Bearer $T2S_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  --data '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_styles","arguments":{}}}'
```

Response `200 OK`: A tool result has text and structuredContent; legacy clients may receive it as an SSE message.

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\"styles\":[{\"id\":\"st_0123456789abcdef\",\"name\":\"Mono\",\"prompt\":\"black shadows\",\"created_at\":\"2026-09-26T14:12:04.000Z\",\"updated_at\":\"2026-09-26T14:12:04.000Z\"}]}"
      }
    ],
    "structuredContent": {
      "styles": [
        {
          "id": "st_0123456789abcdef",
          "name": "Mono",
          "prompt": "black shadows",
          "created_at": "2026-09-26T14:12:04.000Z",
          "updated_at": "2026-09-26T14:12:04.000Z"
        }
      ]
    }
  }
}
```

| status | code | when |
|---|---|---|
| 400 | invalid_request | malformed style ID on create_render |
| 401 | unauthorized | missing or invalid API key |
| 403 | forbidden | key lacks the tool's scope |
| 404 | not_found | style is unknown, deleted or not yours |
| 409 | idempotency_conflict | key reused with another prompt or style ID |

## 7. keys and scopes

- `app -> api keys` (scope `browser session`)

Keys are created, listed and revoked in the signed-in app, never through the API. Scopes are independent; grant only what the caller needs. Creating and polling renders needs renders:create and renders:read, which the key form starts with. New keys do not preselect style scopes, and existing keys gain none.

- **renders:read**: GET /v1/renders, GET /v1/renders/{id}
- **renders:create**: POST /v1/renders
- **renders:delete**: DELETE /v1/renders/{id}
- **account:read**: GET /v1/me
- **styles:read**: GET /v1/styles, GET /v1/styles/{id}
- **styles:write**: POST /v1/styles, PATCH and DELETE /v1/styles/{id}
- **app only**: Managing keys, reading request logs, confirming eligibility, recording plan interest and payments need the signed-in app.
- **limits**: Names 1 to 64 characters. Expiry within a year, or none. 20 active keys and 100 retained key records per account; inactive keys are removed 30 days after they expire or are revoked.
- **rotate**: Create a replacement, deploy it, confirm a successful call, then revoke the old key. Revocation stops later requests; ones already authorized can finish. A lost key can't be shown again: revoke it and make another.

```sh
curl --fail-with-body https://text2shader.com/v1/me \
  -H "x-api-key: $T2S_API_KEY"
```

Response `403 Forbidden`: A key without the scope is refused, and the refusal counts toward rate limits.

```json
{
  "error": {
    "code": "forbidden",
    "message": "This API key does not have permission for this operation."
  }
}
```

## 8. limits and errors

- `every endpoint`

Every error uses one envelope. Branch on error.code; error.message is safe to show. A render that fails arrives as a 200 manifest with status failed, not as an HTTP error.

- **rate**: 60 requests per key and 120 across an account's keys, per UTC minute. Scope refusals count; invalid credentials don't. A 429 carries Retry-After and error.details.retry_after_s. Fixed windows allow a burst at a minute boundary.
- **retry**: Honor Retry-After on 429 and 503; a service pause may have no estimate. Retry a create with the same Idempotency-Key, and don't start a new render because polling failed.
- **request id**: Every /v1 response has an X-Request-Id. The app's request logs keep the newest 1,000 requests within 30 days, at most 150 a minute: method, route, status, duration and key, never prompts, bodies or secrets. They're best-effort, not an audit trail, and a logged 202 is admission, not a finished render.

Response `429 Too Many Requests`: Wait the given seconds, then send the same request again.

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 23

{
  "error": {
    "code": "rate_limited",
    "message": "API request limit reached. Try again soon.",
    "details": {
      "retry_after_s": 23
    }
  }
}
```

| status | code | when |
|---|---|---|
| 400 | invalid_request | bad body, query or credentials |
| 400 | invalid_idempotency_key | Idempotency-Key isn't a UUID |
| 401 | unauthorized | missing, invalid, expired or revoked key |
| 402 | insufficient_credits | not enough credits to create |
| 403 | forbidden | missing scope, eligibility or closed account |
| 404 | not_found | the render or style isn't available to this account |
| 409 | idempotency_conflict | a key reused with another prompt or style ID |
| 429 | rate_limited | over a rate limit; see Retry-After |
| 500/503 | internal | server trouble, pause or pending cleanup |
