Midjourney API Python Guide: Generate and Save Images
Create the job, poll until it finishes, then save one four-up PNG grid; the key and body are on disk before the POST.
On this page
- Short answer
- Which API does this Python example use?
- What you need
- Request body and parameters
- The Python script
- What comes back
- Rerun after a timeout or a failed download
- Errors and fixes
- Cost and when to use another image API
- Frequently asked questions
- Do I need a Midjourney subscription to use this API?
- Why did one Midjourney task return four images in one file?
- What does 409 idempotency_key_reused mean?
- What happens if my script times out while polling?
A Midjourney job on OmniaKey takes three steps in Python: create the task, poll its status until it finishes, then download the PNG from the signed asset URL. A November 12, 2026 integration run returned one 2048x2048 image in 197.1 seconds, and that image is a 2x2 grid of four candidates. The script below adds a state file, so a timeout or a failed download does not create a second paid job.
Short answer
- Send
POST https://api.omniakey.com/v1/media/taskswith anIdempotency-Key, then pollGET /v1/media/tasks/{id}and downloadassets[0].url. - One task returns one PNG grid with four images, not four separate files.
- Midjourney on this API does text-to-image only: no edit, reference image, blend, upscale, vary, pan or version selector.
- The key comes from OmniaKey and is separate from a Midjourney subscription. Save the body and idempotency key before the POST, add the task id after the API accepts it, and keep the state file to resume later. A local timeout does not cancel the server task.
Which API does this Python example use?
The example calls OmniaKey's third-party API for Midjourney. OmniaKey is not Midjourney, and an OmniaKey key is a separate account with its own balance. A Midjourney website subscription is a separate product and does not create an OmniaKey key.
The Midjourney model page shows the model and current rate on OmniaKey's side. The rest of the walkthrough covers this integration and its task lifecycle.
What you need
You need three things:
- An OmniaKey API key, exported as
OMNIAKEY_API_KEY. - Python 3. The script uses only the standard library, so there is no SDK to install.
midjourneyavailable to that key/account. If the model is missing from the key's model list, check your account access before you run the create call.
export OMNIAKEY_API_KEY="your-key"
python3 midjourney_grid.py
Keep the key out of the script and out of logs. The code reads it once from the environment.
Request body and parameters
Midjourney takes a prompt plus three parameters. The bot type is preset on OmniaKey's side, so the body can omit a model version and botType.
| Field | Value | Notes |
|---|---|---|
input.prompt | Text | The scene you want |
parameters.aspectRatio | 1:1 | Also 16:9, 9:16, 3:2, 2:3 |
parameters.quality | "1" | The docs use the string "1" |
parameters.n | 1 | Must be 1 |
Set the shape with aspectRatio instead of a pixel size. The field is camelCase: aspectRatio, never aspect_ratio. Use one of the five listed values.
The body for the sample prompt looks like this:
{
"model": "midjourney",
"type": "image",
"operation": "generate",
"input": {
"prompt": "A small red paper boat floating on a quiet blue lake at sunrise, cinematic lighting, elegant composition, no text"
},
"parameters": {"aspectRatio": "1:1", "quality": "1", "n": 1}
}
The Python script
Save this as midjourney_grid.py and run it. The first run writes midjourney-task.json before the POST, then adds the task id once OmniaKey accepts the request.
import hashlib
import json
import os
import sys
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path
API = "https://api.omniakey.com/v1"
KEY = os.environ["OMNIAKEY_API_KEY"]
STATE = Path("midjourney-task.json")
OUT = Path("midjourney-grid.png")
PROMPT = ("A small red paper boat floating on a quiet blue lake at sunrise, "
"cinematic lighting, elegant composition, no text")
def save(state):
tmp = STATE.with_suffix(".tmp")
tmp.write_text(json.dumps(state, indent=2))
tmp.replace(STATE)
def read_json(response):
raw = response.read()
try:
return json.loads(raw)
except ValueError:
return {"raw": raw[:300].decode("utf-8", "replace")}
def call(method, path, body=None, headers=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
API + path, data=data, method=method,
headers={"Authorization": f"Bearer {KEY}", **(headers or {})})
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=60) as response:
return response.status, read_json(response)
except urllib.error.HTTPError as error:
return error.code, read_json(error)
except (urllib.error.URLError, TimeoutError) as error:
return None, {"error": str(error)}
def main():
state = json.loads(STATE.read_text()) if STATE.exists() else None
if state is None:
body = {
"model": "midjourney", "type": "image", "operation": "generate",
"input": {"prompt": PROMPT},
"parameters": {"aspectRatio": "1:1", "quality": "1", "n": 1},
}
state = {"idempotency_key": str(uuid.uuid4()), "body": body,
"task_id": None, "status": "creating"}
save(state) # no API key here; write before POST
elif state["body"]["input"]["prompt"] != PROMPT:
sys.exit("The state file holds a different prompt. Move it aside to start a new job.")
if OUT.exists() and state.get("status") == "downloaded":
saved_sha = state.get("download_sha")
if not saved_sha or hashlib.sha256(OUT.read_bytes()).hexdigest() == saved_sha:
print("already saved", OUT)
return
if not state["task_id"]:
code, task = call("POST", "/media/tasks", state["body"],
{"Idempotency-Key": state["idempotency_key"]})
if code is None:
sys.exit(f"Create not confirmed: {task['error']}. Rerun to retry the same key and body.")
if code == 409:
sys.exit("409 idempotency_key_reused: same key, different body. Restore the saved body.")
if code == 503:
sys.exit("503 no_available_channel: check model access for this key/account; "
"contact support with the request id if it persists.")
if code not in (200, 202) or "id" not in task:
sys.exit(f"Create failed: HTTP {code} {task}")
state.update(task_id=task["id"], status=task["status"])
save(state)
deadline = time.monotonic() + 900
while True:
code, task = call("GET", f"/media/tasks/{state['task_id']}")
if code is None:
sys.exit(f"Polling stopped: {task['error']}. The job is still saved; rerun this script.")
if code != 200 or "status" not in task:
sys.exit(f"Poll failed: HTTP {code} {task}")
state["status"] = task["status"]
save(state)
if task["status"] in ("failed", "canceled"):
sys.exit(f"Job {task['status']}: {task.get('error')}")
if task["status"] == "succeeded":
break
if time.monotonic() > deadline:
sys.exit(f"Still {task['status']} after 900 s. Rerun with the same state file.")
time.sleep(min(8, 2 + task.get("progress", {}).get("percent", 0) // 20))
asset = task["assets"][0]
if asset.get("mime_type") != "image/png":
sys.exit(f"Expected a PNG grid, got {asset.get('mime_type')}")
try:
with urllib.request.urlopen(asset["url"], timeout=120) as response:
data = response.read() # signed URL: no Authorization header
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as error:
sys.exit(f"Download failed: {error}. Rerun to get a fresh signed URL and retry.")
digest = hashlib.sha256(data).hexdigest()
if asset.get("sha256") and digest != asset["sha256"]:
sys.exit("SHA-256 mismatch. Rerun to download the asset again.")
part = OUT.with_suffix(".part")
part.write_bytes(data)
part.replace(OUT)
state.update(status="downloaded", download_sha=digest)
save(state)
print("saved", OUT, len(data), "bytes")
if __name__ == "__main__":
main()
The request and response shapes come from the November 12 integration run; the restart state is the addition here, and its lifecycle paths were checked offline with mocked responses.
The state file preserves what you need to resume: the JSON body and the UUID used as the idempotency key. It holds no API key. The file is written before the POST, so a dropped connection leaves both values on disk for recovery.
The create call can return two success codes. A 202 accepts a new job. A 200 means OmniaKey has seen the same key and the same body before and is returning the original task; continue polling that task. Resend the same body and key only when the create outcome is unknown after a connection failure. The docs list 409 idempotency_key_reused for a changed body.
Drive the poll loop on status. The percentage is best-effort and can stay at 0 while the job runs. Stop on succeeded, failed or canceled. OmniaKey's media task lifecycle docs (checked Nov 12, 2026) describe those states.
The poll cap is local. When it expires the job is still running or queued; rerunning the same script reads the saved task id and keeps polling. A local timeout is not a cancellation. The cancel endpoint is separate.
What comes back
A succeeded task has one asset: a PNG grid. This is the sample from the November 12 integration run:

The response below is shortened: the URL is redacted and expires_at is historical. It adds the asset fields you need:
{
"id": "task_DbCWJPfPL1jyhSD38IihNHiiFQao8hjV",
"status": "succeeded",
"progress": {"percent": 100, "stage": "completed"},
"assets": [
{
"kind": "image",
"mime_type": "image/png",
"width": 2048,
"height": 2048,
"byte_size": 5834301,
"sha256": "f503cef038d15fa150b36b5fe7c41874febad3aa1167698d790781e47a4a10b9",
"expires_at": "2026-11-12T04:16:41Z",
"url": "https://…redacted-signed-url…"
}
]
}
Read width and height from the asset instead of hard-coding 2048. The 2048x2048 square and the 197.1 seconds are one sample; treat them as such. All four candidates share one PNG, so split the grid after download if your app needs single images.
The url is signed and short-lived, about one hour. The task record and image are kept for 24 hours after creation, according to OmniaKey's image API docs (checked Nov 12, 2026). Download before the URL expires. If it has expired, poll the task id again for a fresh signed URL.
Download the asset URL with a plain GET and no Authorization header. The signature is in the URL. The script also compares the downloaded bytes with the task's sha256, which catches a truncated file before you overwrite your good copy.
Rerun after a timeout or a failed download
Rerun the same command and keep the job. The script reads midjourney-task.json and takes the shortest safe path:
- Create was unconfirmed: it POSTs the same body with the same idempotency key.
- Task id exists and the job is still running: it polls the existing id.
- Task succeeded but the PNG is missing: it polls once for a fresh signed URL and downloads again.
- PNG is already saved and matches its recorded SHA-256: it exits without another request. A corrupted file is polled and downloaded again.
This works within the 24-hour retention window. The same key with a changed prompt or parameters is a 409. The script refuses a state file whose prompt no longer matches; move the file aside if you change parameters.
Errors and fixes
The failures below come from the API's task and idempotency rules. Fix the cause, then rerun the same state file where it applies.
| Response | Code | What to do |
|---|---|---|
| 400 | missing_idempotency_key | Add the header; keep it under 255 characters |
| 409 | idempotency_key_reused | Send the original body, or start a new job with a new key |
| 503 | no_available_channel | Check access for this key/account; contact support if it persists |
failed | error.code, error.message | Read the task error; failed jobs are refunded |
canceled | — | The job stopped; create a new one if you still need it |
503 no_available_channel does not prove that a key lacks model permission. Two submissions with one key used for this batch returned 503 and produced no task; a separate key succeeded in an earlier same-day integration run. The cause is unknown: it could be key/account availability or upstream channel availability. Confirm that midjourney is available to your current key/account. If 503 persists, send support the response body and request id.
A 200 on create is a replay of the original task. Only failed and canceled are terminal failures; stopping the script leaves the job running.
Cost and when to use another image API
OmniaKey charges once per succeeded job, and failed or canceled jobs are refunded automatically. The current rate is on the Midjourney model page; check it before you estimate a batch.
Midjourney suits choosing between four variations in one grid. This integration currently returns that text-to-image grid; for one image per call, or an edit that keeps a source scene, see GPT Image 2.5 API or Nano Banana Pro API.
If you would rather type a prompt than write code, the AI Image Generator runs image models in the browser.
Frequently asked questions
Do I need a Midjourney subscription to use this API?
No. The key comes from OmniaKey, and the calls go to OmniaKey's third-party API. OmniaKey is not Midjourney; a Midjourney website subscription is a separate product and does not create an OmniaKey key.
Why did one Midjourney task return four images in one file?
Midjourney returns a 2x2 grid of four candidates as a single PNG. All four candidates share that file, and upscale and vary are unavailable on this integration, so split the grid yourself after download if you need separate files.
What does 409 idempotency_key_reused mean?
You sent an idempotency key that OmniaKey already has, but the request body changed. Send the original body, or start a new job with a new key. Reusing a key with a different prompt is the usual cause.
What happens if my script times out while polling?
The job keeps running. The task id is in midjourney-task.json, so rerun the same script and it will poll that id or download the finished asset. A local timeout leaves the job alive and the charge in place.
Models in this article
Related articles
Nano Banana Pro API: Python Tutorial, Edits and Price
One POST, one poll, one download: a working Nano Banana Pro script.
GPT Image 2.5 API: Generate and Edit Images in Python
One POST creates the task and one GET reads it back. Here is the request code, the sizes we ran on October 29, 2026, and one edit shown before and after.
GPT Image 2.6 Review: GIF, SVG, Cost and Who It's For
What's new in GPT Image 2.6, tested with our own GIF, three posters and a photo edit.

