# Genjutsu media API (skill for AI coding tools)

Generate images, video and audio with one HTTP API. This file is self-contained: read it, then call the API.
Machine-readable spec: `https://genjutsu.top/openapi.yaml`. Human docs: `https://genjutsu.top/docs`.

- **Base URL:** `https://genjutsu.top` (the site origin, without `/v1`). Every path below starts with `/v1/...` and is appended to it, for example `https://genjutsu.top/v1/media/generate`.
- **Auth:** `Authorization: Bearer $GENJUTSU_API_KEY` on every call. Keys look like `gj_live_<prefix>_<secret>` and are created in the
  API console (`https://genjutsu.top/console/keys`). Never put a key in client-side code, URLs or logs.
- **Money:** everything is priced in credits, shown as exact decimal strings with six decimals (`"0.250000"`). `$1 = 10 credits`.
  Credits are held when a task is accepted, charged when it succeeds and refunded when it fails or is cancelled.
- **Scopes:** a key needs `generate` to submit and cancel, `read` to poll tasks and fetch the notify secret, `assets` for uploads.
  A key with only `generate` can submit but gets `403 forbidden` when polling, so tick both when you create it.
- **Format:** JSON in, JSON out, UTF-8. Request bodies are limited to 256 KiB. Every response has an `X-Request-Id` header.

## 1. Discover models

```
GET /v1/media/models            # optional ?type=image|video|audio; needs a key; full list, no cursor
GET /v1/models/{id}/pricing     # price range and per-line prices (credits)
GET /api/public/models          # no key needed: published models and prices, without `params`
```

Without a key, browse `https://genjutsu.top/models`; each model page shows its parameters and a ready-made request.

Each model has `id`, `name`, `output_group`, `price_range` (min..max credits at default options) and `params`: the list of
accepted parameters for its default mode. A param is `{key, label, type, required, default?, min?, max?, options?[{code,label}]}`.
Send **option codes** (not labels) for enum params. File params (`type: file` / `file[]`) take an https URL or
`{"asset_id": "<uuid>"}` of an asset you uploaded (`POST /v1/assets/uploads`, then `POST /v1/assets/{id}/complete`).
Upload: `POST /v1/assets/uploads` with `{mime, bytes}` returns `upload: {method, url, headers, auth, expires_at}`. `PUT` the raw file
bytes to `upload.url` with exactly `upload.headers`; when `upload.auth` is `same_as_request` (usual) also send
`Authorization: Bearer <your key>`, when it is `none` the URL is pre-signed and must get no Authorization header. A file must match
the slot: image slots take images only, video slots video, audio slots audio (otherwise 422 `INVALID_INPUT`, nothing is charged).
`modes` lists other modes (for example image-to-video) with their own `params`; pass one as `mode` in the generate call.

## 2. Generate

```
POST /v1/media/generate
Content-Type: application/json
Idempotency-Key: <unique string, optional but recommended>   # 1-200 chars of A-Za-z0-9._:-

{
  "model": "gpt-image-2-5",
  "prompt": "a lighthouse at dusk, film photo",
  "params": { "aspect_ratio": "16:9", "num_images": 2 },
  "strategy": "price"
}
```

Optional: add `"notify_url": "https://your-server.example/hooks/genjutsu"` (your own public https endpoint) to receive a callback.

- `model` required. `prompt` is a shortcut for the model's prompt param. `params` holds every other value.
- `mode` (optional): capability such as `i2v`; the model default otherwise.
- `strategy` (optional): `best` (default), `price`, `speed`, `success`: how the platform picks among the model's lines.
- `notify_url` (optional): public https URL (port 443 or 8443). Receives the final task body.
- Response `202` (with a `Location` header): `{"task_id": "<uuid>", "state": "queued", "credits_held": "0.250000"}`.
- Repeating a request with the same `Idempotency-Key` and body returns the original task (`200`, `Idempotent-Replayed: true`);
  the same key with a different body is `409 invalid_request`. Always send a key when your code may retry.

## 3. Poll or receive a callback

```
GET /v1/media/tasks/{task_id}
```

```json
{
  "task_id": "…",
  "state": "queued | running | succeeded | failed | cancelled",
  "is_final": false,
  "progress": 40,
  "outputs": [{ "url": "https://genjutsu.top/media/…", "type": "image", "expires_at": "2026-10-23T10:10:00.000Z", "width": 1024, "height": 1024 }],
  "error_code": null,
  "error_message": null,
  "credits": { "held": "0.250000", "captured": "0.000000", "refunded": "0.000000", "net": "0.000000" }
}
```

Poll **every 5 seconds** until `is_final` is true. On `succeeded`, download the `outputs[].url` links right away: they are signed
and expire at `expires_at` (fetch the task again for fresh links). Outputs are streamed from the provider and expire after 3 days: download them promptly. An output link opened after that answers `410` with `error.code` `OUTPUT_EXPIRED`; the task then lists the output as `expired` with no `url`.
Cancel with `POST /v1/media/tasks/{task_id}/cancel` (scope `generate`; the result says `cancelled`, `requested` or `not_cancellable`).

