Seedance + n8n: Build a Bounded Async Polling Workflow
Seedance + n8n: Build a Bounded Async Polling Workflow
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_idand loop configuration across every node - wait between status requests
- stop immediately on success or failure
- enforce a hard polling limit
- 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
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
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
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
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
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 }}
After the wait, send the same item to two nodes:
pollTaskperforms the status request.carryStateretains the state required by the next iteration.
pollTask calls:
GET {{$json.base_url}}/v1/video/generations/{{$json.task_id}}
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' }}
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,
},
},
];
The important invariant is:
const pollIndex = (carried.json.poll_index || 0) + 1;
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'))
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'))
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)
The true branch stops with a timeout error. The false branch reaches continuePolling.
continuePolling copies the latest values back into the loop:
task_idpoll_indexmaxPollsintervalSecondsbase_url
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/maxPollsbudget 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:
- Live API documentation
- Postman Collection
- 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.
Comments
No comments yet. Start the discussion.