Skip to content

Image and video API

The image and video API follows the shape of OpenAI’s image and video endpoints, so the request you already build for those works here with a different base URL and key. Every generation is a task: you can wait for it, poll it, and keep its results in your personal files.

It uses the same keys as the Model API. Base URL: https://api.harperharbor.com/v1

Open Personal API keys in the console, tick Call models directly through the API, and add the image or video models to the key’s model list, or tick All models. A key can only call the models on its list, exactly as with chat models; any other model returns 403.

GET /v1/models lists what the key may use. Each entry now carries a type: chat, image or video.

POST /v1/images/generations takes JSON:

Field Required Meaning
model yes An id with type: "image" from /v1/models
prompt yes Up to 4,000 characters
n no Number of images, 1–4. Default 1
size no One of the model’s capabilities.sizes, such as 1920x1080. Omit it, or send auto, for the model’s default
images no Reference images, up to the model’s capabilities.max_reference_images: https:// URLs of PNG, JPEG or WebP images in your own media library, 10 MB each. They keep a character’s face, hair and outfit the same across images and do not change the price
OpenAI SDK
from openai import OpenAI
client = OpenAI(api_key="hh_live_…", base_url="https://api.harperharbor.com/v1")
result = client.images.generate(
model="gemini-3.1-flash-image",
prompt="A lighthouse on a cliff at dawn, watercolour",
size="1920x1080",
)
print(result.data[0].url)
curl
curl https://api.harperharbor.com/v1/images/generations \
-H "Authorization: Bearer hh_live_…" \
-H "Content-Type: application/json" \
-d '{"model":"gemini-3.1-flash-image","prompt":"A lighthouse on a cliff at dawn, watercolour","size":"1920x1080"}'

The request waits up to 85 seconds. When the images are ready within that time you get 200 in the OpenAI shape, plus a harbor field holding the task:

{
"created": 1761700000,
"data": [{ "url": "https://…/media-gen/…/0.png" }],
"harbor": { "id": "0199…", "object": "image.generation.task", "status": "completed", "credits": 600, "…": "…" }
}

When the images take longer, you get 202 with the task object instead, and the generation keeps running. Poll GET /v1/images/tasks/{id} until status is completed or failed. Every image and video response carries the task id in the X-Harbor-Task-Id header, failed generations included.

Disconnecting does not cancel a generation. The task still finishes, is charged if it succeeds, and stays readable at its task URL.

POST /v1/images/edits takes multipart/form-data, the way the OpenAI SDK sends it:

Field Required Meaning
image yes One to four image files. image[] is accepted too
prompt yes What to change
model yes A model whose capabilities.edits is true
n, size no As for generations
OpenAI SDK
result = client.images.edit(
model="gemini-3.1-flash-image",
image=[open("room.png", "rb")],
prompt="Turn the lights on and add rain on the window",
)
curl
curl https://api.harperharbor.com/v1/images/edits \
-H "Authorization: Bearer hh_live_…" \
-F model=gemini-3.1-flash-image \
-F prompt="Turn the lights on and add rain on the window" \
-F image=@room.png

Responses work exactly like generations: 200 with images, or 202 with a task to poll. A request body, files included, can be up to 20 MiB.

Images already in your media library don’t need uploading again: pass their URLs as images to POST /v1/images/generations instead. We read them from your library ourselves; another site’s URL or someone else’s file returns 400 before anything is charged.

POST /v1/videos takes JSON and always answers 202 with a task, because videos take minutes.

Field Required Meaning
model yes An id with type: "video" from /v1/models
prompt yes Up to 4,000 characters
seconds no One of the model’s capabilities.durations, as "5" or 5. Default 5. duration is accepted too
resolution no One of the model’s capabilities.resolutions, such as 720p. size is accepted too. Default is the model’s own
images no Reference images as https:// URLs, up to capabilities.max_reference_images. A single image is accepted too
aspect_ratio no Such as 16:9 or 9:16
Create
curl https://api.harperharbor.com/v1/videos \
-H "Authorization: Bearer hh_live_…" \
-H "Content-Type: application/json" \
-d '{"model":"seedance-2.0-fast","prompt":"Waves rolling onto a beach at sunset","seconds":5,"resolution":"720p"}'
Poll, then download
curl https://api.harperharbor.com/v1/videos/0199… -H "Authorization: Bearer hh_live_…"
curl -L -o wave.mp4 https://api.harperharbor.com/v1/videos/0199…/content \
-H "Authorization: Bearer hh_live_…"

