Technical tutorial
Build an animated GLB with the Meshy API
This is a focused, reproducible walkthrough of the API pipeline behind Prompt-to-Playable Character. By the end you will have called Meshy directly with curl, understood why each request looks the way it does, and be able to trace the same flow through the app's TypeScript client and its durable Workflow SDK run. You need a Meshy API key.
1. Auth and key safety
Every Meshy call is a Bearer-token request over HTTPS:
Authorization: Bearer <MESHY_API_KEY>
Two rules, non-negotiable if you're building anything you'll deploy:
- Never call Meshy from browser code. There is no CORS grant for this, and putting the key in client JS means it ships in the bundle.
- Never prefix the variable with
NEXT_PUBLIC_. In this repo the key is read once, insideserver/meshy/client.ts, by a module gated by theserver-onlypackage — importing that module from a client component is a build-time error, not a runtime leak you find later. A visitor-supplied key follows the same rule:POST /api/credentialsvalidates it server-side against Meshy's Balance endpoint and hands the browser back only an opaque,HttpOnlyowner-token cookie, never the key itself.
Set the key as a plain server environment variable:
export MESHY_API_KEY="msy_your_key_here"
export MESHY_API_BASE_URL="https://api.meshy.ai"
2. Architecture in one paragraph
Live generation runs as a single durable Vercel Workflow SDK function, generateCharacterWorkflow (src/workflows/character-generation.ts, marked "use workflow"). Every Meshy call and every progress emission inside it is its own "use step" function, persisted as it completes. A typed Meshy client (src/server/meshy/client.ts) knows the wire format; route handlers under /api/orchestrations/* start the workflow, resume its approval webhook, and read its published snapshots. Everything below maps onto those pieces.
3. Two concepts, in parallel
The workflow starts two text-to-image tasks at once — direction A (faithful to the prompt) and direction B (a bolder variation) — so there's something to choose between before any geometry is spent:
curl -X POST https://api.meshy.ai/openapi/v1/text-to-image \
-H "Authorization: Bearer $MESHY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"ai_model": "nano-banana-2",
"prompt": "A nimble desert ranger with layered leather armor, a teal scarf, clear human proportions, and a neutral A-pose, stylized for an indie action game.\n\nFaithful direction: practical materials, clean silhouette, restrained details. Full-body single humanoid character concept, centered and isolated on a plain neutral background, entire body visible, clean A-pose, arms and legs clearly separated, no text, no frame, no scenery. Designed as production reference for a riggable 3D game asset.",
"aspect_ratio": "1:1",
"pose_mode": "a-pose"
}'
The equivalent TypeScript, from src/server/meshy/client.ts:
export function buildConceptRequest(
input: ConceptTaskInput,
): Record<string, unknown> {
const direction =
input.direction === "A"
? "Faithful direction: practical materials, clean silhouette, restrained details."
: "Alternate direction: bolder costume shapes and material contrast while preserving the core character identity.";
return {
ai_model: "nano-banana-2",
prompt: `${input.prompt}\n\n${direction} Full-body single humanoid character concept, centered and isolated on a plain neutral background, entire body visible, clean A-pose, arms and legs clearly separated, no text, no frame, no scenery. Designed as production reference for a riggable 3D game asset.`,
aspect_ratio: "1:1",
pose_mode: "a-pose",
};
}
pose_mode: "a-pose" in the prompt and request body isn't a stylistic choice — the base-geometry step downstream needs separated limbs to detect a clean humanoid rig later. The response for each is a task ID:
{ "result": "0193a340-...task-id...-abcd" }
Poll both with GET /openapi/v1/text-to-image/<task-id> until each reaches SUCCEEDED and has an image_urls[0].
4. Approve one — the gate before geometry spend
This is the step a plain endpoint wrapper doesn't have. Everything before this point is two cheap images; everything after it spends real geometry credits. The workflow opens a webhook and pauses at AWAITING_CONCEPT once both concepts are ready. In the app, POST /api/orchestrations/[id]/concept with { "conceptId": "concept-a" } resumes it:
curl -X POST https://your-deployment/api/orchestrations/wrun_.../concept \
-H "Content-Type: application/json" \
-d '{ "conceptId": "concept-a" }'
If you're driving Meshy directly rather than through this app, the equivalent decision is simply: only pass the task ID of the image you actually want forward into the next call. The rejected direction is never converted into geometry.
5. Base geometry: image-to-3d with input_task_id, and why the profile matters
The approved concept's task ID becomes input_task_id on image-to-3d. Which fields go alongside it depends on the profile:
# fast profile
curl -X POST https://api.meshy.ai/openapi/v1/image-to-3d \
-H "Authorization: Bearer $MESHY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"input_task_id": "<approved-concept-task-id>",
"model_type": "smart-topology",
"ai_model": "meshy-t2",
"should_texture": true,
"enable_pbr": true,
"texture_resolution": "2k",
"target_polycount": 15000,
"pose_mode": "a-pose",
"target_formats": ["glb"]
}'
# quality profile
curl -X POST https://api.meshy.ai/openapi/v1/image-to-3d \
-H "Authorization: Bearer $MESHY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"input_task_id": "<approved-concept-task-id>",
"model_type": "standard",
"ai_model": "meshy-6",
"should_texture": true,
"enable_pbr": true,
"texture_resolution": "4k",
"should_remesh": true,
"topology": "quad",
"target_polycount": 50000,
"pose_mode": "a-pose",
"image_enhancement": true,
"remove_lighting": true,
"target_formats": ["glb"]
}'
fast targets meshy-t2 with smart-topology, 2K PBR textures, and a 15K polycount ceiling—quick turnaround for iteration. It deliberately omits image_enhancement and remove_lighting, because Meshy rejects those options for smart-topology. quality targets meshy-6 with standard topology remeshed to quad, 4K PBR textures, a 50K polycount, and both source-cleanup options. Both profiles set pose_mode: "a-pose" and request GLB output.
The app's request builder, buildPreviewRequest(conceptTaskId, profile) in src/server/meshy/client.ts, is the exact function generating both bodies above.
6. Rig: bind a skeleton to the base geometry
Rigging is a separate endpoint, and it takes the geometry task's ID as its input:
curl -X POST https://api.meshy.ai/openapi/v1/rigging \
-H "Authorization: Bearer $MESHY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"input_task_id": "<image-to-3d-task-id>",
"height_meters": 1.7
}'
export function buildRiggingRequest(
geometryTaskId: string,
): Record<string, unknown> {
return { input_task_id: geometryTaskId, height_meters: 1.7 };
}
This step is where a non-humanoid or ambiguous mesh gets rejected — Meshy returns 422 if it can't recognize clear bipedal anatomy. If that happens, the textured GLB from step 5 is still yours; the run doesn't need to restart to keep it, you just can't animate it through this pipeline.
7. Prepare Idle, Walk, and Run
A successful Meshy rig already includes Walk and Run. The app then makes one animation-library request for the missing Idle action, producing the three-motion set shown in the player. The curated options live in src/config/animations.ts: combat-idle → 0, walk → 30, and run → 14. Walk is the default motion in the viewer.
curl -X POST https://api.meshy.ai/openapi/v1/animations \
-H "Authorization: Bearer $MESHY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"rig_task_id": "<rig-task-id>",
"action_id": 0
}'
export function buildAnimationRequest(
rigTaskId: string,
actionId: number,
): Record<string, unknown> {
return { rig_task_id: rigTaskId, action_id: actionId };
}
The repository ships bun run verify:actions (scripts/verify-animation-actions.ts) to resolve the curated set against Meshy's animation-library reference. Run it with your own key before a release that depends on fixed IDs, because an upstream action catalog can evolve independently of the application.
8. Poll to a terminal state without leaking the key
All four task families expose the same status shape:
curl https://api.meshy.ai/openapi/v1/image-to-3d/<task-id> \
-H "Authorization: Bearer $MESHY_API_KEY"
Terminal states are SUCCEEDED, FAILED, and CANCELED. A minimal poll loop:
while true; do
status=$(curl -s https://api.meshy.ai/openapi/v1/image-to-3d/$TASK_ID \
-H "Authorization: Bearer $MESHY_API_KEY" | jq -r .status)
echo "$status"
[[ "$status" == "SUCCEEDED" || "$status" == "FAILED" || "$status" == "CANCELED" ]] && break
sleep 3
done
The browser never calls Meshy directly. A visitor submits a key to POST /api/credentials on the same origin; the server validates it through Meshy's balance endpoint, then keeps it out of JavaScript-accessible storage. The browser only polls GET /api/orchestrations/[id]. For a workflow run (id starting wrun_), that route resolves the run through workflow/api's getRun() and reads the latest snapshot from its stream; the response strips ownerKey and approvalToken before it reaches JSON. Inside the workflow, each stage polls with sleep("2s") (Fast) or sleep("3s") (Detail), capped at 180 attempts per Meshy stage and 120 for the two concept tasks.
9. Download before the signed URL expires
A successful task's response carries a signed .glb URL — the exact field varies by endpoint family, which is why normalize.ts's extractGlbUrl() checks several documented shapes rather than assuming one. Download it immediately:
curl -L -o character-animated.glb "<signed-glb-url-from-task-response>"
This project never asks the client to supply a raw provider URL: GET /api/assets/download takes a session ID and an asset name (animated, rigged, refined, preview, or fbx), looks up the current URL server-side, and refuses to stream from outside an explicit host allow list. Meshy returns expires_at on completed tasks, which the app trusts directly; when that is absent, the app treats the URL as stale after ASSET_URL_TTL_MS—71 hours, leaving a one-hour safety margin inside the documented three-day non-enterprise retention window.
Troubleshooting: real failure modes
This maps directly to src/server/meshy/errors.ts, the single place this project normalizes every failure Meshy or the network can produce.
| HTTP / condition | Cause | Fix |
|---|---|---|
| 401 / 403 | Missing or invalid API key | MESHY_UNAUTHORIZED — the app disables live generation entirely and points at the showcase rather than retrying an auth failure. Check MESHY_API_KEY, or re-enter a visitor key beginning with msy_. |
| 402 | Insufficient credits | MESHY_INSUFFICIENT_CREDITS — not retryable automatically. Top up, or use the showcase character, which needs no live credits. |
| 422 | Meshy could not process the input — most commonly rigging rejecting an unclear or non-bipedal mesh | MESHY_UNPROCESSABLE. Retry with a prompt that has clearly separated limbs and a neutral A-pose. The textured geometry from the prior stage is preserved either way. |
| 429 | Rate limited | MESHY_RATE_LIMITED. The client already retries GET requests automatically with exponential backoff (up to 3 attempts, honoring Retry-After). Task-creation POSTs are not auto-retried, since Meshy documents no idempotency key and a blind retry risks a double charge. |
| 500 / 502 / 503 / 504 | Meshy-side server error | MESHY_SERVER_ERROR, also auto-retried for GET. If it persists on a POST, retry the specific stage once Meshy recovers — upstream task IDs are untouched. |
Task reaches FAILED or CANCELED | The task itself failed after being accepted, distinct from an HTTP error on the create/poll call | MESHY_TASK_FAILED / MESHY_TASK_CANCELED. Read the task's own error payload; retry from the failed stage's upstream task ID rather than restarting the whole run. |
| Signed URL 404s or times out on download | The URL has expired | ASSET_URL_EXPIRED. Re-fetch the task object by ID for a fresh signed URL. |
| Request times out client-side | Network stall, not a Meshy response | The client aborts after 30 seconds (REQUEST_TIMEOUT_MS) and treats that as a 504-equivalent, retryable failure. |
| Prompt rejected before any Meshy call | Local validation caught a non-character or malformed prompt | INVALID_PROMPT. Describe one humanoid character in 10–600 characters; presets in src/config/prompts.ts are written for the rigging constraints. |
Fork it
Everything above is one Meshy account and a handful of request builders away from running in your own project. Clone the repository, set MESHY_API_KEY for private development or use the public bring-your-own-key gate, run bun run dev, and watch the same requests through the developer panel—including the approval webhooks you trigger against a local run.