Introduction
AIport is a unified AI API gateway for Indonesian developers — one API key, one IDR balance, every modality.
Why AIport.
- Top up in IDR. No FX markup, no monthly fee, no expiry. Each call deducts exactly what it costs from your balance.
- One key, every modality. Text, image, image-to-image, and video generation behind a single
Authorization: Bearerheader. - Provider routing & auto-refund. When a provider errors mid-job, credits are refunded automatically.
- OpenAI-compatible text endpoint.
POST /v1/chat/completionsis a drop-in for the OpenAI SDK, includingstream: true.
Base URL
https://aiport.id
All endpoints are JSON unless noted. Async modalities (image, img2img, video) return a job_id you poll via GET /v1/jobs/{job_id}.
Authentication
Every /v1/* call requires a Bearer token. Create one in your dashboard at /dashboard/api-keys.
Send your API key in the Authorization header:
Authorization: Bearer aip_live_...
Keys are scoped to your account and bill against your IDR balance. Rotate compromised keys immediately by deleting and re-creating from the dashboard. Never embed an API key in client-side code — proxy through your own backend.
Quickstart
Every endpoint is plain HTTP + JSON. The only thing that varies is whether the response is synchronous or asynchronous.
Sync vs async — read this first.
Every AIport endpoint is a normal HTTP request: POST (or GET for jobs), Authorization: Bearer header, JSON body. The difference is only in how the result comes back:
- Synchronous —
/v1/chat/completions(alias/v1/text/completions). The response body *is* the result, unless you opt intostream: true. Typical latency: 1–10 s. - Asynchronous —
/v1/image/generate,/v1/image/img2img, all/v1/video/*. The POST returns immediately with{ "job_id": "...", "status": "pending" }. You then pollGET /v1/jobs/{job_id}every few seconds untilstatusbecomesdone(readresult_url) orfailed. Typical latency: 30–120 s for video, 5–30 s for image.
Async exists because images and videos can take a minute or more — too long to hold open a single HTTP request reliably.
1. Synchronous: text completion
The text endpoint is OpenAI-compatible — point the OpenAI SDK at https://aiport.id/v1, or call it directly:
curl -X POST https://aiport.id/v1/chat/completions \
-H "Authorization: Bearer $AIPORT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"Hello"}]}'Response (200) — final answer is already inside choices[0].message.content:
{ "choices": [ { "message": { "role": "assistant", "content": "Hi!" } } ], "usage": { ... } }Add "stream": true to receive the answer incrementally as Server-Sent Events (OpenAI chat.completion.chunk objects, ending with data: [DONE]). See the Text completions section for the chunk shape.
2. Asynchronous: image generation — step by step
Step 2a — POST the request. Same shape as text, just a different URL:
curl -X POST https://aiport.id/v1/image/generate \
-H "Authorization: Bearer $AIPORT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-image-2","prompt":"Sunset over Mount Bromo","resolution":"2k"}'Response (202) — note: no image yet, only a job id to poll:
{ "job_id": "job_01HX...", "status": "pending" }Step 2b — GET the job until it's done:
curl https://aiport.id/v1/jobs/job_01HX... \ -H "Authorization: Bearer $AIPORT_API_KEY"
Response (200) — when finished, status is done and result_url points at the image. Its host can vary between jobs and isn't guaranteed to be under aiport.id — don't hardcode a domain allowlist against it:
{ "job_id": "job_01HX...", "status": "done", "result_url": "https://result.example/..." }3. Same thing, scripted with a poll loop (optional)
The block below is just the two-step flow wrapped in a shell loop — useful for trying things in a terminal:
# Submit
JOB=$(curl -s -X POST https://aiport.id/v1/image/generate \
-H "Authorization: Bearer $AIPORT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-image-2","prompt":"Sunset over Mount Bromo","resolution":"2k"}' | jq -r .job_id)
# Poll every 3s
while true; do
STATUS=$(curl -s https://aiport.id/v1/jobs/$JOB \
-H "Authorization: Bearer $AIPORT_API_KEY")
echo "$STATUS"
case "$(echo $STATUS | jq -r .status)" in
done|failed) break ;;
esac
sleep 3
doneIn production, replace the shell loop with a job-polling routine in your language of choice (or a webhook on your side if you don't want to poll).
Text completions
Chat-style generation with an OpenAI-compatible request/response shape. POST /v1/chat/completions (alias: /v1/text/completions). Synchronous by default, or set stream: true for Server-Sent Events. GET /v1/models lists the available text models for OpenAI-compatible clients.
/v1/chat/completionsChat-style text generation. OpenAI-compatible request and response shapes — a drop-in for the OpenAI SDK (set base_url to https://aiport.id/v1). The legacy path /v1/text/completions is a backward-compatible alias.
none low medium highauto none requiredcurl -X POST https://aiport.id/v1/chat/completions \
-H "Authorization: Bearer $AIPORT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-v4-flash",
"messages": [
{"role": "user", "content": "Write a haiku about Jakarta traffic."}
]
}'{
"id": "chatcmpl_...",
"object": "chat.completion",
"created": 1716480000,
"model": "deepseek-v4-flash",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "..."},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 12,
"completion_tokens": 38,
"total_tokens": 50,
"prompt_tokens_details": {"cached_tokens": 0},
"completion_tokens_details": {"reasoning_tokens": 0}
}
}Synchronous unless stream is true. prompt_tokens_details.cached_tokens reflects prompt-cache hits (billed at a reduced input rate when the upstream reports them, currently DeepSeek); completion_tokens_details.reasoning_tokens counts reasoning output. Standard OpenAI params (top_p, stop, presence_penalty, frequency_penalty, seed, …) are forwarded verbatim to OpenAI-compatible upstreams; unknown params are dropped.
With stream: true the response is text/event-stream. Each event is a chat.completion.chunk: the first carries delta.role, the rest carry delta.content, then a chunk with finish_reason: stop, a final usage-only chunk (choices: []), and the data: [DONE] terminator. If the upstream fails mid-stream the connection ends with an in-band error event — there is no provider switch once the first byte is sent, and a stream that already emitted output is not refunded.
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":1716480000,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":1716480000,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":1716480000,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":1716480000,"model":"deepseek-v4-flash","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":38,"total_tokens":50,"prompt_tokens_details":{"cached_tokens":0},"completion_tokens_details":{"reasoning_tokens":0}}}
data: [DONE]400 invalid_request_errormessages missing, content too long, invalid temperature, or reasoning_effort on a non-reasoning model.400 upstream_invalid_requestThe upstream model rejected the request's parameters (e.g. tools with a non-"none" reasoning_effort on a GPT reasoning model). The message explains why; not retryable as-is. Refunded.401 unauthorizedMissing or invalid API key.404 MODEL_NOT_FOUNDRequested model id does not exist or is disabled.429 insufficient_quotaIDR balance cannot cover the request — top up to continue.502 provider_errorUpstream AI provider failed. Refunded unless a stream already emitted output. A stream that fails before its first chunk returns a normal JSON error with the matching HTTP status instead of an SSE event.503 provider_unavailableEvery upstream provider for the model is down or failing. Refunded; safe to retry later.
/v1/modelsList the models available to your key. With no query it returns text/chat models in the OpenAI models shape, so OpenAI-compatible clients can verify the base URL and populate model pickers. Add ?modality= to list any modality with its current IDR price and supported options. GET /v1/models/{model} retrieves a single text model.
curl "https://aiport.id/v1/models?modality=video" \ -H "Authorization: Bearer $AIPORT_API_KEY"
{
"object": "list",
"data": [
{
"id": "seedance-2.0-fast",
"object": "model",
"created": 1716480000,
"owned_by": "aiport",
"display_name": "Seedance 2.0 Fast",
"modality": "video",
"recommended": true,
"features": {
"endpoint": "/v1/video/generate",
"variant": "text2video",
"resolutions": ["480p", "720p"],
"aspect_ratios": ["adaptive", "16:9", "4:3", "1:1", "3:4", "9:16", "21:9"],
"durations": [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
},
"pricing": {
"currency": "IDR",
"peak_active": false,
"peak_multiplier": 1,
"kind": "per_second",
"per_second": {
"480p": { "current_price": 320, "base_price": 320 },
"720p": { "current_price": 640, "base_price": 640 }
}
}
}
]
}Without modality, only enabled text/chat models are returned, as bare { id, object, created, owned_by } objects — the same ids you can pass as model to /v1/chat/completions. With modality, prices are whole IDR: current_price is what a request costs right now (any peak-hour multiplier already applied), base_price is the off-peak price, and peak_active / peak_multiplier tell you whether a peak window is on. pricing.kind is one of per_token (text: per_request plus input/cached_input/output per 1M tokens), per_request (flat per image), per_resolution (per_image by 1k/2k/4k), per_second (video, per output resolution), per_duration (per_video by duration in seconds), or multimodal_per_second (no_reference / with_reference / with_reference_addon per second by resolution, plus min_billable_seconds). features.endpoint names the endpoint that accepts the model. recommended marks a model we suggest as a default for its modality. created is a Unix timestamp in seconds. GET /v1/models/{model} returns a single text model object, or 404 model_not_found if the id is unknown, disabled, or not a text model.
400 invalid_modalitymodality is not one of text, image, img2img, video.401 unauthorizedMissing or invalid API key.403 forbiddenAccount suspended.404 model_not_foundGET /v1/models/{model}: id does not exist, is disabled, or is not a text model.
Tool calling
Let a model call your functions. Works on /v1/chat/completions with the standard OpenAI tools / tool_choice fields — pass tool definitions, run the call yourself, feed the result back. Supported across OpenAI-compatible, DeepSeek, Gemini, and Claude models.
Tool calling is a round-trip you drive. You describe the functions the model is allowed to call; the model replies with the function name and JSON arguments; you execute it and send the result back; the model uses that result to answer.
1. Send a request with tools.
curl -X POST https://aiport.id/v1/chat/completions \
-H "Authorization: Bearer $AIPORT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-v4-flash",
"messages": [
{"role": "user", "content": "What is the weather in Jakarta?"}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"}
},
"required": ["city"]
}
}
}
]
}'2. The model asks to call the tool. Instead of content, the assistant message carries tool_calls and finish_reason is tool_calls. arguments is a JSON string you must parse:
{
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"Jakarta\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
]
}3. Run the function and reply with a tool message. Append the assistant's tool_calls message verbatim, then one role: "tool" message per call, echoing its tool_call_id. Keep sending tools on this follow-up request too — resubmit it on every turn of the conversation, exactly as the OpenAI SDK does, or some models reject the replayed tool history:
curl -X POST https://aiport.id/v1/chat/completions \
-H "Authorization: Bearer $AIPORT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-v4-flash",
"messages": [
{"role": "user", "content": "What is the weather in Jakarta?"},
{"role": "assistant", "content": null, "tool_calls": [
{"id": "call_abc123", "type": "function",
"function": {"name": "get_weather", "arguments": "{\"city\":\"Jakarta\"}"}}
]},
{"role": "tool", "tool_call_id": "call_abc123", "content": "{\"temp_c\":31,\"sky\":\"partly cloudy\"}"}
],
"tools": [
{"type": "function", "function": {"name": "get_weather",
"description": "Get the current weather for a city",
"parameters": {"type": "object",
"properties": {"city": {"type": "string", "description": "City name"}},
"required": ["city"]}}}
]
}'The model now replies with normal content (finish_reason: "stop"):
{ "choices": [ { "message": { "role": "assistant", "content": "It's 31°C and partly cloudy in Jakarta." }, "finish_reason": "stop" } ] }Forcing or disabling calls. Use tool_choice: "auto" (default when tools is set) lets the model decide, "required" forces some tool call, "none" forces a plain-text reply, and { "type": "function", "function": { "name": "get_weather" } } forces one specific function. Set parallel_tool_calls: false to limit the model to one call per turn.
Claude models. Claude Sonnet 5.5, Opus 5.5, and Fable 5.1 don't support forced tool calls. On these models "required" and a named function are sent as "auto" plus a system instruction telling the model to call the tool. The model almost always follows it, but check finish_reason rather than assuming a tool_calls reply. Other Claude models (Haiku 4.5, Sonnet 5, Opus 5) honor forced calls normally.
Streaming. With stream: true, tool calls arrive incrementally on delta.tool_calls (each fragment carries an index; function.arguments is concatenated across chunks). Accumulate by index until the terminating chunk with finish_reason: "tool_calls".
Round-trip integrity. Send each assistant tool_calls message back exactly as you received it — including the id on every call — and pair it with a matching tool_call_id in your tool reply. Some models (e.g. Gemini reasoning models) attach opaque state to the call id that must survive the round-trip, so don't rewrite or drop it.
Image generation
Text-to-image. Async — returns a job_id; poll /v1/jobs/{job_id} for the result. Pricing varies by resolution tier on supporting models.
/v1/image/generateText-to-image. Returns an async job_id — poll GET /v1/jobs/{job_id} until status is done.
1:1 3:2 2:3 4:3 3:4 16:9 9:161k 2k 4kcurl -X POST https://aiport.id/v1/image/generate \
-H "Authorization: Bearer $AIPORT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-image-2",
"prompt": "A traditional Balinese temple at golden hour, cinematic",
"aspect_ratio": "16:9",
"resolution": "2k"
}'{
"job_id": "job_01HX...",
"status": "pending"
}Poll GET /v1/jobs/{job_id} every 2–5 seconds until status is 'done' (then read result_url) or 'failed'. Image generation typically takes 10–60 seconds at 1k, 30–120 seconds at 2k, and up to 180 seconds at 4k. Jobs that exceed 10 minutes are auto-failed and credits are refunded.
400 invalid_requestMissing prompt, invalid aspect_ratio, or invalid resolution.400 invalid_requestUnrecognized field in the request body (e.g. a typo or deprecated field name).401 unauthorizedMissing or invalid API key.402 insufficient_balanceIDR balance cannot cover the request.404 MODEL_NOT_FOUNDRequested model id does not exist or is disabled.
Image-to-image
Edit or transform an existing image. Upload reference images first (or pass any HTTPS URL), then call /v1/image/img2img.
/v1/image/uploadUpload a reference image to be used by /v1/image/img2img. Returns a hosted URL — pass that URL (not an id) in the img2img call's images field.
curl -X POST https://aiport.id/v1/image/upload \ -H "Authorization: Bearer $AIPORT_API_KEY" \ -F "file=@./reference.jpg;type=image/jpeg"
{
"url": "https://cdn.aiport.id/uploads/...",
"type": "image"
}No upload_id — the returned url is what you pass to img2img or other endpoints that accept a reference image URL. The server also detects the file type from its content, so a plain -F "file=@./reference.jpg" (no ;type=) works too — the explicit ;type= above is only needed if your client sends application/octet-stream for this extension (some curl builds do this for .webp).
400File content does not match a declared, specific Content-Type.401 unauthorizedMissing or invalid API key.413File exceeds the per-modality size limit.415Unsupported or undetectable file type (not JPEG, PNG, or WebP).
/v1/image/img2imgImage-to-image editing. Provide reference image URL(s) (from /v1/image/upload or any HTTPS URL) plus a prompt. Returns an async job_id.
1:1 3:2 2:3 4:3 3:4 16:9 9:161k 2k 4kcurl -X POST https://aiport.id/v1/image/img2img \
-H "Authorization: Bearer $AIPORT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Make it look like a Studio Ghibli scene",
"images": ["https://cdn.aiport.id/uploads/.../ref.jpg"],
"resolution": "2k"
}'{ "job_id": "job_01HX...", "status": "pending" }Poll GET /v1/jobs/{job_id} every 2–5 seconds. Image-to-image typically takes 10–60 seconds at 1k and 30–180 seconds at higher resolutions. Jobs that exceed 10 minutes are auto-failed and credits are refunded.
400Missing prompt, invalid image URL, or unsupported model parameter.400 invalid_requestUnrecognized field in the request body (e.g. a typo or deprecated field name).401 unauthorizedMissing or invalid API key.402 insufficient_balanceIDR balance cannot cover the request.
Video generation
Cinematic video generation, billed per generated second by output resolution. Three flavors: text-to-video, image-to-video, and multimodal (multiple references).
/v1/video/generateText-to-video. Async — returns job_id, then poll /v1/jobs/{job_id} until done. Billing is per generated second by output resolution.
480p 720p 1080p 2k 4k4 5 6 7 8 9 10 11 12 13 14 15adaptive 16:9 4:3 1:1 3:4 9:16 21:9curl -X POST https://aiport.id/v1/video/generate \
-H "Authorization: Bearer $AIPORT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "seedance-2.0-fast",
"prompt": "A drone shot flying over rice terraces at sunrise",
"resolution": "1080p",
"duration": 6,
"ratio": "16:9"
}'{ "job_id": "job_01HX...", "status": "pending" }Video jobs typically take 30–120 seconds. Poll every 3–5 seconds.
400Invalid resolution, duration, ratio, or missing prompt.400 invalid_requestUnrecognized field in the request body (e.g. a typo or deprecated field name).401 unauthorizedMissing or invalid API key.402 insufficient_balanceIDR balance cannot cover the request.
/v1/video/img2videoImage-to-video. Provide a first frame (and optional last frame) URL; the model interpolates motion. Async job. Two request shapes depending on model: standard models (default) use the fields below. Sora 2 (`img2video-sora-2`) uses a different shape instead — `image_url` (required, https) in place of `first_frame_url`/`last_frame_url`, no `resolution` or `conversion_slots`, and `duration` restricted to 4, 8, or 12 seconds at flat per-tier pricing (not per-second). See /pricing for which tier applies to each model.
480p 720p 1080p 2k 4k4 5 6 7 8 9 10 11 12 13 14 15all firstFrameUrl lastFrameUrlcurl -X POST https://aiport.id/v1/video/img2video \
-H "Authorization: Bearer $AIPORT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "seedance-2.0-fast-img2video",
"prompt": "Camera slowly pushes in, leaves drift across frame",
"first_frame_url": "https://cdn.aiport.id/uploads/.../start.jpg",
"resolution": "720p",
"duration": 5
}'{ "job_id": "job_01HX...", "status": "pending" }400Missing first_frame_url, invalid duration/resolution.400 invalid_requestUnrecognized field in the request body (e.g. a typo or deprecated field name).401 unauthorizedMissing or invalid API key.402 insufficient_balanceIDR balance cannot cover the request.
/v1/video/uploadUpload a reference video to be used by /v1/video/multimodal. Returns a hosted URL with its duration already measured — pass that URL in video_urls and you won't need video_durations for it.
curl -X POST https://aiport.id/v1/video/upload \ -H "Authorization: Bearer $AIPORT_API_KEY" \ -F "file=@./scene.mp4;type=video/mp4"
{
"url": "https://cdn.aiport.id/uploads/...",
"type": "video",
"duration_seconds": 8
}duration_seconds is measured server-side and stored against this url — pass the url in video_urls on /v1/video/multimodal without also needing video_durations for it. The server also detects the file type from its content, so a plain -F "file=@./scene.mp4" (no ;type=) works too — the explicit ;type= above is only needed if your client sends application/octet-stream for this extension (curl's own MIME table on most systems has no entry for .mp4/.mov, so it does this by default). If upload fails with "Only MP4 and MOV are supported" for a file you know is a real MP4, this is almost always the cause.
400File content does not match a declared, specific Content-Type.401 unauthorizedMissing or invalid API key.413File exceeds the per-modality size limit.415Unsupported or undetectable file type (not MP4 or MOV).422 unparseable_videoCould not read the video's duration. Re-encode with faststart and try again.400 invalid_reference_videoVideo dimensions are outside the reference-video bounds: total pixels must be 409,600–2,086,876 (roughly 480p to 1080p), each side 300–6000px.
/v1/audio/uploadUpload a reference audio clip to be used by /v1/video/multimodal. Returns a hosted URL — pass that URL in audio_urls.
curl -X POST https://aiport.id/v1/audio/upload \ -H "Authorization: Bearer $AIPORT_API_KEY" \ -F "file=@./voiceover.mp3;type=audio/mpeg"
{
"url": "https://cdn.aiport.id/uploads/...",
"type": "audio"
}No upload_id — the returned url is what you pass in audio_urls on /v1/video/multimodal. The server also detects the file type from its content, so a plain -F "file=@./voiceover.mp3" (no ;type=) works too — the explicit ;type= above is only needed if your client sends application/octet-stream for this extension (some curl builds do this for .mp3/.wav/.m4a).
400File content does not match a declared, specific Content-Type.401 unauthorizedMissing or invalid API key.413File exceeds the per-modality size limit.415Unsupported or undetectable file type (not MP3, WAV, or M4A).
/v1/video/multimodalMultimodal video generation — combine reference images, videos, and audio with a text prompt. Async job; per-resolution per-second billing with a min-billable-seconds floor (see /pricing). • seedance-2.0-fast-multimodal (default): image, video, and audio references — 480p/720p/1080p/2k/4k, 4–15s, at least one reference required. • minimax-h3-multimodal: image and audio references only (no video) — 480p/720p/1080p only, 1080p capped at 10s, references optional. • Deprecated: earlier docs used images/videos/audios for these fields. The server never reads those names — use image_urls/video_urls/audio_urls instead, or the request is now rejected with 400 invalid_request.
480p 720p 1080p 2k 4k4 5 6 7 8 9 10 11 12 13 14 15all image1 image2 image3 image4 image5 image6 image7 image8 image9 video1 video2 video3curl -X POST https://aiport.id/v1/video/multimodal \
-H "Authorization: Bearer $AIPORT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "seedance-2.0-fast-multimodal",
"prompt": "Subject from image1 walking through scene from video1",
"image_urls": ["https://cdn.aiport.id/uploads/.../subject.jpg"],
"video_urls": ["https://cdn.aiport.id/uploads/.../scene.mp4"],
"resolution": "720p",
"duration": 6,
"ratio": "16:9"
}'{ "job_id": "job_01HX...", "status": "pending" }Billing when a reference asset is supplied: • seedance-2.0-fast-multimodal: 'with reference' tier + per-duration min-billable floor — see /pricing. • minimax-h3-multimodal: flat per-resolution rate regardless of references — see /pricing.
400Too many reference assets, invalid URL, or invalid resolution/duration/ratio.400 invalid_requestUnrecognized field in the request body — e.g. images/videos/audios instead of image_urls/video_urls/audio_urls.400 unknown_reference_videoA video_urls entry has no known duration — its matching video_durations entry is missing and the URL wasn't submitted with a duration before.401 unauthorizedMissing or invalid API key.402 insufficient_balanceIDR balance cannot cover the request.
Polling jobs
Async modalities (image, img2img, video) return a job_id. Poll for status here.
/v1/jobs/{job_id}Poll an async job (image, img2img, or video). Returns the current status, the job's type and model, credits charged, and — when done — the result URLs.
curl https://aiport.id/v1/jobs/job_01HX... \ -H "Authorization: Bearer $AIPORT_API_KEY"
{
"job_id": "job_01HX...",
"status": "done",
"result_url": "https://result.example/...",
"error_message": null,
"error_code": null,
"created_at": 1716480000000,
"job_type": "image",
"model_id": "gpt-image-2",
"result_urls": ["https://result.example/..."],
"credits_used": 1500,
"refunded": false,
"updated_at": 1716480042000
}status is one of: 'pending', 'processing', 'done', 'failed'. On 'failed', error_message is a human-readable reason (it may be the provider's own wording, e.g. a content safety rejection) and error_code is a stable category to branch on: 'content_safety', 'model_unavailable', 'invalid_input', 'timeout', 'enqueue_failed', 'insufficient_credits', or 'provider_failed' (anything else). error_code is null while the job hasn't failed, and on jobs that failed before it was introduced. Image and video credits are auto-refunded on failure. job_type is one of: 'image' (text-to-image and image-to-image), 'video', 'template', 'text', 'chat'. result_urls lists every output URL (result_url is the first one, kept for backward compatibility); it is an empty array until the job is done. credits_used is the IDR amount charged for the job (deducted when the job was submitted). A job whose credits were returned reports refunded: true and credits_used: 0 — normally a failed job, but a video refunded after a timeout that later completes also stays done with refunded: true (no re-charge). result_url's host can vary between jobs and is not guaranteed to be under the aiport.id domain — don't hardcode a domain allowlist against it, just fetch whatever URL is returned. created_at and updated_at are Unix timestamps in milliseconds.
401 unauthorizedMissing or invalid API key.403 forbiddenAccount suspended.404 job_not_foundJob does not exist or does not belong to this API key's owner.
Balance
Check your remaining IDR credit balance.
/v1/balanceCheck your account's remaining IDR credit balance before starting a large batch of jobs.
curl https://aiport.id/v1/balance \ -H "Authorization: Bearer $AIPORT_API_KEY"
{
"balance": 125000,
"currency": "IDR",
"low_balance_threshold": 5000
}balance is an integer in IDR. When it drops below low_balance_threshold, top up at /dashboard/topup to avoid failed requests.
401 unauthorizedMissing or invalid API key.403 forbiddenAccount suspended.
Use AIport in Claude / ChatGPT
AIport is also a remote MCP server. Connect it to an AI app such as Claude or ChatGPT and ask for images or videos in plain language. Generations are paid from the same IDR balance.
Server URL
https://aiport.id/mcp
The server speaks MCP protocol version 2026-07-28 over Streamable HTTP (POST only) and signs you in with OAuth. You don't need an API key.
Connect from Claude
- Open Settings → Connectors → Add custom connector.
- Paste
https://aiport.id/mcpas the URL, then click Connect. - Sign in to AIport (email or Google). If you don't have an account yet, sign up, verify your email, then go back to Claude and click Connect again.
- Review the consent screen and click Allow.
Connect from ChatGPT or another MCP client
Add a custom remote MCP connector with the same URL and pick OAuth when you're asked how to authenticate. The client needs to support MCP 2026-07-28. Older clients get an unsupported-protocol-version error.
Clients that let you set request headers can skip OAuth and send Authorization: Bearer <api_key> instead. This grants every scope, and spend caps don't apply. They only cover OAuth connections.
Tools
| Tool | Scope | What it does |
| --- | --- | --- |
| list_models | models:read | Lists image, image-to-image, and video models with their current IDR price and supported options. |
| check_balance | balance:read | Returns your IDR balance and a top-up link. |
| generate_image | generate | Generates or edits an image. Waits up to about 40 s and returns the image URLs, or a job_id if it takes longer. Charged per image. |
| generate_video | generate | Starts a text-to-video, image-to-video, or multimodal job and returns a job_id. Charged per generated second. |
| get_job | jobs:read | Returns the status, result URLs, credits used, and refund state of a job. |
Scopes
The AI app requests all scopes at once, and you approve them together on the consent screen.
| Scope | Lets the app |
| --- | --- |
| models:read | See which AI models are available and what they cost. |
| balance:read | See your credit balance. |
| jobs:read | See the status and results of your generations. |
| generate | Generate images and videos, paid from your credit balance. |
| offline_access | Stay connected until you disconnect it. |
Spend caps
Every OAuth connection has two limits. A generation is refused before anything is charged if it would exceed either one:
- Per-request cap. The most a single generation may cost. Default Rp 50,000.
- Monthly cap. The most the connection may spend per calendar month (WIB). Default Rp 500,000. You can also set it to Unlimited.
Change either cap per app in Dashboard → Connected apps. The new cap applies from the next generation. Failed and refunded jobs don't count toward the monthly cap.
Revoking access
Open Dashboard → Connected apps and click Disconnect on the app. Its tokens stop working right away. To use it again, connect again and approve the consent screen. If an app doesn't use its access for 90 days, the access expires and the app asks you to sign in again.
Limits & billing
- Each user can make up to 60 MCP requests per minute. Above that, requests fail with a rate-limit error. Wait a minute and retry.
- Prices are the same as on
/v1. Failed jobs are refunded automatically. - If your account is suspended, every tool call returns an error.
Errors
All errors return an OpenAI-style JSON body of the form { "error": { "message": "...", "type": "...", "param": null, "code": "..." } } with a meaningful HTTP status.
| Status | Meaning |
|---|---|
| 400 | Invalid request: missing or malformed parameters. |
| 401 | Missing or invalid Authorization header. |
| 402 | Insufficient IDR balance on async modalities (image, img2img, video) — top up at /dashboard/topup. |
| 404 | Resource not found (unknown model id, unknown job_id, or job belongs to another user). |
| 413 | Upload exceeded the per-modality size limit. |
| 429 | insufficient_quota: IDR balance cannot cover an OpenAI-compatible text request — top up. Also returned when rate-limited — back off and retry. |
| 5xx | Upstream provider failure. Credits for failed async jobs are auto-refunded. |
Available models
Live catalog. See /pricing for IDR rates per tier.
| Model id | Display name |
|---|---|
deepseek-v4-flash | DeepSeek V4 Flash (Routing to v4.1) |
gemini-3.1-flash-lite | Gemini 3.1 Flash Lite |
kimi-k3 | Kimi K3 |
kimi-k3-fast | Kimi K3 Fast |
gemini-3.7-flash | Gemini 3.7 Flash |
gemini-3.5-flash-lite | Gemini 3.5 Flash Lite |
qwen-3.8-27b | Qwen3.8 27B |
glm-5.3 | GLM 5.3 |
claude-fable-5-1 | Claude Fable 5.1 |
gemini-3.8-flash | Gemini 3.8 flash |
gpt-6-astra | GPT 6 Astra |
deepseek-flash | Deepseek V4.1 Flash |
claude-opus-5-5 | Claude opus 5.5 |
gpt-6-sol | Gpt 6 sol |
gpt-6-luna | Gpt 6 luna |
claude-sonnet-5-5 | Claude Sonnet 5.5 |
claude-haiku-5-5 | Claude haiku 5.5 |
gemini-4-argon | Gemini 4 Argon |
minimax-m3.1-flash | MiniMax M3.1 Flash |
deepseek-v4-pro | DeepSeek V4 Pro |
glm-5.2 | GLM 5.2 |
glm-5.2-fast | GLM 5.2 Fast |
kimi-k2.7-code | Kimi K2.7 Code |
| Model id | Display name |
|---|---|
gpt-image-2 | GPT Image 2 |
gpt-image-2.5-flare | Gpt image 2.5 flare |
gpt-image-2.5-sunburst | Gpt image 2.5 sunburst |
nano-banana-2.1 | Nano Banana 2.1 |
| Model id | Display name |
|---|---|
gpt-image-2-img2img | GPT Image 2 |
gpt-image-2.5-flare-img2img | Gpt image 2.5 flare |
gpt-image-2.5-sunburst-img2img | Gpt image 2.5 sunburst |
nano-banana-2.1-img2img | Nano Banana 2.1 |
| Model id | Display name |
|---|---|
seedance-2.0-fast | Seedance 2.0 Fast |
seedance-2.0-fast-img2video | Seedance 2.0 Fast (img2video) |
seedance-2.0-fast-multimodal | Seedance 2.0 Fast (multimodal) |
txt2video-sora-2 | Sora 2 |
img2video-sora-2 | Sora 2 (Image to Video) |
minimax-h3-multimodal | Minimax H3 (Multimodal) |