DEV Community

Cover image for Seedance + n8n: Build a Bounded Async Polling Workflow
vancine
vancine

Posted on • Originally published at xingod.me

Seedance + n8n: Build a Bounded Async Polling Workflow

This article was originally published on XINGOD.ME. This DEV edition keeps the implementation complete while adapting the explanation for automation builders.

Video generation APIs are asynchronous by design. The first request does not return a finished MP4. It returns a task identifier that you must preserve while the provider queues, processes, and eventually completes—or fails—the job.

That changes the automation problem. A production-ready workflow must do more than “poll until done.” It must:

  • submit exactly once;
  • preserve task_id and loop configuration across every node;
  • wait between status requests;
  • stop immediately on success or failure;
  • enforce a hard polling limit; and
  • make the final timeout observable.

In this tutorial, the default polling budget is 5 seconds × 120 polls, or roughly 10 minutes. Both values are configurable, but the loop always remains bounded.

The state machine

At a high level, the workflow is:

submit
  → save task_id and loop state
  → wait
  → poll
  → merge the API response with carried state
  → completed / failed / timeout / continue
Enter fullscreen mode Exit fullscreen mode

The tested n8n workflow uses these concrete nodes:

manualTrigger
  → setFields
  → submitTask
  → saveTaskId
  → waitInterval
       ├─→ pollTask ─────┐
       └─→ carryState ───┴─→ Merge (append)
                               → mergeState
                               → ifCompleted
                                  ├─ true  → outputResult
                                  └─ false → ifFailed
                                               ├─ true  → failNode
                                               └─ false → ifTimeout
                                                            ├─ true  → timeoutFail
                                                            └─ false → continuePolling
                                                                          → waitInterval
Enter fullscreen mode Exit fullscreen mode

The branching around pollTask and carryState is the critical part. An n8n HTTP Request node replaces the current item with the HTTP response. Without a parallel state branch, the response can erase the loop counter and configuration needed for the next iteration.

1. Configure the request and polling budget

Start with an Edit Fields node named setFields:

model = Doubao-Seedance-1.5-pro
prompt = A timelapse of a flower blooming at sunrise
size = 1280x720
base_url = https://vancine.com
intervalSeconds = 5
maxPolls = 120
Enter fullscreen mode Exit fullscreen mode

intervalSeconds and maxPolls are configuration, not hidden constants. A different workflow may need a shorter interval or a different deadline, but it should still have an explicit upper bound.

Store authentication in n8n Credentials, for example with a reusable Header Auth credential. Never paste a real API key into exported workflow JSON, screenshots, or code examples.

2. Submit once and normalize task_id

Configure submitTask as an HTTP Request node:

POST https://vancine.com/v1/video/generations
Content-Type: application/json
Enter fullscreen mode Exit fullscreen mode

Send the configured model, prompt, and size in the JSON body. The exact supported models and request fields can change, so check the live documentation before using the workflow in production.

Immediately after submission, saveTaskId creates the loop envelope:

task_id = {{ $json.task_id || $json.id || ($json.data && $json.data.task_id) || ($json.data && $json.data.id) }}
base_url = {{ $('setFields').first().json.base_url || 'https://vancine.com' }}
intervalSeconds = {{ $('setFields').first().json.intervalSeconds || 5 }}
maxPolls = {{ $('setFields').first().json.maxPolls || 120 }}
poll_index = 0
Enter fullscreen mode Exit fullscreen mode

The submit response is a receipt. task_id is the durable hand-off value for every later status request, error message, and audit record.

3. Wait, then fork response and state

Connect saveTaskId to a Wait node named waitInterval. Its amount is:

{{ $json.intervalSeconds || 5 }}
Enter fullscreen mode Exit fullscreen mode

After the wait, send the same item to two nodes:

  1. pollTask performs the status request.
  2. carryState retains the state required by the next iteration.

pollTask calls:

GET {{$json.base_url}}/v1/video/generations/{{$json.task_id}}
Enter fullscreen mode Exit fullscreen mode

carryState outputs:

task_id = {{ $json.task_id }}
poll_index = {{ $json.poll_index || 0 }}
maxPolls = {{ $json.maxPolls || 120 }}
intervalSeconds = {{ $json.intervalSeconds || 5 }}
base_url = {{ $json.base_url || 'https://vancine.com' }}
Enter fullscreen mode Exit fullscreen mode

Both branches feed a Merge node configured in append mode. The resulting item list contains the API response and the carried loop state.

