API quickstart
Muke Labs exposes a task-based REST API at https://api.mukelabs.com. You submit a generation task, receive a job id, and poll (or resolve) until the job reaches a terminal status. All request and response bodies are JSON.
Authentication
API keys are issued in the developer portal at https://portal.muke.name. Send the key as a Bearer token on every request. The full secret is shown once at creation — after that the portal lists only its prefix.
Every key created in the portal carries the same fixed scope set — scopes are not selectable per key. Keys can optionally be created with an expiry timestamp and revoked at any time.
- Webhook management uses dedicated scopes (webhook:endpoint:write, webhook:delivery:read, webhook:delivery:replay) that are provisioned separately — self-serve portal keys do not carry them. Manage webhook endpoints in the portal; see Webhooks below.
Authorization: Bearer <your-api-key>| Scope | Allows | Endpoints |
|---|---|---|
| generation:write | Submit, resolve, and cancel tasks; manage output assets | POST /v1/generations · POST /v1/generations/resolve · POST /v1/generations/{id}/cancel · asset lifecycle endpoints |
| generation:read | Poll job status, replay text streams, read the model catalog | GET /v1/generations/{id} · GET /v1/generations/{id}/text-stream · GET /v1/catalog · GET /v1/catalog/status |
| usage:read | Usage reporting | GET /v1/usage |
Create a generation task
Submit POST /v1/generations with a JSON body. The API answers 202 Accepted with the job id; generation runs asynchronously.
Appending ?wait=true asks the gateway to hold the request and poll briefly on your behalf for tools that support synchronous wait. When the job reaches a terminal status in time, the response is the full Job object instead of a 202 acceptance.
curl -X POST https://api.mukelabs.com/v1/generations \
-H "Authorization: Bearer $MUKE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"toolId": "seedream-5-pro",
"inputMode": "text-to-image",
"prompt": "a lantern-lit night market, cinematic",
"params": { "aspect_ratio": "16:9" },
"references": [],
"idempotencyKey": "order-42-image-1",
"subjectId": "user_123"
}'{
"jobId": "job_7f3a…",
"run": { "id": "run_01…", "ordinal": 1 },
"acceptedAt": "2026-01-01T12:00:00.000Z",
"policy": { "profileCode": "default", "version": 1 },
"effectiveRetention": { "kind": "ttl", "ttlDays": 30 },
"deduplicated": false
}| Field | Type | Required | Notes |
|---|---|---|---|
| toolId | string | yes | Model identifier, e.g. seedream-5-pro. See the models page or GET /v1/catalog. |
| inputMode | string | yes | e.g. text-to-image, image-to-video, chat, text-to-speech. Must be one of the model's allowed input modes. |
| prompt | string | yes | May be an empty string where the model does not need one. |
| params | object | yes | Model parameters (aspect ratio, duration, voice…). Must be a JSON object — pass {} when none apply. |
| references | array | yes | Reference media URLs. Pass [] for text-only modes. Each entry: {url, role?, ordinal?, kind?, expectedContentType?, expectedSha256?}; url must be absolute HTTPS. |
| idempotencyKey | string | yes | ≤240 chars, no whitespace. Unique per logical task. |
| subjectId | string | yes | Your end-user identifier. Quotas and policies are enforced per subject. |
| variantId | string | no | Model variant where a model exposes several. |
| messages | array | no | Chat context, only for inputMode=chat. Roles: system, user, assistant. |
| stream | boolean | no | Request incremental text output where supported (chat only). |
| retention | object|string|null | no | {ttlDays: n} or "permanent" — output retention override within policy limits. |
| webhookEndpointId | string | no | Deliver the terminal event to a registered webhook endpoint. |
| subjectPolicyVersion | integer | no | Assert the policy version you last observed for the subject. |
Poll for the result
GET /v1/generations/{jobId} returns the job. Poll until status is terminal: succeeded, failed, or cancelled. A suggested starting point is one request every 2–5 seconds.
Succeeded jobs expose outputs — each with a signed url, contentType, expiresAt, and an assetHandle for lifecycle operations.
- status: queued → running → succeeded | failed | cancelled
- POST /v1/generations/{jobId}/cancel cancels a queued job (returns JOB_ALREADY_STARTED once running).
- Output urls are signed and expire — download results to your own storage before expiresAt.
{
"id": "job_7f3a…",
"status": "succeeded",
"outputs": [
{
"assetHandle": "asset_…",
"url": "https://…/signed-download-url",
"contentType": "image/png",
"expiresAt": "2026-01-08T12:00:00.000Z"
}
],
"usage": { "billableUnits": 1, "processingMs": 8200 },
"createdAt": "2026-01-01T12:00:00.000Z",
"completedAt": "2026-01-01T12:00:08.000Z"
}Model catalog
GET /v1/catalog returns the models enabled for your key with their allowed input modes. GET /v1/catalog/status reports per-model and per-input-mode availability.
{
"scope": "tenant_base",
"revision": "rev_…",
"tools": [
{
"toolId": "seedream-5-pro",
"category": "image",
"allowedInputModes": ["text-to-image", "image-to-image"]
}
]
}Usage reporting
GET /v1/usage summarizes your account's jobs and billable units over a time range (max 366 days). Requires the usage:read scope.
- groupBy: tool or subject
- from / to: ISO 8601 timestamps with timezone; range must be positive and ≤ 366 days
curl "https://api.mukelabs.com/v1/usage?from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z&groupBy=tool" \
-H "Authorization: Bearer $MUKE_API_KEY"
# -> {
# "from": "…", "to": "…", "groupBy": "tool",
# "totals": { "jobs": 12, "billableUnits": 12 },
# "groups": [{ "key": "seedream-5-pro", "jobs": 12, "billableUnits": 12 }],
# "truncated": false
# }Idempotency and retries
Every submission requires an idempotencyKey. Repeating the same key with the same request body replays the original 202 acceptance (deduplicated: true) — safe to retry after timeouts or network failures. Reusing the key with a different request returns 409 IDEMPOTENCY_CONFLICT.
POST /v1/generations/resolve recovers the acceptance for an earlier request without creating a duplicate task. The preferred form is to resend the original generation request body — the gateway parses and normalizes it exactly as at submission time, then compares the stored request hash. The endpoint answers {status: "accepted", acceptance: {…}} or {status: "fenced"} when no matching acceptance exists. A fenced answer is permanent for that key: it records a negative fence so a late-arriving original POST can no longer create work, and any further attempt under the same key returns 409 IDEMPOTENCY_CONFLICT. Resubmit under a new idempotencyKey instead.
Alternatively send exactly {idempotencyKey, requestHash} — no other fields — where requestHash is the SHA-256 hex of the canonical JSON of the request. Canonicalization: object keys sorted lexicographically at every level, properties with an undefined value dropped, array order preserved, numbers must be finite, nesting depth at most 32. Compute the hash over the parsed request object, not over raw JSON text.
The hash is compared against the request as the gateway normalizes it, which diverges from the raw body in two cases: references[].expectedContentType is lowercased (send it lowercase), and for the seedance-2 model family (seedance-2-0, seedance-2-standard, seedance-2-fast) the gateway injects a server-owned parameter derived from inputMode before hashing — for those models always resolve with the original request body, not a self-computed hash. A hash that does not match the stored request returns 409 IDEMPOTENCY_CONFLICT.
import { createHash } from "node:crypto";
function canonicalJson(value, depth = 1, ancestors = new Set()) {
if (value === null || typeof value === "boolean" || typeof value === "string") {
return JSON.stringify(value);
}
if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
if (value && typeof value === "object") {
if (depth > 32) throw new Error("Generation request exceeds the maximum JSON depth of 32.");
if (ancestors.has(value)) throw new Error("Generation request contains a cyclic value.");
ancestors.add(value);
try {
if (Array.isArray(value)) {
return "[" + value.map((child) => canonicalJson(child, depth + 1, ancestors)).join(",") + "]";
}
return "{" + Object.entries(value)
.filter(([, child]) => child !== undefined)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([key, child]) => JSON.stringify(key) + ":" + canonicalJson(child, depth + 1, ancestors))
.join(",") + "}";
} finally {
ancestors.delete(value);
}
}
throw new Error("Generation request contains a non-JSON value.");
}
const requestHash = createHash("sha256")
.update(canonicalJson(originalRequest), "utf8")
.digest("hex");Errors
Errors return { "error": { "code", "message", "retryAfterSeconds?" } } with an appropriate HTTP status. Rate-limit and quota errors may include retryAfterSeconds.
| HTTP | Code | Meaning |
|---|---|---|
| 400 | INVALID_JSON / INVALID_REQUEST / VALIDATION_ERROR | Malformed body or a field failed validation |
| 401 | INVALID_KEY | Missing, invalid, or inactive API key |
| 403 | INSUFFICIENT_SCOPE / TOOL_NOT_ALLOWED_BY_POLICY | Key lacks the scope, or the subject policy forbids the tool |
| 404 | JOB_NOT_FOUND / NOT_FOUND / WEBHOOK_ENDPOINT_NOT_FOUND | Unknown job, route, or webhook endpoint |
| 409 | IDEMPOTENCY_CONFLICT | The idempotency key was already used for a different request |
| 409 | JOB_ALREADY_STARTED | Only a queued generation can be cancelled |
| 409 | WEBHOOK_ENDPOINT_INVALID_STATE / WEBHOOK_ENDPOINT_LIMIT_REACHED / SUBJECT_POLICY_VERSION_MISMATCH | Webhook endpoint not active or subscribed, at the endpoint limit, or the asserted subject policy version changed |
| 413 | PAYLOAD_TOO_LARGE | Request body exceeds the limit |
| 415 | UNSUPPORTED_MEDIA_TYPE | Content-Type must be application/json |
| 422 | CONTENT_REJECTED / PROMPT_TOO_LONG / MEDIA_DURATION_INVALID / REFERENCE_COUNT_INVALID / REFERENCE_MEDIA_INVALID / CAPABILITY_REQUIRED / REQUEST_COST_LIMIT_REACHED | The request is well-formed but rejected by model or policy constraints |
| 429 | SUBJECT_LIMIT_REACHED / TOO_MANY_ACTIVE_GENERATIONS / DAILY_LIMIT_REACHED / TENANT_LIMIT_REACHED | Quota or concurrency limit reached — back off and retry |
| 500 | INTERNAL_ERROR | The generation could not be completed; failed tasks refund automatically |
| 503 | GATEWAY_EXECUTION_DISABLED / CATALOG_UNAVAILABLE | Service temporarily unavailable — retry with backoff |
Webhooks (optional)
Instead of polling, register a webhook endpoint and pass webhookEndpointId on submission. When the job reaches a terminal status the gateway POSTs a signed JSON event to your endpoint URL. Up to 10 pending-or-active endpoints are allowed per account.
Endpoints are registered and managed in the developer portal. Registration returns a signingSecret once — store it immediately. Activation performs a handshake: Muke Labs sends a signed POST with body {"event":"webhook.challenge","challenge":"<id>"} and your endpoint must answer 2xx with a JSON body echoing the same challenge value back ({"challenge":"<id>"}).
Submissions can only target an endpoint that is active and subscribed to at least one terminal event — otherwise the submission is rejected. The same management surface exists on the REST API under dedicated scopes that self-serve keys do not carry: GET/POST /v1/webhook-endpoints, POST /v1/webhook-endpoints/{id}/activate, POST /v1/webhook-endpoints/{id}/secret/rotate, PUT /v1/webhook-endpoints/{id}/subscriptions, DELETE /v1/webhook-endpoints/{id} (scope webhook:endpoint:write); GET /v1/webhook-deliveries and GET /v1/webhook-deliveries/{id} (webhook:delivery:read); POST /v1/webhook-deliveries/{id}/replay (webhook:delivery:replay).
Deliveries that do not receive a 2xx response are retried up to 4 more times (5 attempts total) at roughly 1 minute, 5 minutes, 30 minutes, and 2 hours, with ±20% jitter; a Retry-After header on a 429 or 503 response is honored up to one hour. After the last attempt the delivery is marked exhausted and can be replayed from the portal. Respond 2xx quickly and process asynchronously.
Verify every delivery before trusting it: recompute the signature over the exact received body bytes (not re-serialized JSON), reject timestamps older than about 5 minutes, and deduplicate on x-muke-event-id. Keep the previous signing secret accepted during rotation — deliveries carry the secret version they were signed with.
- Event types: generation.succeeded, generation.failed, generation.cancelled
- data.job is the same Job object returned by GET /v1/generations/{id} at a terminal status, including outputs or error
{
"id": "evt_…",
"version": "2026-07-22",
"type": "generation.succeeded",
"occurredAt": "2026-01-01T12:00:08.000Z",
"tenantId": "tenant_…",
"data": {
"run": { "id": "run_01…", "ordinal": 1 },
"job": {
"id": "job_7f3a…",
"status": "succeeded",
"outputs": [
{
"assetHandle": "asset_…",
"url": "https://…/signed-download-url",
"contentType": "image/png",
"expiresAt": "2026-01-08T12:00:00.000Z"
}
],
"usage": { "billableUnits": 1, "processingMs": 8200 },
"createdAt": "2026-01-01T12:00:00.000Z",
"completedAt": "2026-01-01T12:00:08.000Z"
}
}
}import { createHmac, timingSafeEqual } from "node:crypto";
// signed = base64url( HMAC-SHA256(secret, "<version>.<timestamp>.<eventId>." || rawBody) )
function signWebhook({ rawBody, eventId, timestamp, version, secret }) {
return createHmac("sha256", secret)
.update(`${version}.${timestamp}.${eventId}.`, "utf8")
.update(rawBody) // exact request bytes — verify before parsing JSON
.digest("base64url");
}
function verifyWebhook({ rawBody, headers, secretsByVersion, now = new Date(), maxAgeSeconds = 300 }) {
const eventId = headers["x-muke-event-id"];
const timestamp = headers["x-muke-timestamp"];
const version = Number(headers["x-muke-signature-version"]);
const signature = headers["x-muke-signature"];
const eventTime = Date.parse(typeof timestamp === "string" ? timestamp : "");
if (!Number.isFinite(eventTime) || Math.abs(now.getTime() - eventTime) > maxAgeSeconds * 1000) {
return false; // outside the replay window
}
const secret = secretsByVersion[version]; // keep current + previous secret during rotation
if (!secret || typeof signature !== "string") return false;
const expected = Buffer.from(signWebhook({ rawBody, eventId, timestamp, version, secret }), "utf8");
const actual = Buffer.from(signature, "utf8");
return expected.length === actual.length && timingSafeEqual(expected, actual);
}| Header | Value |
|---|---|
| content-type | application/json |
| x-muke-event-id | Unique event identifier — use for deduplication |
| x-muke-timestamp | ISO-8601 dispatch time, e.g. 2026-01-01T12:00:08.123Z |
| x-muke-signature-version | Signing secret version (integer, starts at 1; increments on rotation) |
| x-muke-signature | base64url HMAC-SHA256 over "{version}.{timestamp}.{eventId}." + raw body bytes |