spicyapiDocs
Main content

Tutorial: turn a picture into a video

Start from one picture on your computer and finish with a downloaded video file. Every step shows the CLI, the TypeScript SDK and plain HTTP.

This tutorial takes one picture from your computer and turns it into a short video clip that you download and keep. It goes the whole way in order: choose a model, upload the picture, check the price, start the generation, wait for it, download the result, and deal with anything that goes wrong. You do not need to piece it together from the reference pages.

Every step shows three ways to do the same thing. Pick one and use it all the way through:

WayChoose it ifYou need
CLIYou want to type commands and not write any codeNode.js 22.13 or newer
TypeScript SDKYou are building a Node.js program and want to see the calls it makesNode.js 22.13 or newer
HTTP (curl)You want to see the raw API, for example to use another programming language laterA bash terminal with curl and jq (macOS, Linux, or WSL on Windows)

Would you rather just ask an AI assistant to do it? See Do it from an AI assistant.

What you will make

  • The result: a short video file, usually MP4, that starts from your picture and moves the way your sentence describes.
  • Your time: plan about 20 minutes the first time, most of it reading. The generation itself usually takes a few minutes; some models and settings take longer.
  • Where it runs: on SpicyAPI. Your computer only sends the picture and the instructions, then downloads the finished file.

What it costs, and how to find out before paying

Prices are in US dollars. They depend on the model you pick and on your settings, such as the length and the resolution of the video, so this page does not quote a number. You can see the price in three places, and none of them costs anything:

  1. The live catalog (step 1) lists every model your account can use with your account's prices. Videos are usually priced per_second of output. When a model lists several prices, each one belongs to a setting such as a resolution.
  2. A quote (step 3) gives the exact price of your exact request. It starts nothing and sets no money aside.
  3. The confirmation: the CLI shows the price again and waits for your y before it starts anything.

What happens to your money:

  • Only step 4 costs money, and so does running a failed task again. Listing models, uploading, quoting, waiting and downloading are free.
  • When a generation starts, the quoted amount is set aside from your balance (a hold). That amount is also the most the video can cost.
  • If the video succeeds, you are charged for what was produced, never more than the hold, and anything left over goes back to your balance.
  • Failed or expired tasks normally release the full hold. Some content refusals are charged at the accepted quote only when the model page explicitly discloses this exception. Check settled and cost for the final charge.
  • New accounts start with no free allowance, so you need a balance first. See Billing.

How it works

your picture ──upload──▶ spicy:// address (usable for one day)
                                   │
your sentence + settings ──────────┴──▶ quote (free) ──▶ start the task (money set aside)
                                                                  │
                                         queued ──▶ running ──▶ succeeded / failed / expired
                                                                  │
                                              download link (about 20 minutes) ──▶ your file

A task is one generation job. SpicyAPI works on it in the background, so starting it and collecting the result are two separate actions. Every task has an ID that starts with job_; keep it, because it is how you find the task again. Other words used on this page are explained in the Glossary.

Before you start

Have an account, a balance and an API key

Follow Set up your account first. When you are done you should have:

  • a SpicyAPI account with money on its balance;
  • an API key, which starts with sk-spicy-;
  • that key stored in an environment variable called SPICY_API_KEY. An environment variable is a named value your terminal hands to the programs it runs, so the key never has to appear in a command or in your code.

Check that the key is set in the terminal window you are using, without printing the whole key:

echo "${SPICY_API_KEY:0:9}"

You should see sk-spicy-. If you see nothing or an error, the key is not set in this window; go back to Set up your account.

Put your picture in a work folder

Create a folder for this tutorial and open your terminal in it. Every command on this page is run from this folder, and every file it creates lands here.

mkdir -p ~/spicy-video
cd ~/spicy-video

Copy your picture into that folder and name it start-frame.jpg (or start-frame.png if it is a PNG; then use that name wherever this page says start-frame.jpg).

What makes a good starting picture:

  • Format and size: JPEG, PNG, WebP or GIF, up to 10 MiB (about 10 megabytes). A model can set stricter limits of its own, which you will see in step 1.
  • The file must really be that format. SpicyAPI checks the file's contents, not just its name. Photos from phones are often HEIC; renaming one to .jpg does not convert it. Open it and export or save it as JPEG first.
  • Shape: the picture becomes the first frame. Use a wide picture for a wide video and a tall one for a vertical video; some models also offer an aspect_ratio setting.
  • Rights: use a picture you are allowed to use.

