Skip to main content

createTask, then what? Polling vs webhooks for async generation jobs

Every image and video model here returns a taskId, not a result. Choose polling or webhooks, resume safely after a timeout and avoid paying twice.

Author
SpicyAPI Engineering
Published
Read
7 min read
Updated
A winding path of circular checkpoints beside a direct golden route to a glowing sphere.

You have called createTask and got a taskId back instead of an image. Now you need the result without holding a connection open for minutes, paying twice after a timeout, or losing the job when your process restarts.

The short version:

  • Quick image models in a script: add ?wait=30 to createTask and often skip polling entirely.
  • Scripts, local development, no public server: poll the task with a bounded loop like the one below.
  • Anything a user is waiting on, video, or volume: use callBackUrl, verify the signature, acknowledge fast, and process from a queue.
  • Production: do both. The callback is the fast path; a periodic status sweep catches the callbacks that never arrive.

In the raw API, ?wait=<seconds> (up to 60) holds createTask open until the task ends, which suits image models that finish in seconds and not video. callBackUrl (capital B) is the public HTTPS address that receives the result, and recordInfo is the status call that the SDK's get_task wraps. The task reference has all three.

Try it in the Playground first

Open the seedream-5-0-pro Playground. Type a short Prompt, set Resolution to 1k, press Run model, and read the result in Output; History lists earlier runs. The wait you see is what polling and webhooks automate for your application; they do not create a second image.

Choose the completion path

Your applicationStart withWhy
Local script or occasional batchPolling through the SDKNo public receiver to deploy
Interactive image or video applicationVerified webhookReact when generation finishes without keeping one waiting process per job
Production serviceWebhook plus reconciliation sweepA missed callback does not leave an accepted task orphaned

Choose polling when the job count is small or your machine cannot receive public HTTPS calls. Choose webhooks when users are waiting on a live application or long video jobs would tie up workers.

For image-input tasks, the SDK upload flow performs /files/{fileId}/commit and returns a spicy:// URI; see the media documentation.

Run one task with the SDK

Install spicyapi, set SPICY_API_KEY, and run the example. Keep map-image.json to resume the same task after interruption. For a deliberately different image, use a new run name. The Python SDK supplies the API methods; the task reference covers the complete lifecycle.

python
import json
import os
import time
import uuid
from pathlib import Path
from spicyapi import SpicyClient, output_assets

client = SpicyClient()

def run(model, inputs, name):
    path = Path(name + ".json")
    request = {"model": model, "input": inputs}
    saved = json.loads(path.read_text()) if path.exists() else {"request": request, "key": str(uuid.uuid4())}
    if saved["request"] != request:
        raise ValueError("Changed input needs a new run name; preserve the old attempt.")
    def save():
        temporary = path.with_suffix(".tmp")
        temporary.write_text(json.dumps(saved))
        temporary.chmod(0o600)
        temporary.replace(path)
    if not saved.get("taskId"):
        if time.time() - saved.get("sentAt", time.time()) >= 23 * 3600:
            raise RuntimeError("Reconcile the saved attempt before submitting again.")
        if "quote" not in saved:
            saved["quote"] = client.quote_task(model=model, input_data=inputs)
            if input(f"Maximum ${saved['quote']['maxCharge']}. Create? [y/N] ") != "y":
                raise SystemExit("Stopped without creating this task.")
        saved.setdefault("sentAt", time.time())
        save()
        quote = saved["quote"]
        task = client.create_task(model=model, input_data=inputs,
            idempotency_key=saved["key"], quote_id=quote["quoteId"], expected_cost=quote["estimatedCost"])
        saved["taskId"] = task["taskId"]
        save()
    print("Task:", saved["taskId"])
    for _ in range(360):
        task = client.get_task(saved["taskId"])
        if task["state"] not in {"queued", "running"} and task["state"] != "succeeded":
            raise RuntimeError(str(task.get("errorMessage", task["state"])))
        if task["state"] == "succeeded":
            assets = output_assets(task)
            if assets and assets[0].get("unavailable"):
                raise RuntimeError("Output unavailable; keep the task ID for reconciliation.")
            if assets and assets[0].get("url") and not assets[0].get("pending"):
                return assets[0]
        time.sleep(5)
    raise TimeoutError("Rerun the same saved attempt to resume; no new task is needed.")

