Build with the mazi API
Generate AI videos and images from your own code with the models you know from the Studio. Every generation runs on your mazi credits and lands in your library.
The API is REST over HTTPS. One endpoint starts a job for any model; the model and its input fields come from the model catalog. Jobs run asynchronously: wait for the result in the same request, poll a job, or get a signed webhook when it finishes.
- OpenAPI 3.1:
https://api.joinmazi.ai/v1/openapi.jsonfor codegen and AI agents. No login needed. - MCP: working with Claude, ChatGPT, or Cursor? Connect the mazi MCP server at
https://joinmazi.ai/app/api/mcpinstead. It uses the same mazi credits and API keys.
Authentication
Create an API key in the mazi app under Settings, API access. You see the key once, so store it in a secret manager or an environment variable. Send it as a bearer token with every request:
Authorization: Bearer mz_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Every key can do everything your account can do in the Studio and spends your mazi credits. Keys can't create or revoke other keys. That only works in the app.
A missing or invalid key returns 401 unauthorized. Before your first generation, accept the Terms of Service once in the app; until then, job and upload requests return 403 terms_required.
Quickstart
Generate an image in three steps. The examples read your key from the MAZI_API_KEY environment variable.
1. Pick a model
List the models available to your account with their modes, fields, and prices in mazi credits.
curl https://api.joinmazi.ai/v1/models \ -H "Authorization: Bearer $MAZI_API_KEY"
const res = await fetch("https://api.joinmazi.ai/v1/models", {
headers: { Authorization: `Bearer ${process.env.MAZI_API_KEY}` },
});
const { data: models } = await res.json();
console.log(models.map((m) => m.id));
import os, requests
res = requests.get(
"https://api.joinmazi.ai/v1/models",
headers={"Authorization": f"Bearer {os.environ['MAZI_API_KEY']}"},
)
print([m["id"] for m in res.json()["data"]])
2. Create a job and wait for it
Send the model id and its input. input.mode is required. With wait_seconds the request stays open until the job finishes (up to 60 seconds). Images usually finish in 10 to 30 seconds.
curl https://api.joinmazi.ai/v1/jobs \
-H "Authorization: Bearer $MAZI_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"model": "gpt-image-2.5-flare",
"input": {
"mode": "text",
"prompt": "Product photo of a ceramic coffee cup on a sunlit kitchen counter, soft shadows",
"aspectRatio": "2:3",
"resolution": "1K"
},
"wait_seconds": 60
}'
import { randomUUID } from "node:crypto";
const res = await fetch("https://api.joinmazi.ai/v1/jobs", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MAZI_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": randomUUID(),
},
body: JSON.stringify({
model: "gpt-image-2.5-flare",
input: {
mode: "text",
prompt: "Product photo of a ceramic coffee cup on a sunlit kitchen counter, soft shadows",
aspectRatio: "2:3",
resolution: "1K",
},
wait_seconds: 60,
}),
});
const job = await res.json();
if (!res.ok) throw new Error(`${job.error.code}: ${job.error.message}`);
console.log(job.status, job.credits, job.outputs[0]?.url);
import os, uuid, requests
res = requests.post(
"https://api.joinmazi.ai/v1/jobs",
headers={
"Authorization": f"Bearer {os.environ['MAZI_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"model": "gpt-image-2.5-flare",
"input": {
"mode": "text",
"prompt": "Product photo of a ceramic coffee cup on a sunlit kitchen counter, soft shadows",
"aspectRatio": "2:3",
"resolution": "1K",
},
"wait_seconds": 60,
},
timeout=90,
)
job = res.json()
res.raise_for_status()
print(job["status"], job["credits"], job["outputs"][0]["url"])
The response is a job. If it's still queued or processing when the wait is over, fetch it again with GET /v1/jobs/{job_id}?wait_seconds=60.
{
"id": "7d0c6f0e-3b0a-4f53-9a43-1f6a2c3b4d5e",
"object": "job",
"model": "gpt-image-2.5-flare",
"status": "succeeded",
"progress": 100,
"credits": 10,
"input": { "mode": "text", "prompt": "Product photo of …", "aspectRatio": "2:3", "resolution": "1K" },
"error": null,
"outputs": [{
"id": "b2a7c1d0-1e2f-4a3b-8c4d-5e6f7a8b9c0d",
"type": "image",
"mime": "image/png",
"url": "https://…",
"download_url": "https://…",
"width": 1024,
"height": 1536,
"preview_url": "https://…"
}],
"urls_expire_at": "2026-11-01T10:00:12.000Z",
"outputs_expire_at": "2027-01-29T10:00:00.000Z",
"created_at": "2026-10-31T10:00:00.000Z",
"finished_at": "2026-10-31T10:00:12.000Z"
}3. Download the result
Output links are signed and valid for 24 hours. Save the file to your own storage if you need it longer.
curl -L -o result.png "$DOWNLOAD_URL"
import { writeFile } from "node:fs/promises";
const file = await fetch(job.outputs[0].download_url);
await writeFile("result.png", Buffer.from(await file.arrayBuffer()));
file = requests.get(job["outputs"][0]["download_url"], timeout=60)
with open("result.png", "wb") as f:
f.write(file.content)
Video from a start frame, with a webhook
Videos take a few minutes. Upload the start frame, create the job without waiting, and let mazi call your server when it's done.
# 1) Upload the image for the firstFrame field curl https://api.joinmazi.ai/v1/media \ -H "Authorization: Bearer $MAZI_API_KEY" \ -F model=seedance-2.5 \ -F field=firstFrame \ -F file=@frame.png # → { "id": "5b8e…", "object": "media", "mime": "image/png", … } # 2) Start the video and get a webhook when it finishes curl https://api.joinmazi.ai/v1/jobs \ -H "Authorization: Bearer $MAZI_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "model": "seedance-2.5", "input": { "mode": "first-frame", "firstFrame": "MEDIA_ID", "prompt": "The camera slowly pushes in while steam rises from the cup", "resolution": "720p", "duration": 5 }, "webhook_url": "https://example.com/webhooks/mazi" }'
import { readFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
const auth = { Authorization: `Bearer ${process.env.MAZI_API_KEY}` };
// 1) Upload the image for the firstFrame field
const form = new FormData();
form.set("model", "seedance-2.5");
form.set("field", "firstFrame");
form.set("file", new Blob([await readFile("frame.png")], { type: "image/png" }), "frame.png");
const media = await (await fetch("https://api.joinmazi.ai/v1/media", { method: "POST", headers: auth, body: form })).json();
// 2) Start the video and get a webhook when it finishes
const res = await fetch("https://api.joinmazi.ai/v1/jobs", {
method: "POST",
headers: { ...auth, "Content-Type": "application/json", "Idempotency-Key": randomUUID() },
body: JSON.stringify({
model: "seedance-2.5",
input: {
mode: "first-frame",
firstFrame: media.id,
prompt: "The camera slowly pushes in while steam rises from the cup",
resolution: "720p",
duration: 5,
},
webhook_url: "https://example.com/webhooks/mazi",
}),
});
const job = await res.json(); // status: "queued"
import os, uuid, requests
auth = {"Authorization": f"Bearer {os.environ['MAZI_API_KEY']}"}
# 1) Upload the image for the firstFrame field
with open("frame.png", "rb") as f:
media = requests.post(
"https://api.joinmazi.ai/v1/media",
headers=auth,
data={"model": "seedance-2.5", "field": "firstFrame"},
files={"file": ("frame.png", f, "image/png")},
).json()
# 2) Start the video and get a webhook when it finishes
job = requests.post(
"https://api.joinmazi.ai/v1/jobs",
headers={**auth, "Idempotency-Key": str(uuid.uuid4())},
json={
"model": "seedance-2.5",
"input": {
"mode": "first-frame",
"firstFrame": media["id"],
"prompt": "The camera slowly pushes in while steam rises from the cup",
"resolution": "720p",
"duration": 5,
},
"webhook_url": "https://example.com/webhooks/mazi",
},
).json() # status: "queued"
No public server? Skip webhook_url and long-poll instead: call GET /v1/jobs/{job_id}?wait_seconds=60 until status is succeeded or failed.
Jobs and lifecycle
A job is one generation. It moves through these states:
- Billing. When you create a job, its price is reserved from your mazi credits (
creditson the job). It's charged only when the job succeeds. Failed jobs, including content the model provider rejects, are refunded in full. - Prices are whole mazi credits, rounded up. Check a price up front with
POST /v1/estimates. It takes the same body as a job. - Waiting.
wait_seconds(0 to 60) onPOST /v1/jobsandGET /v1/jobs/{job_id}keeps the request open until the job finishes. The server is notified the moment a job completes, so there's no need for tight polling loops. - Outputs.
outputs[].urlanddownload_urlare valid untilurls_expire_at(24 hours). Fetch the job again for fresh links. Files are deleted after 90 days (outputs_expire_at). - Failures carry
error.codeon the job:content_rejected,provider_failed,submit_failed, ortimeout(no result within 20 minutes). - Library. Every job also shows up in your library in the mazi app.
Webhooks
Pass webhook_url (a public https URL) when you create a job. When the job succeeds or fails, mazi sends a POST with an event to that URL.
{
"id": "0f3a7c2e-6d1b-4b8e-9a51-2c7d4e9f1a3b",
"object": "event",
"type": "job.succeeded",
"created_at": "2026-10-31T10:02:41.000Z",
"api_version": "v1",
"data": {
"job": { "id": "7d0c6f0e-…", "object": "job", "status": "succeeded", "outputs": [ … ], … }
}
}Event types: job.succeeded and job.failed. The job has the same shape as in API responses; its links are valid for 24 hours.
Headers
| Header | Value |
|---|---|
Mazi-Signature | t=1761904961,v1=5f2b…Unix timestamp and HMAC-SHA256 signature, see below. |
Mazi-Event-Id | The event id. Stays the same across retries. |
Mazi-Delivery-Attempt | 1 for the first try, 2 for the first retry, and so on. |
Content-Type | application/json |
Verify the signature
Each account has a webhook signing secret (whsec_…). You'll find it in the mazi app under Settings, API access, where you can also rotate it. The signature is the hex HMAC-SHA256 of "{t}.{raw request body}" with your secret as the key. Compute it over the raw body exactly as received, before any JSON parsing, and reject timestamps older than 5 minutes.
import crypto from "node:crypto";
import express from "express";
function verifyMaziSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post("/webhooks/mazi", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verifyMaziSignature(raw, req.get("Mazi-Signature"), process.env.MAZI_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(raw);
// Store event.id and skip events you've already handled; then queue the work.
res.sendStatus(200);
});
import hashlib, hmac, json, os, time
from flask import Flask, request
def verify_mazi_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
t = int(parts.get("t", "0"))
if not t or abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
app = Flask(__name__)
@app.post("/webhooks/mazi")
def mazi_webhook():
raw = request.get_data()
if not verify_mazi_signature(raw, request.headers.get("Mazi-Signature"), os.environ["MAZI_WEBHOOK_SECRET"]):
return "", 400
event = json.loads(raw)
# Store event["id"] and skip events you've already handled; then queue the work.
return "", 200
Delivery and retries
- Answer with any
2xxstatus within 10 seconds. Do slow work after you respond. - Any other status, a timeout, or a redirect counts as failed. mazi retries after 1 minute, 5 minutes, 30 minutes, 2 hours, and 8 hours, so 6 attempts in total.
- Events can arrive more than once and out of order. Use
Mazi-Event-Idto skip duplicates, and treat the job'sstatusas the source of truth. - Missed every attempt?
GET /v1/jobs/{job_id}always returns the current state.
Idempotency
Networks fail. To retry POST /v1/jobs safely, send an Idempotency-Key header with a unique value per job, such as a UUID or your own order id (up to 255 printable ASCII characters).
- The first request creates the job and returns
201. - Any repeat with the same key and the same body returns the original job with
200and the headerIdempotent-Replayed: true. No second job starts and nothing is charged twice. - Reusing a key with a different model or input returns
409 idempotency_conflict.
Keys are scoped to your account and don't expire. Requests with a key also get a higher rate limit, so retries go through.
Errors
mazi uses standard HTTP status codes. Errors always have the same JSON shape with a stable, machine-readable code. Build your handling on code, not on message.
{
"error": {
"code": "invalid_input",
"message": "The input does not match the model. See details and GET /v1/models/{model_id}.",
"details": [{ "field": "prompt", "problem": "required" }]
}
}Every response carries a Mazi-Request-Id header. Include it when you contact support.
| Status | Code | What to do |
|---|---|---|
| 400 | invalid_request | Fix the body or parameters. details.field names the problem. |
| 400 | invalid_input | The input doesn't match the model and mode. details lists every issue. |
| 400 | media_not_found | A media id isn't on your account. Upload with POST /v1/media first. |
| 400 | invalid_webhook_url, invalid_url | Use a public https URL (no IP addresses or localhost). |
| 400 | download_failed | mazi couldn't fetch the image URL. Check that it's public, or upload the file directly. |
| 401 | unauthorized | Send a valid key as Authorization: Bearer mz_live_…. |
| 402 | insufficient_credits | Top up mazi credits in the app. details shows what's needed. |
| 403 | terms_required | Accept the Terms of Service once in the app. |
| 403 | account_blocked | Contact mazi support. |
| 404 | unknown_model, unknown_field, not_found | Check the id against GET /v1/models or your jobs. |
| 409 | idempotency_conflict | Use a new Idempotency-Key for a different request. |
| 413 | size | The file is too large (32 MB per upload, less for some fields). |
| 415 | type, unsupported_media | Upload JPEG, PNG, or WebP to an image field. |
| 422 | dimensions, ratio | The image is outside the field's pixel or aspect ratio limits. |
| 429 | rate_limited | Wait for Retry-After seconds, then retry. |
| 429 | too_many_open_jobs | Wait until one of your running jobs finishes. |
| 500 | internal_error | Retry with the same Idempotency-Key. If it persists, contact support with the request id. |
| 503 | generation_unavailable | The model is temporarily unavailable. Retry later or pick another model. |
Rate limits
Limits apply per account, across all of its keys, the MCP server, and the Studio.
| What | Limit |
|---|---|
| Creating jobs | 20 per minute |
| Creating jobs with an Idempotency-Key | 60 per minute |
| Uploads | 60 per minute |
| Jobs running at the same time | A fixed number per account. Above it, new jobs return too_many_open_jobs. |
A 429 response includes a Retry-After header in seconds. Reading jobs, models, and your account isn't rate limited in normal use. Need higher limits? Contact us.
API reference
Generated from the OpenAPI spec. All paths are relative to https://api.joinmazi.ai/v1.
Loading the API reference…
Objects
Loading…
Models
Every model uses the same POST /v1/jobs endpoint. Pick a mode, then send the fields listed for that mode in input. Video is billed per second of output, images per image. Your prices in mazi credits are in the Studio and in GET /v1/models with your API key.
Loading models…