Guide: generating and editing images
How the image endpoints behave as a class — the two request shapes, the three ways output size is spelled, what a reference picture adds to the bill, adapter weights, and how to read a rejection.
This guide is about the class of image endpoints, not about any one model. It explains the things that are the same across all of them, and — more usefully — the places where they differ from each other in ways that a request body written for one endpoint will not survive.
It is deliberately not a parameter table. Parameters have exactly one source: the inputSchema that the live catalog returns for the exact model you are calling. This page tells you how to read that schema and what it does not say out loud.
| If you want | Read instead |
|---|---|
| Your first image, end to end, with a key and a balance | Quickstart |
| The same journey for video, step by step | Tutorial: turn a picture into a video |
| Every field of one specific model | Its inputSchema — see Model catalog and schemas |
| Putting a face or a head from one picture into another | Subject swap |
MODEL_ID_FROM_CATALOG is a placeholder
Every example below writes MODEL_ID_FROM_CATALOG where a real model ID goes. Take that ID from the authenticated live catalog — GET /api/v1/models?modality=image — and require both enabled: true and available: true before you spend anything. Reading the catalog is free. Sending the placeholder itself only returns a "not found" error.
Two request shapes
Image work arrives as two tasks, and the catalog separates them:
- Generation endpoints end in
/text-to-image. You send words; you get a picture. - Editing endpoints end in
/edit. You send at least one picture as well.
Editing has two accepted spellings, and it is worth knowing both before you filter the catalog: endpoints are published with the edit suffix, while the tasks array classifies them as image-to-image. The task query parameter accepts either one. See Model catalog and schemas.
Both shapes use the same creation envelope, the same task lifecycle and the same result format as every other media model. What changes between them is input.
prompt is required on both
It is tempting to assume that an editing endpoint can work from the picture alone, and that prompt is therefore optional there. On the endpoints that accept a prompt at all, it is not: prompt is in the root required list of both shapes, and leaving it out is refused before anything is charged.
{
"model": "MODEL_ID_FROM_CATALOG",
"input": {
"image_urls": ["spicy://f/fil_01k3m8x9q2z4v7n5p6r8s0t1w3"]
}
}prompt: is requiredThe picture says what you are starting from; the prompt says what to do with it. An editing endpoint that received no instruction has nothing to act on.
There is one exception, and it is a whole family rather than a stray endpoint: some editing endpoints accept no prompt at all and take only pictures, because the operation is fixed by the endpoint itself. Those belong to the subject-swap toolkit, and they carry a toolkit field in the catalog so you can tell them apart; see Subject swap. For those, prompt: is not a supported field is the correct answer, not a bug.
The input-picture field has three names
Which one an endpoint uses is part of its schema, not something you can infer from the task:
| Field | Shape | What it usually means |
|---|---|---|
image_url | one image reference | There is exactly one subject picture, and the endpoint has no notion of a second |
image_urls | array of image references | The pictures are the material being edited or combined, in the order the instruction refers to them |
reference_image_urls | array of image references | The pictures steer a generation that would otherwise be text-only — they are guidance, not the subject |
reference_image_urls is the one that surprises people: it appears on generation endpoints, so a /text-to-image endpoint can still accept pictures. Do not assume that "text-to-image" means "no images in the request".
An image reference is a public HTTPS URL, a committed spicy:// URI from an upload, or a small data:image/…;base64,… URI. See Media for uploads, and Spicy Schema contract for the canonical field names.
Array limits vary far more than you would guess
The arrays above carry minItems and maxItems, the server enforces both, and the numbers differ by endpoint — today they range from an array that accepts exactly one entry to arrays that accept ten. An endpoint in the same family, with the same field name, can have a different ceiling.
image_urls: must contain at least 1 items
image_urls: must contain at most 3 itemsRead minItems and maxItems from the schema of the model you are calling. A parameter table that shows an array as a bare array with no entry shape and no length limit is the one row a reader cannot act on — which is exactly why this site does not ship one.
Output size is spelled three different ways
This is the first thing most people hit when they point an existing request body at a second endpoint.
There are three spellings in use across the image catalog, and an endpoint accepts exactly one of them:
| Spelling | Fields | Notes |
|---|---|---|
| Split | aspect_ratio and resolution | The frame shape and the pixel tier are chosen separately |
| Preset | size | One enum value that fixes both at once |
| Explicit | width and height | Integer pixels, each with its own minimum and maximum |
The split spelling is the most common on newer endpoints, and it is the more useful of the three: aspect_ratio composes the frame before generation rather than cropping afterwards, so you can change the shape without changing the pixel budget.
A field from the wrong spelling is refused, not ignored
Every image inputSchema sets additionalProperties: false. A field the endpoint does not list is rejected, which means a request body carried over from another endpoint fails outright rather than quietly falling back to a default.
It fails in both directions, and the message names the field:
size: is not a supported field
aspect_ratio: is not a supported fieldNothing is charged for either. The cost is not money, it is the assumption: you cannot write one input object and aim it at several image endpoints, and there is no "compatibility" spelling that all of them accept.
Which one selects a price tier
On a split-spelling endpoint the two fields usually play different roles in the bill:
resolutionnormally selects a price tier. It appears in the quote asvariant, and moving it moves the amount.aspect_rationormally does not. Reframing is not rescaling.
"Normally" is doing real work in those two sentences. The machine-readable answer is on the field itself: x-ui.affects_price: true, and — when the endpoint declares it — membership of x-pricing.variantFields. Check the schema rather than the habit. See Reading pricing and the annotation reference in Spicy Schema contract.
Allowed values are per endpoint too. A rejection lists them, which is a free way to see them:
aspect_ratio: must be one of: "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "21:9"Useful when you are exploring; not a substitute for reading enum, since the schema also carries the default that applies when you leave the field out.
What a reference picture adds to the bill
This is the quiet one, and it is worth a section of its own because nothing in the response points at it.
The usual arithmetic for a media model is price × quantity, where the quantity is the value of quantityField in your input, or one unit when quantityField is empty. On image endpoints quantityField is most often num_outputs, and it behaves exactly as you would expect: ask for four pictures and the quote's quantity reads 4. That dimension is honest — you can see it move.
Some endpoints then add a separate charge for every input picture, and that one you cannot see move. Quote the same endpoint twice, once with a single reference and once with a full array, and you get:
- the same
unit; - the same
quantity; - the same
variant; - a different amount.
Three of the four fields an estimate is usually built from stay exactly where they were. An estimate computed from price × quantity under-reports, and nothing in the response flags it.
The machine-readable signature
A field marked x-ui.affects_price: true that is neither the quantityField nor listed in x-pricing.variantFields moves the amount without selecting a tier. For any endpoint like that, read catalog pricing as a floor and take the amount from a quote.
That is the same rule stated on Model catalog and schemas, and image editing is where you will meet it most often: the input-picture array is frequently marked this way.
How much it adds is endpoint-specific and follows no fixed proportion. Today the catalog contains endpoints where filling the array costs several times a single reference, and endpoints where extra references cost nothing at all — with no way to tell them apart except by asking. So do not price the array by analogy with a sibling endpoint, and do not price a full array from a single-reference quote.
Quote the body you are actually going to send
curl -sS https://api.spicyapi.ai/api/v1/jobs/quote \
-H "Authorization: Bearer $SPICY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"MODEL_ID_FROM_CATALOG","input":{"prompt":"your instruction","image_urls":["spicy://f/fil_01k3m8x9q2z4v7n5p6r8s0t1w3"]}}'A quote runs the same validation as creating the task, costs nothing, starts nothing and sets nothing aside. It returns estimatedCost, maxCharge, quantity and unit for that exact body. Change the array and quote again. Details, and how to recover when the response is lost, are in Quotes and compatibility; where a quote belongs in a real integration is in Production integration.
Adapter weights
Some endpoints let you apply your own fine-tuned weights on top of the base model. The canonical field is loras, or high_noise_loras and low_noise_loras when an endpoint exposes its stages separately.
An array of objects, with its own rules inside
These fields carry x-ui.widget: "object-list": an array whose items is a complete object schema in its own right. It has its own required, its own additionalProperties: false, and its own type, range and default for every sub-field. A typical entry names one set of weights and how strongly to apply it.
{
"model": "MODEL_ID_FROM_CATALOG",
"input": {
"prompt": "your scene",
"loras": [
{ "path": "https://weights.example.com/style.safetensors", "scale": 1 },
{ "path": "https://weights.example.com/character.safetensors", "scale": 0.6 }
]
}
}The entry shape is not fixed across endpoints. What counts as a weight file — a direct address, or a key from a built-in library — is narrowed by that endpoint's own items subschema, as are the sub-field names, how many entries fit and which of them are required. Read items, not this example. Spicy Schema contract describes the widget; Model catalog and schemas shows how to turn it into a nested table.
One rule inside these entries is easy to misread: defaults reach inside array entries. A sub-field that is both listed in items.required and carries a default is effectively optional — leave it out and the default is filled in before validation runs. A sub-field with no default is refused when missing. So items.required does not mean "must appear in the JSON I send".
Where the weights come from
path takes one of three things:
- A public link to the
.safetensorsfile, or, where the endpoint accepts it, a Hugging Face repo id such asowner/repo. The model downloads it at generation time, so the first call with new weights is measurably slower than later ones. - An upload. Upload the file as
application/octet-streamwith the direct upload flow, up to 2 GiB, and use the returnedspicy://f/…URI. Commit checks that it is a complete safetensors file. - An import. For a file that needs a sign-in, such as one in a private or gated Hugging Face repository or a Civitai file behind an API key, send the link and an access token to
POST /api/v1/files/import. SpicyAPI downloads the file once and returns aspicy://f/…URI. The token is used for that download only and is never stored.
curl -sS https://api.spicyapi.ai/api/v1/files/import \
-H "Authorization: Bearer $SPICY_API_KEY" \
-H "Content-Type: application/json" \
--data "$(jq -n --arg url "https://huggingface.co/OWNER/REPO/blob/main/your_lora.safetensors" --arg token "$HF_TOKEN" '{url: $url, token: $token}')"url can also be the repo id (OWNER/REPO) or the repository page, when the repository holds exactly one .safetensors file. The response has the same shape as a commit; put data.uri in loras[].path. Uploaded and imported weights are kept for one day, like every other upload, and are never shared with other accounts. After that, upload or import them again.
Submission checks the link, not the weights inside
Before a task is created, each weights link is probed. A link that needs a sign-in, returns 404 or opens a web page is refused with a 400, nothing is charged, and the message names the entry. Anything else passes, so a host that goes down later, or weights that fail to load, still reach you as a task failure.
Problems the probe cannot see come back later, as a task failure with an errorCode from the platform's nine public classes and a message that explains what went wrong; the amount held for the task is returned. See Errors.
The worst case is quieter still: weights built for a different base model can load without failing and change the output almost not at all. There is no error to read, only a picture that looks like the base model's.
So check at deploy time
Move the check out of the request path and into whatever runs when you ship:
- Reachability. A
HEADrequest against every weights address you are about to put into production. The platform probes each link when a task is created, but a link that works today can stop working next week, and a deploy-time check catches that before your traffic does. - Effect. One generation per new weights file, compared against the same prompt and seed without it. If the two are indistinguishable, the file is not doing what you think — verify that before a batch depends on it.
- Stability. Your addresses have to keep working for as long as they are in production. A weights file that disappears turns into task failures with no obvious cause, days after the deploy that was blamed for it.
Endpoint-specific advice about these fields — what a value is measured against, which setting makes a call noticeably slower, which inputs the schema accepts but you will regret — lives in paramNotes on the public catalogue, keyed by parameter name and available in all ten documentation languages. It is prose, never a rule; inputSchema still decides what is accepted. See Model catalog and schemas.
Asynchronous, like every other media model
Image endpoints are tasks, not request-and-response calls. You create a task, then collect the result by polling or by webhook; queued and running are non-terminal, and succeeded, failed and expired are terminal. Asynchronous tasks is the authority on the states.
Images finish faster than video, which tempts people into writing the call as though it were synchronous. Resist it: a bounded poll is short enough here to feel synchronous, but the code still has to handle a task that is still running when the wait budget runs out, and handing that to a webhook scales better than holding a request open. Polling uses the account’s read allowance, independently of task submission. Excessive polling can rate-limit status queries; see Rate limits.
Reading the result
A successful task lists its files in output.assets[]. Three things about that list on image endpoints:
- It can hold more than one file. Where
num_outputsis above one, you get one asset per picture. Write the handler as a loop from the start; a handler that readsassets[0]silently discards everything you paid for. mimedecides the extension, not the endpoint name and not the prompt. Where an endpoint exposesoutput_format, that field selects the encoding; where it does not, the encoding is the endpoint's own andmimeis the only way to know what you received.- A file still being copied has
pending: trueand nourl. Results are copied into SpicyAPI's own storage before they are handed out, which takes a few seconds after the task reports success. Read the task again rather than treating the missing link as a failure.
Download links last about twenty minutes and need no API key; read the task again for a fresh one. Generated files are kept for at most fourteen days, less if your account's retention is shorter. See Results and downloads and Retention and destruction.
Reading a rejection
A rejected request costs nothing and creates nothing, so the quote endpoint doubles as a free validator for the body you are about to submit.
Every problem is reported, not just the first. The message joins them with ; , so one rejected quote can tell you everything that is wrong with a body:
prompt: is required; loras.0.path: is required; loras.0.scale: must be at most 4; bogus: is not a supported fieldA problem inside an array points at the entry. The pointer is field.index.subfield, with a zero-based index:
loras.1.scale: must be at most 4Pass that pointer through to whoever has to fix it. With several entries in flight, knowing which one is out of range is the difference between a one-line fix and a bisect.
The shapes you will meet:
| Message | What it means | What to do |
|---|---|---|
X: is required | X is in the root required list | Add it. On editing endpoints this is usually prompt or the picture field |
X: is not a supported field | X is not in properties; unknown fields are refused, never ignored | Remove it, or use this endpoint's spelling — see Output size |
X: must be one of: … | The value is outside enum | The message lists the accepted values; enum and default are in the schema |
X: must contain at least N items | Below minItems | An array that the endpoint requires cannot be sent empty |
X: must contain at most N items | Above maxItems | Read this endpoint's ceiling; it is not the same on every endpoint |
X: must be an array | The value is not the declared type | Wrap a single entry in a list |
X: must be at most N characters | Above maxLength | Prompts have their own ceiling per endpoint |
X.<n>.<sub>: is required | A sub-field of entry <n> is required and has no default | Add it to that entry only |
X.<n>.<sub>: is not a supported field | The entry schema sets additionalProperties: false | Remove that key from that entry |
X.<n>.<sub>: must be at most N | The sub-field is outside its own range | Clamp it; ranges are per sub-field, not per array |
If the rejection arrives when you expected it to work, the usual cause is a stale schema. Re-read the catalog after a 400, and never keep a permanent snapshot — Caching the catalog explains how long a cached copy is good for. Troubleshooting covers the rest.
Next steps
Model catalog and schemas
The live catalog, price tiers, and building your own parameter table from inputSchema
Spicy Schema contract
Canonical field names, x-ui annotations, conditional fields and the result format
Media
Uploading a picture and turning it into a spicy:// reference
Quotes and compatibility
Pricing one exact request before you pay for it
Subject swap
The editing endpoints that take pictures and no prompt
Tutorial: turn a picture into a video
The same journey for video, with the CLI, the SDK and plain HTTP
Errors
Every error code, and which ones a retry can help
Production integration
What to add before real users depend on it