Set up the tool you picked

The CLI is a program called spicyapi that you run in the terminal. Install it once (full instructions, including Node.js: CLI: before you start):

npm install -g @spicyapi/cli
spicyapi --version

If the install fails with EACCES, skip it and type npx @spicyapi/cli wherever this page types spicyapi.

Then check that your key works and that you have a balance:

spicyapi balance

You should see available, held and total in US dollars. available must be above zero.

The seven steps

Replace MODEL_ID_FROM_CATALOG with a real model ID

MODEL_ID_FROM_CATALOG is a placeholder, not a model. In step 1 you choose a real model ID from the live catalog (spicyapi models list, listModels() in the SDK, or GET /api/v1/models). Paste it wherever this page shows the placeholder. Sending the placeholder itself only returns a "not found" error.

Using Windows? Pick the CLI or SDK tab

The HTTP tab is written for bash. Windows PowerShell treats quotes, line breaks (\) and $(...) differently, so those commands will not run there as written. On Windows, use the CLI or SDK tab, or run the HTTP commands inside WSL (Windows Subsystem for Linux).

1. Choose a model and read its settings

Do this: list the image-to-video models your account can use, pick one, then read which settings it accepts.

List the models:

spicyapi models list --modality video --task image-to-video --json

You should see a JSON list. This one is shortened, and amounts are shown as …:

{
  "total": 1,
  "items": [
    {
      "model": "MODEL_ID_FROM_CATALOG",
      "displayName": "Example video model",
      "modality": "video",
      "tasks": ["image-to-video"],
      "enabled": true,
      "available": true,
      "quantityField": "duration_seconds",
      "maxOutputDurationSeconds": 10,
      "taskTimeoutSeconds": 1800,
      "pricing": [
        { "variant": "720p", "unit": "per_second", "price": "…", "currency": "USD" },
        { "variant": "1080p", "unit": "per_second", "price": "…", "currency": "USD" }
      ]
    }
  ]
}

Choose a model whose enabled and available are both true and copy its model value. Then read its settings:

spicyapi models get MODEL_ID_FROM_CATALOG --json

The answer has the same fields plus inputSchema (the settings) and examples (inputs the server has already checked).

What to look at in the list:

FieldWhat it tells you
modelThe model ID you will use in every later step
enabled, availableUse a model only when both are true
pricingOne entry per price level: variant (the setting it applies to, often a resolution), unit (usually per_second for video) and price in US dollars. regularPrice and offerLabel appear when a discount applies
quantityFieldThe setting that decides how many units you pay for, usually duration_seconds. Empty means one unit per video
maxOutputDurationSecondsThe longest video the model allows
taskTimeoutSecondsHow long a generation may run before it stops as expired (and is not charged)

How to read the settings. inputSchema is the model's list of settings, written as JSON Schema:

  • required lists the settings you must send. image_url is always one of them for this kind of model, and most also require prompt.
  • properties lists every setting the model accepts, each with a type, a description in plain English, and often enum (the only allowed values), minimum and maximum, and default (used when you leave the setting out).
  • Inside each setting, x-ui holds display hints. x-ui.affects_price: true means changing that setting changes the price.
  • A few models add conditions at the top level, such as "if you send A you must also send B". Spicy Schema explains how to read them.
  • examples holds complete inputs that the server has already checked, which are a safe starting point.

The settings you will meet most often. Names are the same across all models, but each model accepts only some of them, with its own allowed values:

SettingWhat it doesChanges the price?
image_urlThe starting picture, used as the first frame. Takes the spicy:// address from step 2 or a public https:// linkNo
promptWhat should happen: the movement, the camera, the mood. Describe motion rather than repeating what the picture already showsUsually not
duration_secondsVideo length in seconds. Allowed values are in enum or between minimum and maximum; some models accept a special value explained in the setting's descriptionYes on most models: video is usually billed per second, so a longer clip costs more
resolutionOutput size, such as 720p or 1080pUsually: higher resolutions tend to have their own, higher price
aspect_ratioThe video's shape, such as 16:9, 9:16 or 1:1. Some models offer adaptive, which follows the pictureCheck x-ui.affects_price
generate_audioAlso generate a soundtrack that matches the pictureOn some models
last_image_urlA picture for the final frame; see First and last frameQuote to check
negative_promptWhat the video should avoidUsually not
seedA number that makes results easier to compare between runs; it does not guarantee identical videosNo