GET /v1/videos/{id} returns the task. GET /v1/videos/{id}/content redirects (302) to the file once the video is ready, and returns 404 while it is still running, after it failed, or after the result expired.

Reference image URLs are passed to the model provider as they are; we do not fetch them ourselves, so they must be reachable from the public internet.

Image tasks and video tasks share one shape:

Field Meaning
id Task id
object image.generation.task or video
model The model id you called
status queued, in_progress, completed or failed
harbor_status A finer state: submitting, submitted, storing, succeeded, failed, rejected (refused by the model provider) or submit_unknown (the submission was lost)
created_at, completed_at Unix seconds; completed_at only once the task has finished
expires_at When the results will be deleted, unless you save them
units Images, or seconds of video, that the price applies to
price_tier The size or resolution tier the price was taken from
credits Credits charged. It stays 0 unless the task completed
data [{ "url": … }] once results are stored
error { "code", "message" } when the task failed
saved_asset_ids Personal file ids, once the results were saved

Result URLs point at our storage, not the model provider’s, and they are kept for 7 days. To keep them longer, copy them into your account’s personal files:

Terminal window
curl -X POST https://api.harperharbor.com/v1/media/tasks/0199…/save \
-H "Authorization: Bearer hh_live_…"
{ "id": "0199…", "object": "media.saved", "asset_ids": ["0199…"] }

Saved files count toward your 2 GiB personal storage and are not deleted after 7 days. Saving again returns the same files without copying them twice. Saving a task that has not completed returns 409; after the 7 days it returns 410, so download or save what you want to keep before then.

Send an Idempotency-Key header on generation requests. Repeating a request with the same key from the same API key returns the original task instead of starting a new one, and it is charged once. Use a new key when you deliberately want a second generation, for example after submit_unknown.

Media entries in GET /v1/models add pricing units, capabilities and a content policy:

{
"id": "gemini-3.1-flash-image",
"object": "model",
"owned_by": "harperharbor",
"type": "image",
"pricing": { "unit": "credits_per_image", "credits": { "1080p": 600, "1440p": 900, "2160p": 1200 } },
"capabilities": { "sizes": ["1920x1080", "1080x1920", "2560x1440", "…"], "edits": true, "max_images": 4, "max_reference_images": 4 },
"content_policy": { "nsfw": "suggestive", "real_person": true }
}

The prices above are illustrative; read the live ones from /v1/models or the console. Video entries use "unit": "credits_per_second" with prices per resolution, and their capabilities hold resolutions, durations and max_reference_images.

pricing.credits is keyed by tier: for images each tier covers a group of sizes, for videos each tier is a resolution. A task’s price_tier tells you which one applied.

Images are charged per image, videos per second, at the tier’s price times units. Credits are held when the task starts and charged only when it succeeds. Refusals by the model provider, failed generations and lost submissions (submit_unknown) cost nothing, and neither do requests we refuse before submitting.

Spending counts toward the key’s monthly credit limit, the same limit chat calls use.

Each model’s content_policy comes from our own tests against that model, not from what the provider advertises.

Field Values
nsfw none: adult content is refused. suggestive: adult, non-explicit content is accepted. explicit: explicit adult content is accepted
real_person true when the model accepts photos of real people as reference images

Prompts are screened before they reach the model provider. On a model with nsfw: "none", an adult prompt is refused up front with 400 and is not charged. Models that allow adult content pass such prompts on, and the model provider makes the final decision. Sexual content involving minors is refused on every model.

Screening reads the prompt only; reference images are checked by the model provider.

Errors use the OpenAI envelope {"error": {"type", "code", "message", "param"}}.

Status What happened What to do
400 A field is missing or not supported by this model; the message names it Check the model’s capabilities in /v1/models
400 code: content_policy_violation: screening refused the prompt on this model, or the model provider refused it. Not charged Change the prompt, or pick a model whose content_policy fits
401 The key is missing, revoked or expired Create a new key in the console
402 Out of credits, or the key hit its monthly limit Top up, or raise the limit
403 The model is not on this key’s list Add the model to the key in the console
404 No such model, or no such task for your account Take the id from /v1/models
409 Saving a task that has not completed Wait for completed, then save
410 The result was deleted after 7 days Generate it again
413 The result is larger than a personal file can be Download it instead
502 The model provider failed, or the submission was lost (code: submit_unknown). Not charged Retry; after submit_unknown, use a new Idempotency-Key
503 Content screening is briefly unavailable (only models that allow adult content wait for it), or image and video generation is not available yet. Not charged Retry shortly
507 Your personal storage is full Delete files, or download the result instead