# Creativly API

Generate and edit images and videos with one API key and your existing platform credits.

API base: https://api-v2.creativly.ai/v1
Visual guide: https://www.creativly.ai/developers
Get a key: https://www.creativly.ai/developers?settings=api-keys

## Authentication

Open Profile & Usage → API Keys → Generate API key. Copy it once and keep it on your server as CREATIVLY_API_KEY. One active key exists per account. Rotation or revocation stops the old key. Provider credentials in BYOK are separate; this API uses your platform wallet.

Send Authorization: Bearer YOUR_CREATIVLY_API_KEY and Content-Type: application/json. Never put the key in public browser code, a URL or a repository.

## Create an image

POST /generations with a unique Idempotency-Key header:
```json
{
  "model": "nano-banana-2.1",
  "prompt": "A sky-blue espresso cup on an ivory plinth, soft studio light",
  "max_credits": 1
}
```

Returns 202 with batch_id, run_ids, project_id, session_id, credits_debited, poll_after_seconds and app_url. Poll GET /jobs/{batch_id} until terminal=true. Inspect every run; retrieve each completed asset using GET /assets/{asset_id}.

## Image to video

Use an owned image asset ID from a completed generation or clean private upload:
```json
{
  "model": "gemini-omni-1.1-flash-preview",
  "prompt": "A gentle camera push toward the cup",
  "image_asset_ids": [
    "YOUR_IMAGE_ASSET_ID"
  ],
  "parameters": {
    "duration": 4,
    "resolution": "360p"
  },
  "max_credits": 4
}
```

## Upload → moderation → edit → original download

1. POST /uploads with filename, content_type and exact size_bytes, plus an Idempotency-Key.
2. PUT the original bytes to upload_url using only the returned method and headers. Do not send your Creativly key to storage.
3. POST /uploads/{upload_id}/complete with {}.
4. Poll GET /uploads/{upload_id} until ready=true. Scanning, blocked and failed files cannot be references.
5. POST /quotes if you want a price preview, then POST /generations with operation="edit", source_asset_id and a compatible model.
6. Poll GET /jobs/{batch_id}. Use GET /assets/{asset_id} for original_url and download_url.

Images: JPEG, PNG, WebP, GIF; maximum 10 MiB. Videos: MP4, WebM, QuickTime; maximum 50 MiB. Detected MIME and exact size must match the declaration. Plan storage quotas apply. Upload reservations expire after two hours. No arbitrary remote URL import is exposed.

### Complete image-edit example (Python 3.8+, standard library)

Save as edit-image.py, put product.png beside it, set CREATIVLY_API_KEY in your environment, then run python3 edit-image.py. Running the script accepts a paid edit up to the explicit 1-credit cap. It prints the request ID; preserve it and use the same CREATIVLY_REQUEST_ID and exact file/settings after an uncertain submission. Do not rerun an accepted job as a new request; resume its batch_id.