Send only the settings this model lists

Every model has its own list. A setting that is not in that model's properties, a misspelled name, or a value outside enum gets the whole request rejected with 400. Nothing is charged, but do not copy an input from another model without checking.

What just happened: you read the live catalog for your account, including your prices. Nothing was charged. Reading the catalog counts toward your account's request limit, so there is no need to repeat this before every video; do it again when you switch models or get a 400.

2. Upload your picture

Do this: upload start-frame.jpg and copy the spicy:// address you get back. Uploading is free.

spicyapi files upload start-frame.jpg

You should see:

fileId: fil_01k3m8x9q2z4v7n5p6r8s0t1w3
status: ready
bytes: 402118
contentType: image/jpeg
sha256: a91f4c2e5b7d16809e3f2a4c8d0b61e7c4a890e5d2f71b3c609ae8427d15f0c6
uri: spicy://f/fil_01k3m8x9q2z4v7n5p6r8s0t1w3
expiresAt: 2026-09-18T09:12:11Z

Copy the uri value exactly as printed.

What just happened: your picture was stored privately for your account, checked (its real format has to match its type), and given a spicy:// address. That address is not a web link: only your account can use it, and only inside a task's input. It can be used for one day (see expiresAt); after that, upload the picture again.

Picture already online?

If the picture is already at a public https:// address that opens without logging in, you can skip this step and put that address in image_url instead. It must stay reachable while the video is being generated.

If the upload fails:

You seeWhat it meansWhat to do
Unsupported file type, or the CLI or SDK says it cannot tell the typeThe file is not JPEG, PNG, WebP or GIFConvert the picture, or name it with the right extension
File exceeds the 10 MB limitThe picture is larger than 10 MiBExport a smaller version
The uploaded file does not match the upload ticketThe file's contents are not the type its name or declaration says, for example a HEIC photo renamed to .jpgExport it as a real JPEG or PNG and upload again
The upload ticket has expiredMore than about 20 minutes passed between the ticket and the commitStart the upload again

3. Describe the video and check the price

Do this: write your settings into a file called input.json, then ask for a quote.

Write input.json. Start with the two settings every image-to-video model accepts. Put your own sentence in prompt, and replace the image_url value with the uri you copied in step 2:

input.json
{
  "prompt": "The camera slowly pushes in while rain runs down the window and neon light flickers",
  "image_url": "spicy://f/fil_01k3m8x9q2z4v7n5p6r8s0t1w3"
}

Settings you leave out use the model's default. To choose them yourself, add only settings that your model's properties list, with values it allows. For example, if your model lists duration_seconds and resolution and allows these values:

{
  "prompt": "The camera slowly pushes in while rain runs down the window and neon light flickers",
  "image_url": "spicy://f/fil_01k3m8x9q2z4v7n5p6r8s0t1w3",
  "duration_seconds": 5,
  "resolution": "720p"
}

Save the file in your work folder:

Paste all the lines at once, after putting your own text and uri in them:

cat > input.json <<'EOF'
{
  "prompt": "The camera slowly pushes in while rain runs down the window and neon light flickers",
  "image_url": "spicy://f/fil_01k3m8x9q2z4v7n5p6r8s0t1w3"
}
EOF

