Async Jobs

All generation requests are async. The POST endpoint returns immediately with a job_id; the actual generation runs in the background. Once done, you retrieve the result in one of three ways:

  1. PollingGET /v1/jobs/{job_id} until status reaches a terminal state
  2. Webhook — provide callback_url on the POST; Makistry pushes the result to your endpoint when done
  3. Dashboard — download the file manually at api.makistry.ai

The Makistry dashboard (api.makistry.ai) gives you a visual interface over the same data the API exposes: view job status and history across all your keys, download output files directly, create and revoke API keys, rotate signing secrets, and monitor credit usage and billing. It's a useful fallback if a webhook fails or you need to retrieve a file without writing code.


Job Status

queued → processing → success
                   ↘ failed
Status Meaning
queued Job accepted and waiting for a worker
processing Pipeline is actively running
success Complete — download_url is available
failed Irrecoverable error — error_code and error_message are set

Typical processing times:

design_mode Typical duration
standard 30 s – 2 min
pro 1 – 5 min
assembly 2 – 15 min

Set your polling timeout to at least 20 minutes for assembly jobs.


Polling — GET /v1/jobs/{job_id}

GET /v1/jobs/{job_id}
Authorization: Bearer mk_pub_...

Returns the current JobOut snapshot. The download_url is re-signed on every call — always use the URL from the most recent response.

Recommended polling interval: 5 s for the first 30 s, then 15 s until terminal state.

Python poll loop:

import time, httpx

def poll_until_done(job_id: str, timeout_s: int = 1200) -> dict:
    deadline = time.time() + timeout_s
    interval = 5
    while time.time() < deadline:
        resp = httpx.get(
            f"https://api.makistry.ai/v1/jobs/{job_id}",
            headers={"Authorization": "Bearer mk_pub_YOUR_KEY"},
        )
        resp.raise_for_status()
        job = resp.json()
        if job["status"] == "success":
            return job
        if job["status"] == "failed":
            raise RuntimeError(f"{job['error_code']}: {job['error_message']}")
        time.sleep(interval)
        interval = min(interval * 1.5, 15)  # ramp to 15s
    raise TimeoutError("Job did not finish within timeout")

JobOut Fields

Field Populated when Description
job_id always Unique identifier, e.g. "pub_a1b2c3d4e5f6"
status always queued | processing | success | failed
endpoint always "text_to_cad" or "image_to_cad"
design_mode always As submitted
output_format always As submitted
created_at always ISO 8601 UTC
updated_at always ISO 8601 UTC
download_url success Signed GCS URL; re-signed on every GET. Valid for 30 days from job creation.
expires_at success ISO 8601 expiry of the output file
brainstorm success Design analysis object — see output-formats.md
tokens_used success Total billable tokens consumed
latency_ms success Wall-clock pipeline duration in milliseconds
error_code failed Machine-readable error — see errors.md
error_message failed Human-readable detail
metadata always Echo of the metadata you submitted

File expiry: Output files expire 30 days after job creation. Fetching a job after expiry returns 410 output_expired. Re-submit the job to generate a fresh file.


Listing Jobs — GET /v1/jobs

GET /v1/jobs
Authorization: Bearer mk_pub_...

Query Parameters

Parameter Default Description
status Filter by status: queued, processing, success, failed
endpoint Filter by endpoint: text_to_cad, image_to_cad
limit 20 Results per page. Range: 1–100.
cursor Pagination cursor from next_cursor in a previous response

Results are ordered newest first.

Pagination

When there are more results, the response includes next_cursor — an ISO 8601 timestamp. Pass it as cursor in the next request:

cursor = None
while True:
    params = {"limit": 50}
    if cursor:
        params["cursor"] = cursor
    resp = httpx.get("https://api.makistry.ai/v1/jobs",
                     headers=headers, params=params)
    data = resp.json()
    for job in data["jobs"]:
        print(job["job_id"], job["status"])
    cursor = data.get("next_cursor")
    if not cursor:
        break

Webhooks

Provide a callback_url on the POST request to receive a push notification when the job finishes (success or failure). Your endpoint must be reachable over HTTPS.

Delivery

Makistry sends a POST to your callback_url with the full JobOut payload as the body.

Your endpoint must respond with any 2xx HTTP status to acknowledge receipt. If Makistry receives a non-2xx response (or no response), it retries.

Retry policy: Up to 5 attempts with exponential backoff: 2 s → 4 s → 8 s → 16 s → 32 s.

Retried Not retried
5xx responses 4xx (except 408, 425, 429)
408, 425, 429 3xx
Network errors

Request Headers

X-Makistry-Signature: sha256=<hex>
X-Makistry-Timestamp: <unix seconds>
Content-Type: application/json
User-Agent: Makistry-Webhook/1.0

Verifying Signatures

Always verify webhook signatures before processing. The signature proves the request came from Makistry and was not tampered with.

Algorithm: HMAC-SHA256(signing_secret, "{timestamp}.{raw_body}")

import hashlib
import hmac
import time

def verify_makistry_webhook(
    body: bytes,
    sig_header: str,
    ts_header: str,
    signing_secret: str,
) -> bool:
    # Replay protection: reject webhooks older than 5 minutes
    age = abs(time.time() - int(ts_header))
    if age > 300:
        return False

    sig = sig_header.removeprefix("sha256=")
    msg = f"{ts_header}.".encode() + body
    expected = hmac.new(signing_secret.encode(), msg, hashlib.sha256).hexdigest()

    # Use constant-time comparison to prevent timing attacks
    return hmac.compare_digest(expected, sig)

Node.js:

import crypto from "crypto";

function verifyMakistryWebhook(body, sigHeader, tsHeader, signingSecret) {
  // Replay protection
  if (Math.abs(Date.now() / 1000 - parseInt(tsHeader)) > 300) return false;

  const sig = sigHeader.replace("sha256=", "");
  const msg = `${tsHeader}.${body}`;
  const expected = crypto.createHmac("sha256", signingSecret).update(msg).digest("hex");

  // timingSafeEqual throws if buffers differ in length — catch and return false
  try {
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
  } catch {
    return false; // length mismatch = invalid signature
  }
}

Example Webhook Payload (success)

{
  "job_id": "pub_a1b2c3d4e5f6",
  "status": "success",
  "endpoint": "text_to_cad",
  "design_mode": "standard",
  "output_format": "STEP",
  "created_at": "2026-05-30T10:00:00.000000+00:00",
  "updated_at": "2026-05-30T10:01:15.000000+00:00",
  "download_url": "https://storage.googleapis.com/...",
  "expires_at": "2026-06-29T10:00:00.000000+00:00",
  "brainstorm": {
    "project_name": "Lens Cap — 60mm OD",
    "key_features": ["snap-fit retention ring", "52mm inner lip"],
    "design_components": ["cap body", "retention ring"],
    "optimal_geometry": "Thin-wall cylindrical extrusion with inset radial lip",
    "material_recommendations": "PLA or ABS for FDM",
    "suggestions": ["Add a tether boss", "Chamfer the outer rim"]
  },
  "tokens_used": 8420,
  "latency_ms": 74300,
  "metadata": {}
}

Example Webhook Payload (failure)

{
  "job_id": "pub_a1b2c3d4e5f6",
  "status": "failed",
  "endpoint": "text_to_cad",
  "design_mode": "standard",
  "error_code": "geometry_validation_failed",
  "error_message": "Contract gate rejected: contradictory dimension constraints detected",
  "metadata": {}
}