```python
import json, os, time, uuid
from pathlib import Path
from urllib.request import Request, urlopen

BASE = "https://api-v2.creativly.ai/v1"
KEY = os.environ["CREATIVLY_API_KEY"]
RUN = os.environ.get("CREATIVLY_REQUEST_ID") or str(uuid.uuid4())
print("Request ID (reuse if retrying):", RUN, flush=True)
FILE = Path("product.png")
MIME = "image/png"
CAP = 1  # Ceiling, not the quoted price.

def api(method, path, payload=None, key=None):
    headers = {"Authorization": "Bearer " + KEY,
               "Content-Type": "application/json"}
    if key:
        headers["Idempotency-Key"] = key
    data = json.dumps(payload).encode() if payload is not None else None
    request = Request(BASE + path, data=data, headers=headers, method=method)
    with urlopen(request, timeout=75) as response:
        return json.load(response)

# 1. Reserve the original file. Uploading does not spend generation credits.
raw = FILE.read_bytes()
ticket = api("POST", "/uploads", {
    "filename": FILE.name, "content_type": MIME, "size_bytes": len(raw)
}, key=RUN + "-upload")

# Send only the returned upload headers. Never send your API key to storage.
if ticket.get("upload_url"):
    put = Request(ticket["upload_url"], data=raw,
                  headers=ticket["headers"], method=ticket["method"])
    with urlopen(put, timeout=120) as response:
        response.read()
upload_id = ticket["upload_id"]
api("POST", "/uploads/" + upload_id + "/complete", {})

# 2. Wait for moderation. A stored file is not yet an editable reference.
deadline = time.monotonic() + 600
while True:
    source = api("GET", "/uploads/" + upload_id)
    if source["ready"]:
        break
    if (source["status"] in ("failed", "expired", "blocked")
            or source["moderation_status"] in ("failed", "blocked", "rejected")):
        raise RuntimeError("This upload cannot be used.")
    if time.monotonic() > deadline:
        raise TimeoutError("Moderation is pending. Resume using the upload ID.")
    time.sleep(source.get("poll_after_seconds") or 3)

# 3. Quote and submit one edit. The original stays intact.
edit = {
    "model": "nano-banana-2.1",
    "operation": "edit",
    "source_asset_id": source["asset_id"],
    "prompt": "Make the background warm terracotta. Keep the product, label and composition.",
    "max_credits": CAP,
}
quote = api("POST", "/quotes", edit)
print("Quoted credits:", quote["credits"], flush=True)
# Running this script submits the edit within CAP. For a preview, stop here.
receipt = api("POST", "/generations", edit, key=RUN + "-edit")
batch_id = receipt["batch_id"]
print("Accepted job:", batch_id, flush=True)

# 4. Follow the server polling interval. Never submit again to check progress.
deadline = time.monotonic() + 1800
while True:
    job = api("GET", "/jobs/" + batch_id)
    if job["terminal"]:
        break
    if time.monotonic() > deadline:
        raise TimeoutError("Job still running. Resume with GET /jobs/" + batch_id)
    time.sleep(job.get("poll_after_seconds") or 10)

# 5. Retrieve every completed result and download its full original.
for run in job["runs"]:
    if run["status"].lower() != "completed":
        raise RuntimeError(run.get("error") or "Generation did not complete.")
    for result in run["assets"]:
        asset = api("GET", "/assets/" + result["asset_id"])
        name = Path(asset["filename"]).name
        output = Path("creativly-" + result["asset_id"] + "-" + name)
        with urlopen(asset["download_url"], timeout=120) as response:
            with output.open("wb") as handle:
                while chunk := response.read(1024 * 1024):
                    handle.write(chunk)
        print("Saved:", output)
```

### Complete video-edit example

Use a short clip.mp4. Source duration is verified by Creativly; the maximum supported video-edit duration is currently 30 seconds. This example uses a 12-credit ceiling, not a promised price. Check the quote for your actual source before acceptance.

```python
import json, os, time, uuid
from pathlib import Path
from urllib.request import Request, urlopen

BASE = "https://api-v2.creativly.ai/v1"
KEY = os.environ["CREATIVLY_API_KEY"]
RUN = os.environ.get("CREATIVLY_REQUEST_ID") or str(uuid.uuid4())
print("Request ID (reuse if retrying):", RUN, flush=True)
FILE = Path("clip.mp4")
MIME = "video/mp4"
CAP = 12  # Ceiling, not the quoted price.

def api(method, path, payload=None, key=None):
    headers = {"Authorization": "Bearer " + KEY,
               "Content-Type": "application/json"}
    if key:
        headers["Idempotency-Key"] = key
    data = json.dumps(payload).encode() if payload is not None else None
    request = Request(BASE + path, data=data, headers=headers, method=method)
    with urlopen(request, timeout=75) as response:
        return json.load(response)

# 1. Reserve the original file. Uploading does not spend generation credits.
raw = FILE.read_bytes()
ticket = api("POST", "/uploads", {
    "filename": FILE.name, "content_type": MIME, "size_bytes": len(raw)
}, key=RUN + "-upload")

# Send only the returned upload headers. Never send your API key to storage.
if ticket.get("upload_url"):
    put = Request(ticket["upload_url"], data=raw,
                  headers=ticket["headers"], method=ticket["method"])
    with urlopen(put, timeout=120) as response:
        response.read()
upload_id = ticket["upload_id"]
api("POST", "/uploads/" + upload_id + "/complete", {})

# 2. Wait for moderation. A stored file is not yet an editable reference.
deadline = time.monotonic() + 600
while True:
    source = api("GET", "/uploads/" + upload_id)
    if source["ready"]:
        break
    if (source["status"] in ("failed", "expired", "blocked")
            or source["moderation_status"] in ("failed", "blocked", "rejected")):
        raise RuntimeError("This upload cannot be used.")
    if time.monotonic() > deadline:
        raise TimeoutError("Moderation is pending. Resume using the upload ID.")
    time.sleep(source.get("poll_after_seconds") or 3)

# 3. Quote and submit one edit. The original stays intact.
edit = {
    "model": "gemini-omni-1.1-flash-preview",
    "operation": "edit",
    "source_asset_id": source["asset_id"],
    "prompt": "Shift the scene into warm golden-hour light. Keep the subject and camera movement.",
    "parameters": {"resolution": "720p"},
    "max_credits": CAP,
}
quote = api("POST", "/quotes", edit)
print("Quoted credits:", quote["credits"], flush=True)
# Running this script submits the edit within CAP. For a preview, stop here.
receipt = api("POST", "/generations", edit, key=RUN + "-edit")
batch_id = receipt["batch_id"]
print("Accepted job:", batch_id, flush=True)

# 4. Follow the server polling interval. Never submit again to check progress.
deadline = time.monotonic() + 1800
while True:
    job = api("GET", "/jobs/" + batch_id)
    if job["terminal"]:
        break
    if time.monotonic() > deadline:
        raise TimeoutError("Job still running. Resume with GET /jobs/" + batch_id)
    time.sleep(job.get("poll_after_seconds") or 10)

# 5. Retrieve every completed result and download its full original.
for run in job["runs"]:
    if run["status"].lower() != "completed":
        raise RuntimeError(run.get("error") or "Generation did not complete.")
    for result in run["assets"]:
        asset = api("GET", "/assets/" + result["asset_id"])
        name = Path(asset["filename"]).name
        output = Path("creativly-" + result["asset_id"] + "-" + name)
        with urlopen(asset["download_url"], timeout=120) as response:
            with output.open("wb") as handle:
                while chunk := response.read(1024 * 1024):
                    handle.write(chunk)
        print("Saved:", output)
```