Use plain straight quotes ("), not curly ones, and no comma after the last setting.

Get the quote:

spicyapi tasks quote --model MODEL_ID_FROM_CATALOG --input-file input.json

You should see (amounts shown as …):

quoteId: eyJ2IjoxLCJtIjoi…
model: MODEL_ID_FROM_CATALOG
estimatedCost: …
maxCharge: …
currency: USD
quantity: 5
unit: per_second
expiresAt: 2026-09-17T08:05:00Z

What just happened: SpicyAPI checked your input against the model's settings and priced this exact request. Nothing was started and nothing was charged. estimatedCost is the amount that will be set aside if you start now, and maxCharge is the most it can cost. quantity and unit show what you pay for, such as 5 seconds.

A quote is valid for five minutes and only for this exact input. Change the length or resolution and quote again to compare prices. The quote runs the same checks as starting the task, so problems show up here for free: a 400 means the input does not match the model's settings (fix input.json using step 1), and a 40201 means your balance would not cover it. Step 7 lists the other codes.

Want a test run without your own picture?

The model's examples from step 1 are complete inputs that the server has already checked. You can put one into input.json as it is, quote it, and generate it to see the whole flow work before you use your own picture.

4. Start the generation

This step costs money

Starting the generation sets the quoted amount aside from your balance. Once SpicyAPI accepts it, the task cannot be cancelled: it runs to the end and is charged if it succeeds. Failed or expired tasks normally release the full hold. Some content refusals are charged at the accepted quote only when the model page explicitly discloses this exception. Check settled and cost for the final charge.

The idempotency key, in plain words. It is a label for one video, like an order number. If your connection drops and you are not sure whether the video started, send the same request again with the same label: SpicyAPI hands back the task it already started instead of starting, and charging for, a second one. A label works for 24 hours with the same API key. Use a new label for a different video: the same label with a different input is refused with 409. More in Idempotency.

Do this:

spicyapi tasks create --model MODEL_ID_FROM_CATALOG --input-file input.json

The CLI quotes again, shows the price and waits for you:

Estimated charge: USD …; maximum charge: USD …. Quote expires: 2026-09-17T08:05:00Z.
Idempotency-Key: 4f6755a5-0d72-4420-9f6b-9faf0e2b0241
Create a billable task with model MODEL_ID_FROM_CATALOG? … Continue? [y/N]

Type y and press Enter. Anything else cancels, and nothing is charged. Copy the Idempotency-Key value somewhere before you answer; the CLI makes a new label on every run, and you need this one if the connection drops.

You should see:

idempotencyKey: 4f6755a5-0d72-4420-9f6b-9faf0e2b0241
taskId: job_01k3m8x9q2z4v7n5p6r8s0t1w3
state: queued
estimatedCost: …
deadlineAt: 2026-09-17T08:35:00Z

Copy the taskId.

What just happened: SpicyAPI accepted the generation, which is now a task, and set estimatedCost aside from your balance. That is also the most this video can cost. state: queued means it is waiting for its turn. deadlineAt is the latest time it can finish; if it is not done by then it ends as expired and nothing is charged. deadlineAt is not when your files expire.

Passing the quote along means "only accept this at the price I was shown". If the price or the input changed in the meantime, the request is refused with 40901 instead of setting a different amount aside. The CLI does this for you.

If the connection dropped and you never saw a task ID, do not simply start again with a new label, which would make and charge for a second video. Repeat the request with the same label:

  • CLI: spicyapi tasks create --model MODEL_ID_FROM_CATALOG --input-file input.json --idempotency-key <the value you copied>. See Pick up an interrupted task.
  • SDK: run node generate.mjs --yes again; it reuses idempotency-key.txt.
  • HTTP: run the "Start the video" block again; it reuses idempotency-key.txt.

If step 4 was refused, nothing was created and nothing was charged; the reason and the fix are in step 7.

5. Wait for the video

Do this: check the task until it finishes. Videos usually take a few minutes.

spicyapi tasks wait job_01k3m8x9q2z4v7n5p6r8s0t1w3 --wait-timeout-ms 1800000 --json

Use your own task ID. --wait-timeout-ms 1800000 waits up to 30 minutes instead of the default 10.

You should see, once it is finished (shortened):

{
  "taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
  "model": "MODEL_ID_FROM_CATALOG",
  "state": "succeeded",
  "output": {
    "assets": [
      {
        "key": "tasks/2026/09/17/job_01k3m8x9q2z4v7n5p6r8s0t1w3/result.mp4",
        "url": "https://…",
        "expiresAt": "2026-09-17T08:40:00Z",
        "mime": "video/mp4",
        "durationSeconds": 5,
        "width": 1280,
        "height": 720,
        "bytes": 4839211
      }
    ]
  },
  "cost": "…",
  "settled": true
}

Next time you can combine steps 4 and 5 by adding --wait to tasks create.

What just happened: your tool asked SpicyAPI about the task every few seconds. The states are queued (waiting), running (being generated), then one final state: succeeded, failed or expired. After success the file is copied into SpicyAPI's own storage, which takes a few more seconds; until then the result shows pending: true. cost is final once settled is true.

  • Closing the terminal or pressing Ctrl + C does not cancel the task. It only stops the waiting. Keep the task ID and run this step again later.
  • Do not check more often than every few seconds. Checking a task uses the account’s read allowance, independently of starting tasks; see Rate limits.
  • For an app or a server, let SpicyAPI tell you when the task is done instead of asking: see Get notified instead of waiting.

6. Download and keep the video

Do this: get a fresh download link and save the file to your computer.

Get a fresh link:

spicyapi files download-url job_01k3m8x9q2z4v7n5p6r8s0t1w3
key: tasks/2026/09/17/job_01k3m8x9q2z4v7n5p6r8s0t1w3/result.mp4
url: https://…
expiresAt: 2026-09-17T08:52:00Z

Paste the url into a browser to download it, or save it from the terminal (keep the quotes, because the link contains & characters):

curl -L -o my-video.mp4 "PASTE_THE_URL_HERE"

In Windows PowerShell, type curl.exe instead of curl. If a task produced several files, spicyapi tasks get <task-id> --json lists them, and --key picks one.

What just happened: every result comes with a direct download link. The link works for about 20 minutes and needs no API key; never add your key to it. When it expires, ask for the task again (or use files download-url) to get a new one.

Keep your own copy

SpicyAPI keeps generated files for about 14 days at most, less if your account's retention setting is shorter, and then deletes them for good. Your uploaded picture can be used for one day, and your prompt text is erased after 30 days by default. The record of the task and its cost is kept. To delete a finished task's files and prompt sooner, use spicyapi tasks purge, purgeTask() or POST /api/v1/jobs/purge; that deletes content only and is not a refund. See Retention and destruction.

7. When something goes wrong

Problems come in two kinds, and they are handled differently.

A request is refused. Your command or call gets an error straight away. Nothing was created and nothing was charged. The message says why, and the error carries a code and a request ID (req_…) to quote if you contact support.

CodeWhat it meansWhat to do
400, or a more specific code starting with 400The input does not match the model's settings: a missing required setting, an unknown or misspelled one, a value that is not allowed, an expired or foreign spicy:// address, a combination of settings the model cannot serve right now, or an invalid callback addressCompare input.json with the settings from step 1 and fix it. For an expired address, upload the picture again
401The API key is missing, mistyped or revokedCheck SPICY_API_KEY as in Before you start
40201Your balance does not cover the amount to set asideAdd funds under Billing in the console
40202A spending limit on your key (daily, monthly or lifetime) was reachedRaise it on the API keys page, or wait for it to reset
403, 40301This key may not use this model, or the request came from a place it is not allowed fromCheck the key's model list and IP allowlist on the API keys page
404No model with that ID for your account (or you sent the placeholder), or no task with that ID for this keyCopy the exact ID again; use the same key that created the task
409This idempotency key was already used for a different requestUse a new label for a new video
40901The quote expired (five minutes) or the price changedQuote again, then start again
429Too many requests in a short timeWait for the number of seconds the error gives, then continue more slowly
50301The model cannot take new work right nowTry again later, or choose another model

The task fails. The task was accepted, but ends as failed or expired instead of succeeded. The task shows an errorCode that says why, and an errorMessage with more detail in words. Failed or expired tasks normally release the full hold. Some content refusals are charged at the accepted quote only when the model page explicitly discloses this exception. Check settled and cost for the final charge.

Read it:

spicyapi tasks get job_01k3m8x9q2z4v7n5p6r8s0t1w3

Look at state, errorCode and errorMessage.

Then decide what to do from errorCode, not from the wording of the message:

errorCodeWhat it meansWhat to do
rate_limited, upstream_unavailable, upstream_failedGeneration was busy or temporarily unavailableRun it again after a short pause
timeout (or the state is expired)The video was not finished in timeTry once more; if it happens again, choose a shorter length or a lower resolution and start a new task
generation_failedGeneration ran but produced nothingThe same input usually fails the same way. Change the prompt or the picture and start a new task
invalid_assetThe picture could not be used, for example because the upload expired or the file could not be readUpload the picture again and start a new task with the new address
invalid_request, unsupported_combinationThe settings were not acceptedChange the settings and start a new task
content_rejectedThe selected model declined this request under its own policyChange the prompt or the picture, or choose another model

Any other value should be treated like upstream_failed. The full list is in Errors.

Run a failed task again. A retry creates a new task with the same model and input, at the model's current price. It costs money, like step 4, and it keeps the original input: if that input used an upload that has since expired, upload again and start a new task instead.

spicyapi tasks retry job_01k3m8x9q2z4v7n5p6r8s0t1w3 --wait --wait-timeout-ms 1800000

It asks you to confirm, but unlike tasks create it does not show a price first. To see the price, run the tasks quote command from step 3.

Only failed and expired tasks can be retried. For a different version of a video that succeeded, go back to step 3 with a new label. There is no way to cancel a task once it has been accepted. More in Task commitment and retry.

Do it from an AI assistant instead

If you use an AI assistant that supports MCP, such as Claude Code, Cursor or Codex, the SpicyAPI MCP server lets it do all seven steps for you. Set it up once with MCP server: set up your client, then ask in your own words, for example:

Upload the picture at /Users/me/spicy-video/start-frame.jpg to SpicyAPI. Find image-to-video models my key can use and quote a 5-second clip on the two cheapest. Then generate the video with the cheaper one once I confirm, wait for it, and give me the download link.

On Windows, give the path as C:\Users\me\spicy-video\start-frame.jpg.

What happens:

  1. spicyapi_upload_file uploads the picture and gets its spicy:// address. It is free, but it does send the file, so name the exact file you mean. By default only files inside your home folder can be read; see Local files.
  2. spicyapi_models_list and spicyapi_model_get find a model and its settings, and spicyapi_task_quote prices the options. All free.
  3. spicyapi_task_create stops and asks you, showing the model, the estimated and maximum charge and when the quote expires. Nothing is charged until you confirm, and the assistant cannot confirm for you.
  4. spicyapi_task_wait waits up to five minutes at a time, so for a longer video the assistant may wait more than once. It then gives you the download link, which works for about 20 minutes.

More examples and every tool: MCP server.

Going further

First and last frame

Some image-to-video models also accept last_image_url: a second picture for the video to end on. Check whether yours does by looking for last_image_url in the properties from step 1. If it is not there, the model does not support it and sending it is refused with 400.

Upload both pictures as in step 2, then put both addresses in input.json:

{
  "prompt": "The flower slowly opens as morning light spreads across the table",
  "image_url": "spicy://f/fil_01k3m8x9q2z4v7n5p6r8s0t1w3",
  "last_image_url": "spicy://f/fil_01k3m8x9q2z4v7n5p6r8s0t1w4"
}

The setting's description explains how that model uses the final picture. Quote again before starting, to see whether the second picture changes the price.

Several videos

  • Reuse the upload. One spicy:// address can be used in several tasks until its expiresAt, unless your account's upload retention is set to delete uploads as soon as a task finishes.
  • One label per video. Give each video its own idempotency key, such as campaign-clip-1, campaign-clip-2. Running your script again then returns the videos already started instead of paying for them twice.
  • Do not start everything at once. Each running task has money set aside, a spending limit refuses tasks beyond it with 40202, and every status check counts toward your request limit. Start with two at a time.

Ready-made scripts: Run a batch with limited concurrency (SDK) and Run the CLI from a script.

Get notified instead of waiting

For a website or a background service, keeping a program running just to ask "is it done yet?" is wasteful. Instead, give the task a callback address: a public web address on your own server that SpicyAPI calls once, when the task finishes, with the same task details step 5 shows.

What you need:

  • a server reachable from the internet at an https:// address, on the standard port (localhost is refused);
  • your webhook signing secret from the console, so your server can check that each call really came from SpicyAPI;
  • code that checks the signature, answers quickly, and handles the same notification arriving twice.

Webhooks explains the format, the signature and the retry schedule, and the SDK has a minimal receiver. Keep a fallback: now and then, check tasks that are well past their deadlineAt without a notification.

Add the callback address when you quote and start the task:

spicyapi tasks create --model MODEL_ID_FROM_CATALOG --input-file input.json --callback-url https://your-app.example.com/hooks/spicy

The CLI includes the address in its own quote, so the price check still matches.

Next steps

On this page