4. Merge locally and increment exactly once

The mergeState Code node makes no network request. It identifies the two appended items, merges them, and increments the counter from the carried item exactly once:

const items = $input.all();

const response =
  items.find(
    (item) =>
      item.json.status !== undefined && item.json.object !== undefined,
  ) || items[0];

const carried =
  items.find((item) => item.json.poll_index !== undefined) ||
  items[items.length > 1 ? 1 : 0] ||
  items[0];

const pollIndex = (carried.json.poll_index || 0) + 1;

const taskId =
  response.json.task_id ||
  response.json.id ||
  (response.json.data && response.json.data.task_id) ||
  (response.json.data && response.json.data.id) ||
  carried.json.task_id;

const status =
  response.json.status ||
  (response.json.data && response.json.data.status);

const metadata =
  response.json.metadata ||
  (response.json.data && response.json.data.metadata);

const data = response.json.data;
const error =
  response.json.error ||
  (response.json.data && response.json.data.error);

const resultUrl =
  (metadata && metadata.url) ||
  (data && data.result_url) ||
  (data &&
    data.data &&
    data.data.content &&
    data.data.content.video_url) ||
  '';

return [
  {
    json: {
      task_id: taskId,
      status,
      metadata,
      data,
      error,
      poll_index: pollIndex,
      maxPolls: carried.json.maxPolls || 120,
      intervalSeconds: carried.json.intervalSeconds || 5,
      base_url: carried.json.base_url || 'https://vancine.com',
      result_url: resultUrl,
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

The important invariant is:

const pollIndex = (carried.json.poll_index || 0) + 1;
Enter fullscreen mode Exit fullscreen mode

The counter comes from the latest carried state, not from the initial state node. This is what allows the workflow to reach maxPolls and guarantees that a non-terminal task cannot loop forever.

5. Route completed, failed, and timeout separately

Run the terminal checks in order.

ifCompleted

Treat completed and the legacy value SUCCESS as success:

$json.status === 'completed' ||
$json.status === 'SUCCESS' ||
($json.data &&
  ($json.data.status === 'completed' || $json.data.status === 'SUCCESS'))
Enter fullscreen mode Exit fullscreen mode

The true branch goes to outputResult, where you can return or persist result_url.

ifFailed

The false branch from ifCompleted reaches ifFailed. Treat failed and FAILURE as terminal errors:

$json.status === 'failed' ||
$json.status === 'FAILURE' ||
($json.data &&
  ($json.data.status === 'failed' || $json.data.status === 'FAILURE'))
Enter fullscreen mode Exit fullscreen mode

Send the true branch to a Stop And Error node. A failed generation is a business terminal state, not a reason to keep polling.

ifTimeout

Only a non-terminal, non-failed item reaches ifTimeout:

($json.poll_index || 0) >= ($json.maxPolls || 120)
Enter fullscreen mode Exit fullscreen mode

The true branch stops with a timeout error. The false branch reaches continuePolling.

continuePolling copies the latest values back into the loop:

task_id
poll_index
maxPolls
intervalSeconds
base_url
Enter fullscreen mode Exit fullscreen mode

It then reconnects to waitInterval. With the defaults, the 120th non-terminal response enters the timeout branch instead of starting iteration 121.

6. Keep transport retries separate from task polling

A task that is still processing is not the same as an HTTP request that received a transient 429 or 5xx.

Use two separate budgets:

  • a small, explicit retry policy with backoff for transient transport failures;
  • the poll_index / maxPolls budget for valid task-status responses.

This separation prevents network noise from silently changing the business timeout. Also test the following paths independently:

  • completed;
  • failed;
  • timeout;
  • missing task_id;
  • 429 and 5xx; and
  • malformed or unknown status values.

Log task_id, poll_index, the last status, and elapsed time. Never log the Authorization header.

Try the tested workflow

The public Seedance API Starter Kit includes the tested n8n workflow plus Node.js, Python, cURL, and Postman examples.

Additional resources:

Start with Vancine

New Vancine accounts receive $1 in free credit, with no credit card required. You can create an account here.

Credit usage depends on the selected model, inputs, duration, and current pricing. The $1 credit does not guarantee that any specific video generation will complete within that amount.

Bounded polling is a small architectural choice with a large operational payoff: every execution has an inspectable state, every terminal outcome has an explicit branch, and every loop has a known failure budget.

Top comments (0)