## Request fields

| Field | Meaning |
| --- | --- |
| prompt | Required, 1–8000 characters. |
| model | Defaults to nano-banana-2.1. Choose a video model explicitly. |
| operation | generate (default) or edit. |
| parameters | Supported model settings only. Discover allowed values/defaults from the catalog. |
| count | 1–4 generated variants; edits accept one output. |
| image_asset_ids | Up to five owned, clean images, further restricted by model capabilities. |
| source_asset_id | Owned image/video source for an edit. The original stays intact. |
| project_id / session_id | Optional owned context. Source-based work infers its source context; otherwise the API creates/reuses its default workspace/session. |
| max_credits | Integer ceiling 0–1000. Set explicitly. Omitted ceiling is 1000. |
| quote_id | Optional binding to an unexpired matching quote. |

Available parameters are size, aspect_ratio, duration, resolution, quality, audio_enabled and seed only where supported by the selected model/operation. Unknown fields or unsupported parameters are rejected. There is no arbitrary provider parameter passthrough. Do not silently switch the model when a request fails.

## Endpoints

| Method | Path (relative to /v1) | Use |
| --- | --- | --- |
| GET | /account | Current wallet and access. |
| GET | /models?media_type=image or video | Discover available models. |
| GET | /models/{model_id} | Supported operations, inputs, allowed parameter values and defaults. |
| GET | /projects | Owned projects. |
| GET | /projects/{project_id}/sessions | Saved sessions. |
| GET | /assets?project_id={project_id} | Project assets. |
| GET | /assets/{asset_id} | Metadata and fresh original/download URLs. |
| POST | /quotes | Validate and preview the price without a debit. |
| POST | /generations | Accept an image/video generation or edit. Requires Idempotency-Key. |
| GET | /jobs/{batch_id} | Every run and completed output asset ID. |
| POST | /uploads | Reserve a private upload. Requires Idempotency-Key. |
| POST | /uploads/{upload_id}/complete | Verify bytes and begin moderation. |
| GET | /uploads/{upload_id} | Readiness and reusable asset ID. |

List endpoints support limit=1–50 (default 20) and offset=0–10000 (default 0). There is no public REST project/session-create route in this release; generation/upload reuse owned context automatically.

## Credits, retries and failures

Generation debits once on acceptance. Uploading, moderation and reads do not spend generation credits. The same wallet and plan rules as Creativly apply. Quote first when you need to preview a cost; max_credits is always a ceiling.

Idempotency-Key must contain 8–160 letters, digits or . _ : -. Keep one key per intended generation/upload. After a network timeout, retry the exact body and key. Exact retries return the same accepted job without a second debit. Reusing the key with different arguments is rejected. A new key means new work.

Handle non-2xx responses. Invalid inputs, foreign/not-ready assets, unsupported models/settings, insufficient credits and caps below cost are rejected before acceptance. Respect rate limits; do not repeatedly submit to check status. Accepted failures follow the existing refund policy. Treat each run independently in multi-output jobs.

Follow poll_after_seconds and stop when terminal=true. URLs expire after 15 minutes; refresh them by retrieving the asset again. Store asset IDs, not signed URLs, as identity. Download full originals from download_url; no preview compression is substituted.

For AI assistants, use the OAuth MCP connection at https://mcp.creativly.ai/mcp instead of putting API keys into chat. Setup: https://www.creativly.ai/mcp.