asset = run("bytedance/seedream-5.0-pro/text-to-image", {
    "prompt": "a folded paper map on a wooden table, overhead", "resolution": "1k", "output_format": "png"
}, "map-image")
print(asset["url"])

Read the response correctly

A task is always in one of these states: queued, running, succeeded, failed or expired. Only the first two are still moving, so end your loop on "not queued or running" rather than waiting for succeeded, or a failed task polls forever (older records may also show a read-only canceled). The result link sits at output.assets[0].url. When you call the raw API, read the task from the envelope's data object, not the response root. A task you have accepted cannot be canceled, and only succeeded is charged; failed and expired normally release the held price.

What a timeout means for your application

HTTP 202 means a task was accepted, not that generation finished. A local timeout stops waiting; it does not cancel the accepted task. Keep its task ID and resume it rather than buying another generation. A task's returned deadline is the recovery boundary, not a prediction of the render time.

  • Submission response lost: retain the request and its key so recovery can find the original task.
  • Generation failed: surface the model's error and let the user deliberately choose another attempt.
  • Generation succeeded, file pending: keep waiting on that task; the image or video is still being prepared for delivery.

A new request gets a new idempotency key. Recovery of the same request keeps its key and input unchanged. The idempotency reference describes the 24-hour replay window.

Use webhooks for the fast path

Send completion events to your application backend, then update the user interface through your usual notification path. A callback is not a direct message to the user's browser.

The receiver should do four things:

  1. Verify the event using the official SDK and the raw request body.
  2. Confirm that the task belongs to a job your application owns.
  3. Persist and deduplicate the event before acknowledging it.
  4. Queue output copying and the application update separately from the HTTP response.

Choose it when: you already run a backend and want prompt completion updates. Look elsewhere when: this is a short local script with no public receiver; polling is simpler.

Treat task completion and media delivery as separate checks. A well-formed event can describe a failed task, while a succeeded task may need file handling before your application says it is ready. The webhook documentation contains the verification and delivery contract.

Keep a reconciliation sweep

Save every accepted task ID. Periodically revisit tasks your application has not resolved, especially after a deploy, network outage or signing-secret rotation. Feed a recovered result through the same idempotent completion handler as a webhook, so the gallery does not receive the same file twice.

Keep unresolved billing in the journal until settlement finishes. If local records are lost, current-key task history can recover the IDs; production integration covers this path.

Copy results before they expire

Ready output links are temporary. Copy files to storage you control, with a User-Agent request header and a filename matching the returned format. A download failure is a reason to recover the existing result, not regenerate it.

Use media retention and delivery for the deadlines and durable storage for the copy workflow.

The decision

Start with SDK polling for a small script. Add verified webhooks when you have an interactive service, and keep a sweep for recovery. The saved task ID connects all three paths: they collect one generation rather than submit replacements.

For more simultaneous work, add bounded batch dispatch. For the first image-to-video pair, use the starter guide.

Let an Agent handle the call

Use an Agent when you want it to prepare the inputs and return the result while you approve the price and creative choices.

  • CLI: save {"prompt": "a folded paper map on a wooden table, overhead", "resolution": "1k", "output_format": "png"} as input.json, then use npx @spicyapi/cli tasks create --model bytedance/seedream-5.0-pro/text-to-image --input-file input.json --wait. The CLI confirms the price; keep the returned task ID for recovery.
  • MCP: ask your assistant to use spicyapi_task_create for bytedance/seedream-5.0-pro/text-to-image, then spicyapi_task_wait. The MCP server requests price confirmation before creation.
  • Skill: install with npx skills add spicyapi-ai/spicy-skill, then ask $spicyapi to run the same workflow and stop for your approval. The Skill uses a configured CLI or MCP connection.

Also in 日本語 · 한국어 · Deutsch · Français · Español · Português (Brasil) · Русский · Italiano · Polski

Try it yourself

Pay only for what you run — no plan, no subscription.

Made with MiniMax H3 · ByteDance Seedance 2.0 Fast