# Midjourney API Python Guide: Generate and Save Images

> Create a Midjourney image task in Python, poll its status, and download the image grid. Learn which operations are available before building your app.

- Canonical HTML: https://omniakey.com/blog/midjourney-api-python
- Markdown: https://omniakey.com/blog/midjourney-api-python.md
- Published: 2026-11-12 (updated 2026-11-12)
- Author: Kiki, COO at OmniaKey
- Language: en
- Category: API setup
- Reading time: 8 min
- All posts: https://omniakey.com/blog/llms.txt

---

A [Midjourney](https://omniakey.com/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/tasks` with an `Idempotency-Key`, then poll `GET /v1/media/tasks/{id}` and download `assets[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](https://omniakey.com/midjourney) 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:

1. An OmniaKey API key, exported as `OMNIAKEY_API_KEY`.
2. Python 3. The script uses only the standard library, so there is no SDK to install.
3. `midjourney` available 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.

```bash
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:

```json
{
  "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.

```python
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](https://docs.omniakey.com/en/media/overview) (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:

![Four red folded paper boats on blue water at sunrise, arranged in a 2x2 grid with warm reflected light](https://omniakey.com/_img/blog/midjourney-api-python/paper-boat-grid.03735622.1600.webp)

_November 12, 2026 integration sample: one midjourney task, one 2048x2048 PNG, four candidates, 197.1 seconds from create to download. The downloaded bytes matched the task's SHA-256._

The response below is shortened: the URL is redacted and `expires_at` is historical. It adds the asset fields you need:

```json
{
  "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](https://docs.omniakey.com/en/media/image) (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](https://omniakey.com/midjourney); 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](https://omniakey.com/blog/gpt-image-2-5-api) or [Nano Banana Pro API](https://omniakey.com/blog/nano-banana-pro-api).

If you would rather type a prompt than write code, the [AI Image Generator](https://omniakey.com/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.
