Image Compose API
Combine 1–3 reference images with a text prompt to synthesize a new image. Common use cases: place a character into a different scene, compose multiple products in one frame, style-transfer composites, and so on.
Editing a single image? Use the Image Edits API — outfit swaps, scene replacements, variants.
Pure text-to-image? Use the Image Generation API.
When to use which endpoint
| Scenario | Endpoint | Protocol |
|---|---|---|
| Pure text-to-image | /v1/images/generations | JSON |
| Single-image edit | /v1/images/edits | JSON + image_key or generation_id |
| Multi-image compose | /v1/images/compose | JSON + image_keys[] / generation_ids[] / image_urls[] (1–3) |
Request
POST /v1/images/compose
Headers
Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx
Content-Type: application/json
x-api-key (Anthropic SDK style) is also supported.
Body
| Parameter | Type | Required | Description |
|---|---|---|---|
model | string | ✅ | Model name, e.g. gpt-image-2 |
prompt | string | ✅ | Text instruction, up to 32000 characters |
image_keys | string[] | ⚠️ | List of R2 keys obtained from the File Upload API — at least one of image_keys / generation_ids / image_urls is required |
generation_ids | string[] | ⚠️ | List of UUIDs from previous /v1/images/{generations,edits,compose} responses — at least one of image_keys / generation_ids / image_urls is required |
image_urls | string[] | ⚠️ | Inline image list — each entry is either an https://... URL or a data:image/(jpeg|png|gif|webp);base64,<data> data URI. No upload step required. At least one of image_keys / generation_ids / image_urls is required |
n | integer | ❌ | Number of images, default 1, max determined by model config |
size | string | ❌ | Image size, same as Image Generation API |
Reference image total: image_keys.length + generation_ids.length + image_urls.length must be between 1 and 3. All three arrays can be mixed. Duplicate references (including the same image across arrays) are rejected.
image_urls security constraints:
https://URLs must use HTTPS. Tokensmart blocks private / loopback / link-local / CGN addresses, blocks redirects, blocks userinfo (user:pass@host), validates the responseContent-Typeisimage/(jpeg\|png\|gif\|webp), enforces a 30-second timeout, and caps each fetched image at 25MB.data:URIs must be a validdata:image/(jpeg\|png\|gif\|webp);base64,...string (≤35MB), decode to ≤25MB of raw bytes, and the file's magic bytes must match the declared MIME.- Inline images flow through Workers memory only — they are NOT written to R2, do NOT count toward your 200MB file quota, and do NOT appear in
/api/images/history.
Full Example
Compose two previously-generated images:
{
"model": "gpt-image-2",
"prompt": "Place the person from image 1 into the scene from image 2, keep clothing, unify lighting",
"generation_ids": [
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002"
],
"n": 1
}
Mix uploaded image with generated image:
{
"model": "gpt-image-2",
"prompt": "Print this logo on the product, on a white wooden table background",
"image_keys": ["files/<userId>/<key>.png"],
"generation_ids": ["<uuid>"],
"n": 1
}
Pass base64 inline images (no upload step):
{
"model": "gpt-image-2",
"prompt": "Place the person from image 1 into the scene from image 2, keep clothing, unify lighting",
"image_urls": [
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...",
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ..."
],
"n": 1
}
Or pass public HTTPS URLs:
{
"model": "gpt-image-2",
"prompt": "Place the person into this scene",
"image_urls": [
"https://example.com/portrait.jpg",
"https://example.com/scene.jpg"
],
"n": 1
}
Response
{
"created": 1776864985,
"data": [
{
"b64_json": "iVBORw0KGgo...",
"revised_prompt": "..."
}
],
"picklyone_generation_ids": ["00000000-0000-0000-0000-000000000099"]
}
| Field | Type | Description |
|---|---|---|
data[i].b64_json | string | base64-encoded PNG (no data: prefix — decode directly with base64) |
data[i].revised_prompt | string | null | Model's rewritten prompt (debug-only, may be null) |
picklyone_generation_ids[i] | string | UUID of the generated image — can be passed as generation_id to a subsequent /v1/images/{compose,edits} call for chained composition |
Billing
Per-image billing, identical to the Image Generation API: actualCount × price_per_image. Pre-deduction freezes balance for the requested n; settlement charges for actually returned images; the difference is auto-refunded.
Python Example
import requests, base64
API_KEY = "pk_live_xxxxxxxxxxxxxxxx"
BASE = "https://api.tokensmart.ai"
resp = requests.post(
f"{BASE}/v1/images/compose",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={
"model": "gpt-image-2",
"prompt": "Place the person from image 1 into the scene from image 2",
"generation_ids": [
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002",
],
"n": 1,
},
timeout=300,
).json()
for i, item in enumerate(resp.get("data", [])):
if item.get("b64_json"):
with open(f"out_{i}.png", "wb") as f:
f.write(base64.b64decode(item["b64_json"]))
print(f"saved out_{i}.png — generation_id: {resp['picklyone_generation_ids'][i]}")
cURL Example
curl https://api.tokensmart.ai/v1/images/compose \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-image-2",
"prompt": "Place the person from image 1 into the scene from image 2",
"generation_ids": [
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002"
]
}'
Node.js Example
import fs from "fs";
const API_KEY = "pk_live_xxxxxxxxxxxxxxxx";
const BASE = "https://api.tokensmart.ai";
const resp = await fetch(`${BASE}/v1/images/compose`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-image-2",
prompt: "...",
image_keys: ["files/<userId>/<key>.png"],
generation_ids: ["<uuid>"],
n: 1,
}),
}).then(r => r.json());
for (const [i, item] of resp.data.entries()) {
if (item.b64_json) {
fs.writeFileSync(`out_${i}.png`, Buffer.from(item.b64_json, "base64"));
}
}
Limits
| Item | Limit |
|---|---|
| Request body | 100 MB (sized for inline image_urls; if you only use image_keys / generation_ids, requests are far smaller) |
| Per reference image | 25 MB (same cap for R2-sourced and inline) |
Single image_urls data URI string | 35 MB (decodes to ≈25 MB of raw bytes) |
Single image_urls HTTPS URL | 2048 characters |
| Reference images combined | 30 MB (R2-sourced + inline) |
| Reference image count | 1–3 (image_keys + generation_ids + image_urls combined) |
| Images per request | Determined by model's max_n_per_request (n above this returns 400 invalid_request) |
| Upstream timeout | Typically 30–90 seconds |
| Rate limit | 30 requests/min/user (shared with other image endpoints) |
Error Codes
| HTTP | Code | Description |
|---|---|---|
| 400 | invalid_request | Field format invalid / n out of range / size malformed |
| 400 | invalid_source | Zero references, more than 3, or none of the three arrays provided |
| 400 | invalid_image_key | image_key malformed or doesn't belong to the caller |
| 400 | invalid_generation_id | generation_id is not a valid UUID |
| 400 | invalid_url | image_urls entry is not https:// or data:image/..., or URL exceeds 2048 chars |
| 400 | invalid_data_uri | image_urls data URI is malformed (MIME not in allowlist or base64 payload missing) |
| 400 | image_too_large | image_urls data URI string exceeds 35MB, or decoded bytes exceed 25MB |
| 400 | MAGIC_MISMATCH | image_urls actual file magic bytes do not match the declared MIME (anti-spoofing) |
| 400 | IMAGE_TYPE_UNSUPPORTED | image_urls remote response Content-Type is not in image/(jpeg|png|gif|webp) allowlist |
| 400 | NON_HTTPS / BLOCKED_HOST / PRIVATE_IP / REDIRECT_BLOCKED / NOT_IMAGE / TOO_LARGE / UPSTREAM_ERROR | image_urls SSRF / fetch validation failed (see security constraints above) |
| 400 | IMAGES_TOO_LARGE_TOTAL | Sum of all three sources exceeds 30MB |
| 400 | duplicate_reference | Same image referenced multiple times (within any single array, or across image_keys + generation_ids) |
| 400 | content_policy_violation | Prompt triggered content moderation (pre-deduction refunded) |
| 400 | no_image_generated | Model returned zero images for a non-policy reason (pre-deduction refunded) |
| 401 | invalid_api_key | Invalid API key |
| 402 | insufficient_balance | Insufficient balance |
| 404 | model_not_found | Model does not exist or is inactive |
| 404 | generation_not_found | A UUID in generation_ids does not exist or doesn't belong to the caller |
| 408 | upstream_timeout | Request timed out (pre-deduction refunded) |
| 409 | generation_not_ready | Referenced generation_id is still being processed — retry later |
| 413 | image_too_large | A single data URI string exceeds 35MB (rejected before billing) |
| 422 | generation_not_ready | Referenced generation_id belongs to a failed generation and cannot be used as a reference |
| 429 | rate_limit_exceeded | Rate limit exceeded |
| 502 | upstream_error | Service error (pre-deduction refunded) |
Notes
b64_jsonis raw base64 (nodata:image/png;base64,prefix) — decode directly withBuffer.from(b64, "base64")or Pythonbase64.b64decode(b64)- Auto-persisted to history — generated images are automatically saved to Tokensmart history and queryable via
/api/images/history. Thepicklyone_generation_idsin the response are the persisted UUIDs. - Chained composition — pass
picklyone_generation_ids[i]from a prior call as ageneration_idin the next call to iterate within the same context.
Related Endpoints
- File upload (used with
image_keys): File Upload API - Single-image edit: Image Edits API
- Text-to-image: Image Generation API