> **Reading as an AI agent?** This is the Markdown version of https://docs.meshy.ai/api/creative-lab-keycap.
>
> - Full docs index: https://docs.meshy.ai/llms.txt
> - Single-fetch full content: https://docs.meshy.ai/llms-full.txt
> - Tool-calling access via MCP server: https://docs.meshy.ai/api/ai

---
# Creative Lab — Keycap API

Turn a source photo into a full-color custom mechanical keyboard keycap in
two stages: **prototype** generates a "finished keycap" design render
from your input photo. Once you have confirmed that render, **build** turns it into a
textured 3D keycap model in a single run — white-model generation, automatic
seating and cutting on a calibrated default pose, full-model coloring, and
final assembly all happen inside one build task. The two stages are linked
via `input_task_id` plus `candidate_id`.

- `POST /openapi/creative-lab/keycap/v1/prototype`
- `POST /openapi/creative-lab/keycap/v1/build`

> **Note:** Both `POST` endpoints require a paid subscription plan. Requests from
>   free-plan accounts are rejected with `402 Payment Required`.

---

## POST /openapi/creative-lab/keycap/v1/prototype -- Create a Keycap Prototype Task

Generate a finished-keycap design render from the source photo. The task
result carries an `image_urls` array (the display render of the finished
keycap) and a parallel `candidate_ids` array; both hold a single entry.
Call this endpoint again for another render if the result is not what you
want — each call is billed separately. Pass the `candidate_id` together
with the prototype task ID to the [build endpoint](#create-a-keycap-build-task).
Refer to
[The Keycap Prototype Task Object](#the-keycap-prototype-task-object)
for the response shape.

### Parameters

  - `image_url` · *string* · **required**

  Source photo for Meshy to turn into keycap design images. We currently support `.jpg`, `.jpeg`, `.png`, and `.webp` formats.

  The format is detected by decoding the image data, **not** from the URL's file extension — a URL with no extension, or one that redirects, works as long as the bytes decode to a supported format. HTTP redirects are followed. EXIF orientation is normalized, so a rotated phone photo is used the way it looks.

  Limits: at least `32` pixels on each side, at most `178,956,970` pixels in total, and at most `20,000,000` bytes once downloaded. For a data URI the limit applies to the **decoded** bytes, so the source file itself may be up to that size — it is the base64 text that is about a third larger, which matters for your request body, not for this limit. A data URI must declare an `image/*` content type and `;base64`.

  There are two ways to provide the image:

  - **Publicly accessible URL**: A URL that is accessible from the public internet.
  - **Data URI**: A base64-encoded data URI of the image. Example of a data URI: `data:image/jpeg;base64,<your base64-encoded image data>`.

  - `name` · *string*

  Optional task name for display purposes. Maximum 100 characters.

  - `remove_background` · *boolean* · default: `false`

  When set to `true`, the display render returned in `image_urls` is a transparent RGBA PNG with the background removed, so you can composite it onto any background.

  This applies to the display render only. The candidate the build endpoint consumes is unaffected, so the 3D result is identical either way.

### Returns

The `result` property of the response contains the task `id` of the newly created keycap prototype task. Poll the [Get a Task](#retrieve-a-keycap-task) endpoint or subscribe to the [stream](#stream-a-keycap-task) until the task reaches `SUCCEEDED`, then take the entry from `candidate_ids` and pass it, together with the task ID, to the [build endpoint](#create-a-keycap-build-task).

### Failure Modes

  - `400 - Bad Request`

  The request was unacceptable. Common causes:
  * **Missing parameter**: `image_url` is required.
  * **Invalid image format**: The provided `image_url` is not a supported format (`.jpg`, `.jpeg`, `.png`, `.webp`).
  * **Image dimensions out of range**: The image is too small, exceeds the maximum file size, or exceeds the maximum pixel count.
  * **Unreachable URL**: The `image_url` could not be downloaded (404 or timeout).
  * **Invalid Data URI**: The base64 string is malformed.
  * **Content flagged**: The input image was flagged by NSFW moderation.

  - `401 - Unauthorized`

  Authentication failed. Please check your API key.

  - `402 - Payment Required`

  The account is on the free plan (a paid plan is required to create tasks) or has insufficient credits.

  - `403 - Forbidden`

  The input image was flagged by intellectual property moderation.

  - `429 - Too Many Requests`

  You have exceeded your rate limit.

  - `500 - Internal Server Error`

  An unexpected server-side error occurred — for example the content-moderation service was unavailable, staging the input image failed, or the task could not be created. No task is created in this case, so retrying is safe.

**cURL**

```bash
# Stage 1: generate a finished-keycap design render
curl https://api.meshy.ai/openapi/creative-lab/keycap/v1/prototype \
  -X POST \
  -H "Authorization: Bearer ${YOUR_API_KEY}" \
  -H 'Content-Type: application/json' \
  -d '{
    "image_url": "<your publicly accessible image url or base64-encoded data URI>"
  }'
```

```javascript
import axios from 'axios'

const headers = { Authorization: `Bearer ${YOUR_API_KEY}` };

// Stage 1: generate a finished-keycap design render
const payload = {
  image_url: "<your publicly accessible image url or base64-encoded data URI>",
};

try {
  const response = await axios.post(
    'https://api.meshy.ai/openapi/creative-lab/keycap/v1/prototype',
    payload,
    { headers }
  );
  console.log(response.data);
} catch (error) {
  console.error(error);
}
```

```python
import requests

headers = {
    "Authorization": f"Bearer {YOUR_API_KEY}"
}

# Stage 1: generate a finished-keycap design render
payload = {
    "image_url": "<your publicly accessible image url or base64-encoded data URI>",
}

response = requests.post(
    "https://api.meshy.ai/openapi/creative-lab/keycap/v1/prototype",
    headers=headers,
    json=payload,
)
response.raise_for_status()
print(response.json())
```

**Response**

```json
{
  "result": "019c9a4e-2b31-7f6a-8c44-5d2a87b3c1ef"
}
```

---

## POST /openapi/creative-lab/keycap/v1/build -- Create a Keycap Build Task

Generate the final textured 3D keycap model from a succeeded prototype
task and one of its candidates. A single build task runs the whole
pipeline end to end — white-model generation from the chosen design,
automatic seating and cutting onto the keycap base using a calibrated
default pose (no interactive adjustment needed), full-model coloring,
and final assembly and export. A build typically takes **3–7 minutes**, toward the upper end when several builds run concurrently.
Refer to [The Keycap Build Task Object](#the-keycap-build-task-object)
for the response shape.

### Parameters

  - `input_task_id` · *string* · **required**

  The task ID of a prototype task created via this same OpenAPI endpoint. The prototype must have been created by the same Meshy account, must have reached `SUCCEEDED`, and must have produced at least one candidate.

  Prototype tasks created through the webapp are **not** accepted — the build endpoint accepts only prototype tasks produced by `POST /openapi/creative-lab/keycap/v1/prototype` and refuses any other source with `404`.

  - `candidate_id` · *string* · **required**

  The candidate to build, taken from the `candidate_ids` array of the succeeded prototype task. Must belong to that task; any other value is rejected with `400`.

  - `name` · *string*

  Optional task name for display purposes. Maximum 100 characters.

#### `options`

Optional geometry tuning. Every field has a calibrated default — send only the ones you want to override.

  - `base_model` · *string* · default: `cherry-mx-1x1-r1`

  The keycap base to build on. Currently the only available value is
  `cherry-mx-1x1-r1` — a standard Cherry MX profile 1u keycap. 3–5
  additional mainstream standard sizes are planned; custom sizes are
  not supported.

  - `head_size_mm` · *number* · default: `23`

  Target size of the sculpted head, in millimeters: its longest dimension is scaled to this value. Range: `[10, 40]`. Values above roughly `32.9` may be reduced so the head still fits the base's protective footprint limit, so the delivered longest dimension can be smaller than requested. The applied value is not echoed back on the task object today — if you need to confirm the size you actually received, measure the bounding box of the `keycap-head` mesh in the downloaded model.

  - `vertical_offset_mm` · *number* · default: `0`

  Vertical offset applied to the head before it is seated on the base, in millimeters. Range: `[-5, 5]`.

### Returns

The `result` property of the response contains the task `id` of the newly created keycap build task. Poll the [Get a Task](#retrieve-a-keycap-task) endpoint or subscribe to the [stream](#stream-a-keycap-task) until the task reaches `SUCCEEDED`, then download the artifacts from `model_urls.glb` and `model_urls.obj_zip`.

> **Note:** Both the GLB and the OBJ bundle are exported at **real-world
>       millimeter scale**, with a **Y-up** coordinate system and the front of
>       the keycap facing **+Z**.

### Failure Modes

  - `400 - Bad Request`

  The request was unacceptable. Common causes:
  * **Missing parameter**: `input_task_id` and `candidate_id` are required.
  * **Invalid UUID**: The `input_task_id` is not a valid UUID.
  * **Parent not succeeded**: The referenced prototype task has not reached `SUCCEEDED` yet.
  * **No candidates**: The prototype task succeeded but produced no candidates.
  * **Unknown candidate**: `candidate_id` is not one of the input task's candidates.
  * **Options out of range**: One of the `options` fields fell outside its allowed range or enum set.

  - `401 - Unauthorized`

  Authentication failed. Please check your API key.

  - `402 - Payment Required`

  The account is on the free plan (a paid plan is required to create tasks) or has insufficient credits.

  - `404 - Not Found`

  The referenced prototype task does not exist, belongs to a different user, or was created through the webapp (only API-mode prototype tasks chain into build).

  - `429 - Too Many Requests`

  You have exceeded your rate limit.

  - `500 - Internal Server Error`

  An unexpected server-side error occurred — for example the content-moderation service was unavailable, staging the input image failed, or the task could not be created. No task is created in this case, so retrying is safe.

**cURL**

```bash
# Stage 2: build the chosen candidate into a 3D keycap
curl https://api.meshy.ai/openapi/creative-lab/keycap/v1/build \
  -X POST \
  -H "Authorization: Bearer ${YOUR_API_KEY}" \
  -H 'Content-Type: application/json' \
  -d '{
    "input_task_id": "019c9a4e-2b31-7f6a-8c44-5d2a87b3c1ef",
    "candidate_id": "0198c1b2-33f5-7d21-a4b7-8e9d0c1f2a3b",
    "options": {
      "base_model": "cherry-mx-1x1-r1",
      "head_size_mm": 23,
      "vertical_offset_mm": 0
    }
  }'
```

```javascript
import axios from 'axios'

const headers = { Authorization: `Bearer ${YOUR_API_KEY}` };

// Stage 2: build the chosen candidate into a 3D keycap
const payload = {
  input_task_id: "019c9a4e-2b31-7f6a-8c44-5d2a87b3c1ef",
  candidate_id: "0198c1b2-33f5-7d21-a4b7-8e9d0c1f2a3b",
  options: {
    base_model: "cherry-mx-1x1-r1",
    head_size_mm: 23,
    vertical_offset_mm: 0,
  },
};

try {
  const response = await axios.post(
    'https://api.meshy.ai/openapi/creative-lab/keycap/v1/build',
    payload,
    { headers }
  );
  console.log(response.data);
} catch (error) {
  console.error(error);
}
```

```python
import requests

headers = {
    "Authorization": f"Bearer {YOUR_API_KEY}"
}

# Stage 2: build the chosen candidate into a 3D keycap
payload = {
    "input_task_id": "019c9a4e-2b31-7f6a-8c44-5d2a87b3c1ef",
    "candidate_id": "0198c1b2-33f5-7d21-a4b7-8e9d0c1f2a3b",
    "options": {
        "base_model": "cherry-mx-1x1-r1",
        "head_size_mm": 23,
        "vertical_offset_mm": 0,
    },
}

response = requests.post(
    "https://api.meshy.ai/openapi/creative-lab/keycap/v1/build",
    headers=headers,
    json=payload,
)
response.raise_for_status()
print(response.json())
```

**Response**

```json
{
  "result": "019c9a52-7d18-7e2b-9f01-6e3b98c4d2af"
}
```

---

## GET /openapi/creative-lab/keycap/v1/(prototype|build)/:id -- Retrieve a Keycap Task

Retrieve a prototype or build task given a valid task `id`. The URL path
must match the task's stage — a build task fetched through
`/prototype/:id` returns `404`, and vice versa.

Refer to [The Keycap Prototype Task Object](#the-keycap-prototype-task-object)
and [The Keycap Build Task Object](#the-keycap-build-task-object) for
response shapes.

### Parameters

  - `id` · *path*

  Unique identifier for the keycap task to retrieve.

### Returns

The response contains the keycap task object. The shape depends on which
stage was requested.

**cURL**

```bash
# Prototype
curl https://api.meshy.ai/openapi/creative-lab/keycap/v1/prototype/019c9a4e-2b31-7f6a-8c44-5d2a87b3c1ef \
  -H "Authorization: Bearer ${YOUR_API_KEY}"

# Build
curl https://api.meshy.ai/openapi/creative-lab/keycap/v1/build/019c9a52-7d18-7e2b-9f01-6e3b98c4d2af \
  -H "Authorization: Bearer ${YOUR_API_KEY}"
```

```javascript
import axios from 'axios'

const headers = { Authorization: `Bearer ${YOUR_API_KEY}` };

// Prototype
const prototypeId = '019c9a4e-2b31-7f6a-8c44-5d2a87b3c1ef';
const proto = await axios.get(
  `https://api.meshy.ai/openapi/creative-lab/keycap/v1/prototype/${prototypeId}`,
  { headers }
);
console.log(proto.data);

// Build
const buildId = '019c9a52-7d18-7e2b-9f01-6e3b98c4d2af';
const build = await axios.get(
  `https://api.meshy.ai/openapi/creative-lab/keycap/v1/build/${buildId}`,
  { headers }
);
console.log(build.data);
```

```python
import requests

headers = {
    "Authorization": f"Bearer {YOUR_API_KEY}"
}

# Prototype
prototype_id = "019c9a4e-2b31-7f6a-8c44-5d2a87b3c1ef"
proto = requests.get(
    f"https://api.meshy.ai/openapi/creative-lab/keycap/v1/prototype/{prototype_id}",
    headers=headers,
)
proto.raise_for_status()
print(proto.json())

# Build
build_id = "019c9a52-7d18-7e2b-9f01-6e3b98c4d2af"
build = requests.get(
    f"https://api.meshy.ai/openapi/creative-lab/keycap/v1/build/{build_id}",
    headers=headers,
)
build.raise_for_status()
print(build.json())
```

**Prototype Response**

```json
{
  "id": "019c9a4e-2b31-7f6a-8c44-5d2a87b3c1ef",
  "type": "creative-lab-keycap-prototype",
  "name": "",
  "status": "SUCCEEDED",
  "progress": 100,
  "created_at": 1753142456000,
  "started_at": 1753142460000,
  "finished_at": 1753142516000,
  "expires_at": 1753401716000,
  "preceding_tasks": 0,
  "task_error": null,
  "consumed_credits": 12,
  "image_urls": [
    "https://assets.meshy.ai/***/design-1.png?Expires=***"
  ],
  "candidate_ids": [
    "0198c1b2-33f5-7d21-a4b7-8e9d0c1f2a3b"
  ]
}
```

**Build Response**

```json
{
  "id": "019c9a52-7d18-7e2b-9f01-6e3b98c4d2af",
  "type": "creative-lab-keycap-build",
  "name": "",
  "status": "SUCCEEDED",
  "progress": 100,
  "created_at": 1753142600000,
  "started_at": 1753142610000,
  "finished_at": 1753143050000,
  "expires_at": 1753402250000,
  "preceding_tasks": 0,
  "task_error": null,
  "consumed_credits": 50,
  "model_urls": {
    "glb": "https://assets.meshy.ai/***/tasks/019c9a52-7d18-7e2b-9f01-6e3b98c4d2af/output/model.glb?Expires=***",
    "obj_zip": "https://assets.meshy.ai/***/tasks/019c9a52-7d18-7e2b-9f01-6e3b98c4d2af/output/model-obj.zip?Expires=***"
  },
  "process_image_urls": {
    "head_design": "https://assets.meshy.ai/***/head-design.png?Expires=***",
    "composite": "https://assets.meshy.ai/***/composite.png?Expires=***",
    "base_canvas": "https://assets.meshy.ai/***/base-canvas.png?Expires=***"
  }
}
```

---

## DELETE /openapi/creative-lab/keycap/v1/(prototype|build)/:id -- Delete a Keycap Task

Cancel a keycap task. If the task is still `PENDING`, the credits
consumed at create-time are refunded. Tasks that are already
`IN_PROGRESS` are cancelled without a refund (the worker may already be
burning resources). Tasks that have already reached a terminal state
(`SUCCEEDED`, `FAILED`, `CANCELED`) cannot be cancelled.

The URL path must match the task's stage — `DELETE` on
`/prototype/:buildId` returns `404`.

### Path Parameters

  - `id` · *path*

  Unique identifier for the keycap task to cancel.

### Returns

Returns `204 No Content` on success with an empty body.

### Failure Modes

  - `400 - Bad Request`

  The task is already in a terminal state and cannot be cancelled.

  - `404 - Not Found`

  The task does not exist, belongs to a different user, or its stage does not match the URL path.

  - `500 - Internal Server Error`

  An unexpected server-side error occurred while cancelling. The task may or may not have been cancelled — re-read it to confirm before retrying.

  **cURL**

  ```bash
  curl --request DELETE \
    --url https://api.meshy.ai/openapi/creative-lab/keycap/v1/prototype/019c9a4e-2b31-7f6a-8c44-5d2a87b3c1ef \
    -H "Authorization: Bearer ${YOUR_API_KEY}"
  ```

  ```javascript
  import axios from 'axios'

  const taskId = '019c9a4e-2b31-7f6a-8c44-5d2a87b3c1ef'
  const headers = { Authorization: `Bearer ${YOUR_API_KEY}` }

  try {
    await axios.delete(
      `https://api.meshy.ai/openapi/creative-lab/keycap/v1/prototype/${taskId}`,
      { headers }
    )
  } catch (error) {
    console.error(error)
  }
  ```

  ```python
  import requests

  task_id = "019c9a4e-2b31-7f6a-8c44-5d2a87b3c1ef"
  headers = {
      "Authorization": f"Bearer {YOUR_API_KEY}"
  }

  response = requests.delete(
      f"https://api.meshy.ai/openapi/creative-lab/keycap/v1/prototype/{task_id}",
      headers=headers,
  )
  response.raise_for_status()
  ```

**Response**

```json
// Returns 204 No Content on success (empty body).
```

---

## GET /openapi/creative-lab/keycap/v1/(prototype|build)/:id/stream -- Stream a Keycap Task

Stream real-time updates for a keycap task via Server-Sent Events (SSE).
The URL path must match the task's stage — opening a stream at
`/prototype/:buildId/stream` emits a single `event: error` payload with
`status_code: 404` and closes the stream.

### Parameters

  - `id` · *path*

  Unique identifier for the keycap task to stream.

### Returns

Returns a stream of [Keycap Prototype](#the-keycap-prototype-task-object)
or [Keycap Build](#the-keycap-build-task-object) task objects as
Server-Sent Events. Every frame carries the full task object for the stage — the same shape the
Get endpoint returns — so while the task is `PENDING` or `IN_PROGRESS` the
output fields are simply not populated yet (`null`, `[]` or `{}`) and
`finished_at` is `null`.

  **cURL**

  ```bash
  curl -N https://api.meshy.ai/openapi/creative-lab/keycap/v1/build/019c9a52-7d18-7e2b-9f01-6e3b98c4d2af/stream \
  -H "Authorization: Bearer ${YOUR_API_KEY}"
  ```

  ```javascript
  const response = await fetch(
    'https://api.meshy.ai/openapi/creative-lab/keycap/v1/build/019c9a52-7d18-7e2b-9f01-6e3b98c4d2af/stream',
    {
      headers: { Authorization: `Bearer ${YOUR_API_KEY}` }
    }
  );

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop();

    for (const line of lines) {
      if (line.startsWith('data:')) {
        const data = JSON.parse(line.slice(5));
        console.log(data);

        if (['SUCCEEDED', 'FAILED', 'CANCELED'].includes(data.status)) {
          reader.cancel();
        }
      }
    }
  }
  ```

  ```python
  import requests
  import json

  headers = {
      "Authorization": f"Bearer {YOUR_API_KEY}",
      "Accept": "text/event-stream"
  }

  response = requests.get(
      'https://api.meshy.ai/openapi/creative-lab/keycap/v1/build/019c9a52-7d18-7e2b-9f01-6e3b98c4d2af/stream',
      headers=headers,
      stream=True
  )

  for line in response.iter_lines():
      if line:
          if line.startswith(b'data:'):
              data = json.loads(line.decode('utf-8')[5:])
              print(data)

              if data['status'] in ['SUCCEEDED', 'FAILED', 'CANCELED']:
                  break

  response.close()
  ```

  **Response Stream**

  ```javascript
  // Error event example (wrong stage or task not found)
  event: error
  data: {
    "status_code": 404,
    "message": "Task not found"
  }

  // Message event examples illustrate task progress.
  // Every frame is the full task object; fields not yet populated are null / empty.
  // The PENDING frame below is abbreviated to the fields that change.
  event: message
  data: {
    "id": "019c9a52-7d18-7e2b-9f01-6e3b98c4d2af",
    "progress": 0,
    "status": "PENDING"
  }

  event: message
  data: {
    "id": "019c9a52-7d18-7e2b-9f01-6e3b98c4d2af",
    "type": "creative-lab-keycap-build",
    "status": "SUCCEEDED",
    "progress": 100,
    "created_at": 1753142600000,
    "started_at": 1753142610000,
    "finished_at": 1753143050000,
    "expires_at": 1753402250000,
    "task_error": null,
    "consumed_credits": 50,
    "model_urls": {
      "glb": "https://assets.meshy.ai/***/tasks/019c9a52-7d18-7e2b-9f01-6e3b98c4d2af/output/model.glb?Expires=***",
      "obj_zip": "https://assets.meshy.ai/***/tasks/019c9a52-7d18-7e2b-9f01-6e3b98c4d2af/output/model-obj.zip?Expires=***"
    },
    "process_image_urls": {
      "head_design": "https://assets.meshy.ai/***/head-design.png?Expires=***"
    }
  }
  ```

---

## GET /openapi/creative-lab/keycap/v1/(prototype|build) -- List Keycap Tasks

Retrieve a paginated list of your keycap tasks for a single stage. The URL
path selects the stage — `/prototype` returns prototype tasks; `/build`
returns build tasks. Tasks from the other stage are not included in either
response.

### Path Parameters

  - `stage` · *path* · **required**

  Either `prototype` or `build`. The collection returns only tasks
  whose stage matches the URL — fetching `/prototype` never returns
  build tasks and vice versa.

### Query Parameters

  - `page_num` · *integer* · default: `1`

  Page number for pagination.

  - `page_size` · *integer* · default: `10`

  Page size limit. Maximum allowed is `100` items.

  - `sort_by` · *string* · default: `-created_at`

  Field to sort by. Available values:
  * `+created_at`: Sort by creation time in ascending order.
  * `-created_at`: Sort by creation time in descending order.

### Returns

Returns a paginated list of the per-stage task object — either
[the keycap prototype task object](#the-keycap-prototype-task-object)
when listing `/prototype` or
[the keycap build task object](#the-keycap-build-task-object) when
listing `/build`.

  **cURL**

  ```bash
  # List prototype tasks
  curl https://api.meshy.ai/openapi/creative-lab/keycap/v1/prototype?page_size=10 \
    -H "Authorization: Bearer ${YOUR_API_KEY}"

  # List build tasks
  curl https://api.meshy.ai/openapi/creative-lab/keycap/v1/build?page_size=10 \
    -H "Authorization: Bearer ${YOUR_API_KEY}"
  ```

  ```javascript
  import axios from 'axios'

  const headers = { Authorization: `Bearer ${YOUR_API_KEY}` }

  try {
    const { data } = await axios.get(
      'https://api.meshy.ai/openapi/creative-lab/keycap/v1/prototype',
      { headers, params: { page_size: 10 } }
    )
    console.log(data)
  } catch (error) {
    console.error(error)
  }
  ```

  ```python
  import requests

  headers = {
      "Authorization": f"Bearer {YOUR_API_KEY}"
  }

  response = requests.get(
      "https://api.meshy.ai/openapi/creative-lab/keycap/v1/prototype",
      headers=headers,
      params={"page_size": 10},
  )
  response.raise_for_status()
  print(response.json())
  ```

**Response (List Prototype Tasks)**

```json
[
  {
    "id": "019c9a4e-2b31-7f6a-8c44-5d2a87b3c1ef",
    "type": "creative-lab-keycap-prototype",
    "name": "",
    "status": "SUCCEEDED",
    "progress": 100,
    "created_at": 1753142456000,
    "started_at": 1753142460000,
    "finished_at": 1753142516000,
    "expires_at": 1753401716000,
    "preceding_tasks": 0,
    "task_error": null,
    "consumed_credits": 12,
    "image_urls": [
      "https://assets.meshy.ai/***/design-1.png?Expires=***"
    ],
    "candidate_ids": [
      "0198c1b2-33f5-7d21-a4b7-8e9d0c1f2a3b"
    ]
  }
]
```

---

## The Keycap Prototype Task Object
The Keycap Prototype Task object is a work unit that Meshy keeps track of to
generate one **finished-keycap design image** from a source photo. The
output of this stage is chained into [the build stage](#create-a-keycap-build-task)
via `input_task_id` plus `candidate_id`.

### Properties

- `id` · *string*

  Unique identifier for the task. While we use a k-sortable UUID for task ids as the implementation detail, you should **not** make any assumptions about the format of the id.

- `type` · *string*

  Type of the task. The value is `creative-lab-keycap-prototype`.

- `name` · *string*

  The task name supplied when the task was created. Empty string if no name was provided.

- `status` · *string*

  Status of the task. Possible values are one of `PENDING`, `IN_PROGRESS`, `SUCCEEDED`, `FAILED`, `CANCELED`.

- `progress` · *integer*

  Progress of the task. If the task is not started yet, this property will be `0`. Once the task has succeeded, this will become `100`.

- `created_at` · *timestamp*

  Timestamp of when the task was created, in milliseconds.

  > **Note:** A timestamp represents the number of milliseconds elapsed since January 1, 1970 UTC, following
  >                     the [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339) standard.
  >                     For example, Friday, September 1, 2023 12:00:00 PM GMT is represented as `1693569600000`. This applies
  >                     to **all** timestamps in Meshy API.

- `started_at` · *timestamp*

  Timestamp of when the task was started, in milliseconds. If the task is not started yet, this property will be `0`.

- `finished_at` · *timestamp*

  Timestamp of when the task was finished, in milliseconds. If the task is not finished yet, this property will be `0`.

- `expires_at` · *timestamp*

  Timestamp of when the task result expires, in milliseconds.

- `preceding_tasks` · *integer*

  The count of preceding tasks.

  > **Note:** The value of this field is meaningful only if the task status is `PENDING`.

- `task_error` · *object*

  Error details for failed tasks. See [Errors](/api/errors#task-errors) for the full `task_error` object reference.

- `consumed_credits` · *integer*

  The number of credits consumed by this task. A task that reaches `SUCCEEDED` is charged the full amount for its stage. A task that never gets created (a `4xx` at request time, including a moderation rejection) is not charged at all. A task that reaches `FAILED` returns `0` — the charge is refunded, including an asynchronous moderation block. Cancelling via `DELETE` refunds only while the task is still `PENDING`; a task already `IN_PROGRESS` stays charged, because the work has been spent.

- `image_urls` · *array of strings*

  Downloadable URL of the finished-keycap design render — what the candidate looks like as a finished keycap. Holds a single entry; `image_urls[i]` corresponds to `candidate_ids[i]`. Empty until the task reaches `SUCCEEDED`. The URL is for display only; the build endpoint consumes `candidate_ids`, not these URLs. Same URL lifecycle as `model_urls`: signed, no `Authorization` header, valid until `expires_at`, and stable when the task is re-read.

- `candidate_ids` · *array of strings*

  Opaque candidate identifiers, parallel to `image_urls`. Pass the entry matching your chosen design as the build request's `candidate_id`. Do **not** make any assumptions about the format of these ids.

<span id="example-keycap-prototype-task-object" />

**Example Keycap Prototype Task Object**

```json
{
  "id": "019c9a4e-2b31-7f6a-8c44-5d2a87b3c1ef",
  "type": "creative-lab-keycap-prototype",
  "name": "",
  "status": "SUCCEEDED",
  "progress": 100,
  "created_at": 1753142456000,
  "started_at": 1753142460000,
  "finished_at": 1753142516000,
  "expires_at": 1753401716000,
  "preceding_tasks": 0,
  "task_error": null,
  "consumed_credits": 12,
  "image_urls": [
    "https://assets.meshy.ai/***/design-1.png?Expires=***"
  ],
  "candidate_ids": [
    "0198c1b2-33f5-7d21-a4b7-8e9d0c1f2a3b"
  ]
}
```

---

## The Keycap Build Task Object
The Keycap Build Task object is a work unit that Meshy keeps track of to
generate the final textured 3D keycap from a succeeded prototype task and a
chosen candidate. A single build runs the full pipeline — white-model
generation, automatic seating and cutting, coloring, assembly, and export.

### Properties

- `id` · *string*

  Unique identifier for the task.

- `type` · *string*

  Type of the task. The value is `creative-lab-keycap-build`.

- `name` · *string*

  The task name supplied when the task was created. Empty string if no name was provided.

- `status` · *string*

  Status of the task. Possible values are one of `PENDING`, `IN_PROGRESS`, `SUCCEEDED`, `FAILED`, `CANCELED`.

- `progress` · *integer*

  Progress of the task. If the task is not started yet, this property will be `0`. Once the task has succeeded, this will become `100`.

- `created_at` · *timestamp*

  Timestamp of when the task was created, in milliseconds.

- `started_at` · *timestamp*

  Timestamp of when the task was started, in milliseconds.

- `finished_at` · *timestamp*

  Timestamp of when the task was finished, in milliseconds.

- `expires_at` · *timestamp*

  Timestamp of when the task result expires, in milliseconds.

- `preceding_tasks` · *integer*

  The count of preceding tasks. Meaningful only when status is `PENDING`.

- `task_error` · *object*

  Error details for failed tasks. See [Errors](/api/errors#task-errors) for the full `task_error` object reference.

- `consumed_credits` · *integer*

  The number of credits consumed by this task. A task that reaches `SUCCEEDED` is charged the full amount for its stage. A task that never gets created (a `4xx` at request time, including a moderation rejection) is not charged at all. A task that reaches `FAILED` returns `0` — the charge is refunded, including an asynchronous moderation block. Cancelling via `DELETE` refunds only while the task is still `PENDING`; a task already `IN_PROGRESS` stays charged, because the work has been spent.

- `model_urls` · *object*

  Downloadable URLs for the generated model artifacts. Both the GLB and the OBJ bundle are exported at **real-world millimeter scale**, **Y-up**, with the front of the keycap facing **+Z**. Meshes are named `keycap-head` and `keycap-base`; when the base falls back to a pattern fill, a third mesh `keycap-base-interior` is also present for the stem cavity. Do not assume exactly two meshes.

  These are signed URLs: fetch them **without** an `Authorization` header. They stay valid until `expires_at`, which is 3 days after `finished_at`, and re-reading the task inside that window returns the identical URL rather than a freshly signed one. Download and store the files yourself before then — there is no way to refresh an expired link.

  - `glb` · *string*

  Downloadable URL to the final textured `model.glb`.

  - `obj_zip` · *string*

  Downloadable URL to a zip bundle containing `model.obj`, `model.mtl`, and the texture PNGs its MTL actually references. A solid-color base ships only `keycap-head.png`; a patterned base also ships `keycap-base.png`.

- `process_image_urls` · *object*

  Downloadable URLs for intermediate process images, keyed by kind. Same URL lifecycle as `model_urls`: signed, no `Authorization` header, valid until `expires_at`, and stable when the task is re-read. Currently emitted kinds:
  * `head_design` — the chosen candidate's design image the build consumed (always present).
  * `composite` — the finished-keycap display render of the chosen candidate (present when available).
  * `base_canvas` — the painted keycap-base canvas (present when available).

  Treat the key set as open-ended; new kinds may be added without a breaking change.

<span id="example-keycap-build-task-object" />

**Example Keycap Build Task Object**

```json
{
  "id": "019c9a52-7d18-7e2b-9f01-6e3b98c4d2af",
  "type": "creative-lab-keycap-build",
  "name": "",
  "status": "SUCCEEDED",
  "progress": 100,
  "created_at": 1753142600000,
  "started_at": 1753142610000,
  "finished_at": 1753143050000,
  "expires_at": 1753402250000,
  "preceding_tasks": 0,
  "task_error": null,
  "consumed_credits": 50,
  "model_urls": {
    "glb": "https://assets.meshy.ai/***/tasks/019c9a52-7d18-7e2b-9f01-6e3b98c4d2af/output/model.glb?Expires=***",
    "obj_zip": "https://assets.meshy.ai/***/tasks/019c9a52-7d18-7e2b-9f01-6e3b98c4d2af/output/model-obj.zip?Expires=***"
  },
  "process_image_urls": {
    "head_design": "https://assets.meshy.ai/***/head-design.png?Expires=***",
    "composite": "https://assets.meshy.ai/***/composite.png?Expires=***",
    "base_canvas": "https://assets.meshy.ai/***/base-canvas.png?Expires=***"
  }
}
```

---

## End-to-End Example

The complete flow: create a prototype from a photo, poll it to
`SUCCEEDED`, pick a candidate from `candidate_ids`, create a build with
that candidate, poll the build to `SUCCEEDED`, then download the GLB and
the OBJ bundle from `model_urls`.

The example picks the **first** candidate programmatically. In a real
integration you would display the `image_urls` entry to the end user and
let them choose; the chosen index maps 1:1 onto `candidate_ids`.

**cURL**

```bash
#!/usr/bin/env bash
set -euo pipefail

# Requires curl and jq. Point IMAGE_PATH at a local photo, or IMAGE_URL at a public one:
#   export MESHY_API_KEY=msy_...
#   export IMAGE_PATH=./portrait.jpg          # or: export IMAGE_URL=https://...
: "${MESHY_API_KEY:?export MESHY_API_KEY first}"
if [[ -z "${IMAGE_PATH:-}" && -z "${IMAGE_URL:-}" ]]; then
  echo "export IMAGE_PATH (local file) or IMAGE_URL (public url) first" >&2
  exit 1
fi

BASE="https://api.meshy.ai/openapi/creative-lab/keycap/v1"
AUTH="Authorization: Bearer $MESHY_API_KEY"

# api METHOD URL [curl args...] -> prints the response body, non-zero on failure.
# Note we do not use -f/--fail: it discards the body, and the body is the only
# place the reason appears.
api() {
  local method=$1 url=$2 out http_code body
  shift 2
  out=$(curl --silent --show-error --max-time 60 --write-out $'\n%{http_code}' \
    -X "$method" "$url" -H "$AUTH" "$@") || return 1
  http_code=${out##*$'\n'}
  body=${out%$'\n'*}
  if ((http_code >= 400)); then
    echo "HTTP $http_code for $url: $body" >&2
    return 1
  fi
  printf '%s' "$body"
}

# Each task gets its own 40-minute budget.
poll() {
  local kind=$1 id=$2 delay=5 task_status deadline
  deadline=$(($(date +%s) + 2400))
  while :; do
    if (($(date +%s) >= deadline)); then
      echo "gave up waiting for $kind $id" >&2
      return 1
    fi
    task_status=$(api GET "$BASE/$kind/$id" | jq -r '.status')
    echo "$kind: $task_status"
    case "$task_status" in
    SUCCEEDED) return 0 ;;
    FAILED | CANCELED) return 1 ;;
    esac
    sleep "$delay"
    delay=$((delay * 2 > 30 ? 30 : delay * 2))
  done
}

# Build the request body in a file. A base64 data URI must never go on the
# command line or into an exported variable - a photo of any real size will
# exceed the OS argument limit.
BODY=$(mktemp)
trap 'rm -f "$BODY"' EXIT
if [[ -n "${IMAGE_PATH:-}" ]]; then
  # Declare the real type: the API accepts JPEG, PNG and WebP.
  case "$(printf '%s' "${IMAGE_PATH##*.}" | tr 'A-Z' 'a-z')" in
    png) MIME=image/png ;;
    webp) MIME=image/webp ;;
    *) MIME=image/jpeg ;;
  esac
  {
    printf '{"image_url":"data:%s;base64,' "$MIME"
    base64 <"$IMAGE_PATH" | tr -d '\n'
    printf '"}'
  } >"$BODY"
else
  printf '{"image_url":"%s"}' "$IMAGE_URL" >"$BODY"
fi

# 1. Create the prototype task
PROTO_ID=$(api POST "$BASE/prototype" \
  -H 'Content-Type: application/json' --data-binary @"$BODY" | jq -r '.result')

# 2. Wait for the design render
poll prototype "$PROTO_ID"

# 3. Pick a candidate (first one here; show image_urls to a user in production)
CANDIDATE_ID=$(api GET "$BASE/prototype/$PROTO_ID" | jq -r '.candidate_ids[0]')

# 4. Create the build task
jq -n --arg p "$PROTO_ID" --arg c "$CANDIDATE_ID" \
  '{input_task_id: $p, candidate_id: $c}' >"$BODY"
BUILD_ID=$(api POST "$BASE/build" \
  -H 'Content-Type: application/json' --data-binary @"$BODY" | jq -r '.result')

# 5. Wait for the model (a build usually takes 3-7 minutes)
poll build "$BUILD_ID"

# 6. Download the artifacts. These are signed URLs: no Authorization header,
#    and they stay valid for 3 days after the task finishes.
TASK=$(api GET "$BASE/build/$BUILD_ID")
curl --silent --show-error --fail --max-time 900 \
  -o keycap.glb "$(jq -r '.model_urls.glb' <<<"$TASK")"
curl --silent --show-error --fail --max-time 900 \
  -o keycap-obj.zip "$(jq -r '.model_urls.obj_zip' <<<"$TASK")"
echo "Done: keycap.glb + keycap-obj.zip"
```

```python
# Requires the requests package:  pip install requests
import base64
import os
import time

import requests

BASE = "https://api.meshy.ai/openapi/creative-lab/keycap/v1"
REQUEST_TIMEOUT = 60  # per HTTP call
TASK_DEADLINE = 2400  # give up on a single task after 40 minutes

def env(name: str) -> str:
    value = os.environ.get(name)
    if not value:
        raise SystemExit(f"set {name} before running")
    return value

# Point IMAGE_PATH at a local photo, or IMAGE_URL at a public one:
#   export MESHY_API_KEY=msy_...
#   export IMAGE_PATH=./portrait.jpg        # or: export IMAGE_URL=https://...
HEADERS = {"Authorization": f"Bearer {env('MESHY_API_KEY')}"}

def source_image() -> str:
    path = os.environ.get("IMAGE_PATH")
    if path:
        # Encode here, in the process. A base64 data URI passed on a command
        # line or through an exported variable will exceed the OS argument
        # limit for a photo of any real size.
        # Declare the real type: the API accepts JPEG, PNG and WebP.
        mime = {"png": "image/png", "webp": "image/webp"}.get(
            os.path.splitext(path)[1].lower().lstrip("."), "image/jpeg")
        with open(path, "rb") as f:
            return f"data:{mime};base64," + base64.b64encode(f.read()).decode()
    if not os.environ.get("IMAGE_URL"):
        raise SystemExit("set IMAGE_PATH (local file) or IMAGE_URL (public url) first")
    return os.environ["IMAGE_URL"]

def check(resp: requests.Response) -> dict:
    # raise_for_status()'s message omits the response body, and the body is
    # where the reason is.
    if not resp.ok:
        raise RuntimeError(f"HTTP {resp.status_code} for {resp.url}: {resp.text[:500]}")
    return resp.json()

def poll(stage: str, task_id: str) -> dict:
    deadline = time.monotonic() + TASK_DEADLINE
    delay = 5
    while True:
        if time.monotonic() > deadline:
            raise TimeoutError(f"gave up waiting for {stage} {task_id}")
        data = check(requests.get(f"{BASE}/{stage}/{task_id}",
                                  headers=HEADERS, timeout=REQUEST_TIMEOUT))
        print(f"{stage}: {data['status']} ({data['progress']}%)")
        if data["status"] == "SUCCEEDED":
            return data
        if data["status"] in ("FAILED", "CANCELED"):
            raise RuntimeError(
                f"{stage} task {task_id} ended as {data['status']}: {data['task_error']}")
        time.sleep(delay)
        delay = min(delay * 2, 30)

# 1. Create the prototype task
prototype_id = check(requests.post(
    f"{BASE}/prototype", headers=HEADERS,
    json={"image_url": source_image()}, timeout=REQUEST_TIMEOUT,
))["result"]

# 2. Wait for the design render
prototype = poll("prototype", prototype_id)

# 3. Pick a candidate (first one here; show image_urls to a user in production)
candidate_id = prototype["candidate_ids"][0]

# 4. Create the build task
build_id = check(requests.post(
    f"{BASE}/build", headers=HEADERS,
    json={"input_task_id": prototype_id, "candidate_id": candidate_id},
    timeout=REQUEST_TIMEOUT,
))["result"]

# 5. Wait for the model (a build usually takes 3-7 minutes)
build = poll("build", build_id)

# 6. Download the artifacts. These are signed URLs: no Authorization header,
#    and they stay valid for 3 days after the task finishes.
for key, filename in (("glb", "keycap.glb"), ("obj_zip", "keycap-obj.zip")):
    with requests.get(build["model_urls"][key], stream=True, timeout=(60, 900)) as dl:
        if not dl.ok:
            raise RuntimeError(f"download {key} failed: HTTP {dl.status_code}")
        with open(filename, "wb") as f:
            for chunk in dl.iter_content(chunk_size=1 << 20):
                f.write(chunk)
    print(f"Saved {filename}")
```
