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.
Header
Authorization: Bearer <your-api-key>
ScopeAllowsEndpoints
generation:writeSubmit, resolve, and cancel tasks; manage output assetsPOST /v1/generations · POST /v1/generations/resolve · POST /v1/generations/{id}/cancel · asset lifecycle endpoints
generation:readPoll job status, replay text streams, read the model catalogGET /v1/generations/{id} · GET /v1/generations/{id}/text-stream · GET /v1/catalog · GET /v1/catalog/status
usage:readUsage reportingGET /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.

Request
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"
  }'
Response 202
{
  "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
}
FieldTypeRequiredNotes
toolIdstringyesModel identifier, e.g. seedream-5-pro. See the models page or GET /v1/catalog.
inputModestringyese.g. text-to-image, image-to-video, chat, text-to-speech. Must be one of the model's allowed input modes.
promptstringyesMay be an empty string where the model does not need one.
paramsobjectyesModel parameters (aspect ratio, duration, voice…). Must be a JSON object — pass {} when none apply.
referencesarrayyesReference media URLs. Pass [] for text-only modes. Each entry: {url, role?, ordinal?, kind?, expectedContentType?, expectedSha256?}; url must be absolute HTTPS.
idempotencyKeystringyes≤240 chars, no whitespace. Unique per logical task.
subjectIdstringyesYour end-user identifier. Quotas and policies are enforced per subject.
variantIdstringnoModel variant where a model exposes several.
messagesarraynoChat context, only for inputMode=chat. Roles: system, user, assistant.
streambooleannoRequest incremental text output where supported (chat only).
retentionobject|string|nullno{ttlDays: n} or "permanent" — output retention override within policy limits.
webhookEndpointIdstringnoDeliver the terminal event to a registered webhook endpoint.
subjectPolicyVersionintegernoAssert 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.
Response 200
{
  "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.

Response 200
{
  "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
Request
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.

requestHash (Node.js)
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.

HTTPCodeMeaning
400INVALID_JSON / INVALID_REQUEST / VALIDATION_ERRORMalformed body or a field failed validation
401INVALID_KEYMissing, invalid, or inactive API key
403INSUFFICIENT_SCOPE / TOOL_NOT_ALLOWED_BY_POLICYKey lacks the scope, or the subject policy forbids the tool
404JOB_NOT_FOUND / NOT_FOUND / WEBHOOK_ENDPOINT_NOT_FOUNDUnknown job, route, or webhook endpoint
409IDEMPOTENCY_CONFLICTThe idempotency key was already used for a different request
409JOB_ALREADY_STARTEDOnly a queued generation can be cancelled
409WEBHOOK_ENDPOINT_INVALID_STATE / WEBHOOK_ENDPOINT_LIMIT_REACHED / SUBJECT_POLICY_VERSION_MISMATCHWebhook endpoint not active or subscribed, at the endpoint limit, or the asserted subject policy version changed
413PAYLOAD_TOO_LARGERequest body exceeds the limit
415UNSUPPORTED_MEDIA_TYPEContent-Type must be application/json
422CONTENT_REJECTED / PROMPT_TOO_LONG / MEDIA_DURATION_INVALID / REFERENCE_COUNT_INVALID / REFERENCE_MEDIA_INVALID / CAPABILITY_REQUIRED / REQUEST_COST_LIMIT_REACHEDThe request is well-formed but rejected by model or policy constraints
429SUBJECT_LIMIT_REACHED / TOO_MANY_ACTIVE_GENERATIONS / DAILY_LIMIT_REACHED / TENANT_LIMIT_REACHEDQuota or concurrency limit reached — back off and retry
500INTERNAL_ERRORThe generation could not be completed; failed tasks refund automatically
503GATEWAY_EXECUTION_DISABLED / CATALOG_UNAVAILABLEService 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
Delivery payload
{
  "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"
    }
  }
}
Verify a signature (Node.js)
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);
}
HeaderValue
content-typeapplication/json
x-muke-event-idUnique event identifier — use for deduplication
x-muke-timestampISO-8601 dispatch time, e.g. 2026-01-01T12:00:08.123Z
x-muke-signature-versionSigning secret version (integer, starts at 1; increments on rotation)
x-muke-signaturebase64url HMAC-SHA256 over "{version}.{timestamp}.{eventId}." + raw body bytes