`notify_url` callbacks are separate from the account webhooks in the console (`generation.*` events, other retry schedule). If you passed `notify_url`, the same body is POSTed to it once the task is final: immediately, then retried after 10 s, 30 s and
60 s until you answer 2xx. Verify the signature before trusting it. Headers: `Genjutsu-Signature: v1=<hex>`,
`Genjutsu-Timestamp`, `Genjutsu-Event-Id`, `Genjutsu-Event-Type: media.task`. The signature is HMAC-SHA256 over
`"<timestamp>.<raw body>"` with the secret from `GET /v1/media/notify-secret`; reject timestamps more than 300 s off and ignore
event ids you have already handled.

## 4. Errors

Errors are `{"error": {"code", "message", "retryable", "request_id"}}` with an HTTP status. **Retry only when `retryable` is true**
(wait for `Retry-After` if present, otherwise back off 5 s, 15 s, 45 s); permanent errors will fail the same way again.

| code | HTTP | retry | what to do |
|---|---|---|---|
| `invalid_request` | 400/404/409/413/415/422 | no | fix the request; `error.details.errors[]` lists bad params |
| `unauthorized` | 401 | no | check the key |
| `insufficient_balance` | 402 | no | the user must redeem a code in the console |
| `quota_exceeded` | 402 | no | the key budget or account limit is used up |
| `forbidden` / `model_not_allowed` | 403 | no | key lacks the scope or model |
| `ip_not_allowed` | 403 | no | call from an allowed address (the message names the one we saw); nothing was charged |
| `key_config_error` | 403 | no | the key's custom line list has no usable line; fix it in the console |
| `model_not_found` | 404 | no | use an id from `/v1/media/models` |
| `content_policy_violation` | 422 | no | change the prompt or inputs |
| `rate_limited` | 429 | yes | slow down |
| `channel_unavailable` | 503 | yes | no line is free right now |
| `upstream_error` | 502 | yes | the model failed; resubmit |
| `timeout` | 504 | yes | resubmit |
| `server_error` | 500 | yes | resubmit with the same `Idempotency-Key` |

A task that fails after being accepted ends with `state: "failed"` and the same `error_code` values; its credits are refunded.
Default limits: 60 requests per minute per key and 120 per minute per account. Successful responses and 429s carry
`X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds); a 429 also has `Retry-After`. Other errors
(401, 402, 403, 422) carry only `X-Request-Id`. Every response has `Genjutsu-Api-Version`.

## 5. Manage keys (optional)

A **management key** (create one in the console) can call `GET/POST /v1/keys` and `GET/PATCH/DELETE /v1/keys/{id}` for its own
account, and nothing else. New keys can never exceed the creating key (scopes, models, budget, rate limit, IP range, expiry).
`GET /v1/keys` returns the full list (no cursor). There is no rotate call: to rotate without downtime, `POST /v1/keys` for a
new key, deploy it, then `DELETE /v1/keys/{id}` the old one. Useful fields: `label`, `scopes` (`generate`, `read`, `assets`, `redeem`), `budget` (credits string), `rate_limit_per_minute`,
`strategy`, `model_allowlist`, `excluded_models`, `ip_allowlist` (IPs or CIDR ranges, IPv4 and IPv6), `expires_at`.
The secret of a new key is returned once in `key`.

## Minimal client

```python
import os, time, uuid, requests

BASE = "https://genjutsu.top"
H = {"Authorization": f"Bearer {os.environ['GENJUTSU_API_KEY']}"}

def generate(model, prompt, **params):
    r = requests.post(f"{BASE}/v1/media/generate", headers={**H, "Idempotency-Key": str(uuid.uuid4())},
                      json={"model": model, "prompt": prompt, "params": params}, timeout=30)
    r.raise_for_status()
    task = r.json()["task_id"]
    while True:
        t = requests.get(f"{BASE}/v1/media/tasks/{task}", headers=H, timeout=30).json()
        if t["is_final"]:
            if t["state"] != "succeeded":
                raise RuntimeError(f"{t['error_code']}: {t['error_message']}")
            return [o["url"] for o in t["outputs"]]
        time.sleep(5)
```

## Rules for agents

- Pick the model from `/v1/media/models`; do not guess ids or parameter names.
- Check `price_range` / `/pricing` before expensive requests and tell the user the cost in credits.
- Use one `Idempotency-Key` per logical job and reuse it on retries, so a retry never charges twice.
- There is no endpoint to list tasks: store each `task_id`. If you lost one, repeat the submit with the same `Idempotency-Key` to get it back.
- Never log or echo the API key or signing secret. Never retry non-retryable errors.
