---
title: "How do I know when an AI image or video job is done: polling or webhooks?"
description: "Know when an AI image or video job is done: poll the run until a final status, or add a webhook to /run and get a signed POST when it ends."
updated: 2026-10-22
---

To know when a Wireflow image or video job is finished, poll `GET /api/v1/workflows/executions/{executionId}/poll` until `status` is `COMPLETED`, `FAILED`, `CANCELLED` or `TIMEOUT`, or add a `webhook` block to `POST /api/v1/workflows/{id}/run` so Wireflow POSTs the result to your server when the run ends. Polling keeps working when you use a webhook, so you can use both.

## How does an asynchronous AI video job work?

The call that starts a run returns straight away with an `executionId`, and the work carries on in the background until the run reaches a final status.

1. `POST /api/v1/workflows/{id}/run` returns `202 Accepted` with the `executionId` and a `poll` path. The `Location` header points to the same poll URL.
2. Wireflow runs the workflow's nodes. Video generation can take 5+ minutes, and no HTTP connection has to stay open while it works.
3. The run ends as `COMPLETED`, `FAILED`, `CANCELLED` or `TIMEOUT`. You read the result by polling, or from the webhook delivery.

## Should I poll or use a webhook?

Poll when a script can wait for its own result, and use a webhook when a server should hear about the result without asking. The two compare like this:

| | Polling | Webhook |
| --- | --- | --- |
| How it works | You `GET` the poll URL every few seconds | Wireflow sends one `POST` to your URL when the run ends |
| How you ask for it | Nothing extra: every run has a poll URL | Add `webhook: { url, secret }` to the `POST /run` body |
| What you need | Your API key | An `https` URL that your server answers |
| Proof it is genuine | Your own authenticated request | An HMAC-SHA256 signature, when you set a `secret` |
| If something goes wrong | Poll again | Wireflow retries, up to 12 attempts |
| Documented for | Workflow runs, app runs, and agent runs through `get_execution` | Runs started with `POST /api/v1/workflows/{id}/run` |

## How do I poll until the job is done?

Request the poll URL every few seconds with an API key that has the `executions:read` scope, let the wait grow to about 10 seconds, and stop as soon as `status` is `COMPLETED`, `FAILED`, `CANCELLED` or `TIMEOUT`. If you get `429 Too Many Requests`, wait the number of seconds in the `Retry-After` header before the next request.

```bash
POLL="https://www.wireflow.ai/api/v1/workflows/executions/exec_789/poll"
for i in $(seq 1 120); do
  CODE=$(curl -s -D headers.txt -o run.json -w '%{http_code}' "$POLL" \
    -H "Authorization: Bearer $WIREFLOW_API_KEY")
  if [ "$CODE" = "429" ]; then
    WAIT=$(grep -i '^retry-after:' headers.txt | tr -dc '0-9')
    sleep "${WAIT:-5}"; continue
  fi
  if [ "$CODE" != "200" ]; then echo "HTTP $CODE: $(cat run.json)"; exit 1; fi
  STATUS=$(jq -r '.status' run.json)
  echo "$STATUS"
  case "$STATUS" in
    COMPLETED|FAILED|CANCELLED|TIMEOUT) break ;;
  esac
  sleep 5
done
```

```javascript
const FINAL = ['COMPLETED', 'FAILED', 'CANCELLED', 'TIMEOUT'];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function waitUntilDone(executionId) {
  const url = `https://www.wireflow.ai/api/v1/workflows/executions/${executionId}/poll`;
  const headers = { Authorization: `Bearer ${process.env.WIREFLOW_API_KEY}` };
  let delay = 1000;
  const deadline = Date.now() + 20 * 60 * 1000;
  while (Date.now() < deadline) {
    const res = await fetch(url, { headers });
    if (res.status === 429) {
      await sleep(Number(res.headers.get('Retry-After') || 5) * 1000);
      continue;
    }
    if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
    const run = await res.json();
    if (FINAL.includes(run.status)) return run;
    await sleep(delay);
    delay = Math.min(delay * 1.5, 10000);
  }
  throw new Error('Polling deadline exceeded');
}

const run = await waitUntilDone('exec_789');
console.log(run.status, run.error ?? '');
```

```python
import os
import time
import requests

FINAL = {"COMPLETED", "FAILED", "CANCELLED", "TIMEOUT"}

def wait_until_done(execution_id):
    url = f"https://www.wireflow.ai/api/v1/workflows/executions/{execution_id}/poll"
    headers = {"Authorization": f"Bearer {os.environ['WIREFLOW_API_KEY']}"}
    delay = 1
    deadline = time.time() + 20 * 60
    while time.time() < deadline:
        res = requests.get(url, headers=headers)
        if res.status_code == 429:
            time.sleep(int(res.headers.get("Retry-After", "5")))
            continue
        res.raise_for_status()
        run = res.json()
        if run["status"] in FINAL:
            return run
        time.sleep(delay)
        delay = min(delay * 1.5, 10)
    raise TimeoutError("Polling deadline exceeded")

run = wait_until_done("exec_789")
print(run["status"], run.get("error", ""))
```

The 1-second start, the 1.5 times growth and the 10-second cap follow the polling strategy in [Executions](/docs/api/executions#poll-execution-status). Each example stops after a client deadline, so raise it for long videos.

## Which status means the job is finished?

`COMPLETED` and `FAILED` are the normal endings, and `CANCELLED` and `TIMEOUT` can also appear. `RUNNING` and `PENDING` mean the job is still going.

| `status` | Finished? | What to do |
| --- | --- | --- |
| `RUNNING` | No | Keep polling |
| `PENDING` | No | Keep polling |
| `COMPLETED` | Yes | Read the outputs |
| `FAILED` | Yes | Show the `error` message to a person |
| `CANCELLED` | Yes | The run stopped before it finished |
| `TIMEOUT` | Yes | The run stopped before it finished |

The `202` reply from `/run` says `running` in lowercase. The poll and the webhook use the uppercase values above, so branch on those. `error` is a message for a person to read and its wording can change, so branch on `status`, never on the message. Files that finished generating before a run stopped are still charged.

## How do I get a webhook when the job is done?

Add a `webhook` block with your `https` URL, and a `secret` to sign it, to the `POST /run` body. When the run reaches a final status, Wireflow sends one `POST` to that URL with the result.

```bash
curl -X POST https://www.wireflow.ai/api/v1/workflows/YOUR_WORKFLOW_ID/run \
  -H "Authorization: Bearer $WIREFLOW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "inputs": { "node-prompt": "A cat wearing a top hat" },
    "metadata": { "jobId": "job_42" },
    "webhook": {
      "url": "https://yourapp.com/api/webhooks/wireflow",
      "secret": "YOUR_WEBHOOK_SECRET"
    }
  }'
```

```javascript
const res = await fetch('https://www.wireflow.ai/api/v1/workflows/YOUR_WORKFLOW_ID/run', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.WIREFLOW_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    inputs: { 'node-prompt': 'A cat wearing a top hat' },
    metadata: { jobId: 'job_42' },
    webhook: {
      url: 'https://yourapp.com/api/webhooks/wireflow',
      secret: process.env.WIREFLOW_WEBHOOK_SECRET,
    },
  }),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
```

```python
import os
import requests

res = requests.post(
    "https://www.wireflow.ai/api/v1/workflows/YOUR_WORKFLOW_ID/run",
    headers={"Authorization": f"Bearer {os.environ['WIREFLOW_API_KEY']}"},
    json={
        "inputs": {"node-prompt": "A cat wearing a top hat"},
        "metadata": {"jobId": "job_42"},
        "webhook": {
            "url": "https://yourapp.com/api/webhooks/wireflow",
            "secret": os.environ["WIREFLOW_WEBHOOK_SECRET"],
        },
    },
)
res.raise_for_status()
```

The delivery is a JSON body like this:

```json
{
  "event": "execution.completed",
  "eventId": "550e8400-e29b-41d4-a716-446655440000",
  "executionId": "exec_789",
  "workflowId": "cm1abc123",
  "status": "COMPLETED",
  "nodeStates": [
    {
      "nodeId": "node-video",
      "label": "Final video",
      "status": "COMPLETED",
      "error": null,
      "outputUrl": "https://cdn.wireflow.ai/..."
    }
  ],
  "metadata": { "jobId": "job_42" },
  "createdAt": "2026-09-28T12:01:15.000Z"
}
```

- `event` is `execution.completed` when `status` is `COMPLETED`, and `execution.failed` for `FAILED`, `TIMEOUT` and `CANCELLED`. Read `status` for the exact reason.
- `nodeStates` has one entry per node that ran, with `outputUrl`, `outputUrls`, `text` or `error` as they apply.
- `metadata` comes back only if you sent it, so use it to match the result to your own job.
- The request carries the headers `X-Wireflow-Event`, `X-Wireflow-Delivery` and, when you set a secret, `X-Wireflow-Signature`.

`webhook.url` must be `https`, with plain `http` accepted only for `localhost`, `127.0.0.1` and `*.localhost`, and it can be at most 2048 characters. An invalid block is refused with `400` and code `invalid_webhook` before anything runs.

## How do I check that a webhook really came from Wireflow?

Compute an HMAC-SHA256 of the raw request body with your secret and compare it with the `X-Wireflow-Signature` header, which reads `sha256=` followed by the hex digest. Check it before you trust the payload, and use the body exactly as it arrived, before any JSON parsing.

```javascript
import { createHmac, timingSafeEqual } from 'crypto';

function verify(rawBody, header, secret) {
  const expected = `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`;
  const a = Buffer.from(header || '');
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

```python
import hashlib
import hmac

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest((header or "").encode(), expected.encode())
```

The signature header is only there when you set `webhook.secret` on the run.

## What happens if my server misses the webhook?

Wireflow retries it. Any `2xx` answer counts as delivered, and anything else, or no answer within 10 seconds, is retried with exponential backoff, starting at 30 seconds and capped at 6 hours, for up to 12 attempts.

- **Deliveries are at least once.** Each run produces one event, and its retries reuse the same `eventId`, so skip an `eventId` you have already handled.
- **Answer fast.** Return `200` first and do the heavy work afterwards.
- **Polling still works.** If every attempt fails, poll the `executionId`, or list the workflow's recent runs with `GET /api/v1/workflows/{id}/executions`.

## Can a webhook start a run?

No. The webhook is outbound only: Wireflow calls you when a run ends. To start a run from another system, a cron job or your own backend, call `POST /api/v1/workflows/{id}/run` with an API key, and add a `webhook` block if you want the result pushed back. The [Webhooks reference](/docs/api/webhooks) states that inbound trigger URLs are not available.

## How do I check a run from a published app or an AI agent?

An app run gives you its own `pollUrl`, and an agent connected over MCP asks with the `get_execution` tool.

- **Apps.** `POST /api/v1/apps/{slug}/execute` returns an `executionId`, a `pollUrl` and, when the server could sign one, a `pollToken`. Put `https://www.wireflow.ai` in front of `pollUrl` and `GET` it every few seconds. Authenticate with an `apps:execute` key from the same account that started the run, or with the token in the `x-wireflow-run-token` header or the `?t=` query parameter. The token lasts 24 hours.
- **Agents.** Over MCP, `run_workflow` and `run_app` return an `executionId`, and `get_execution` returns the status and the output URLs.

```bash
curl "https://www.wireflow.ai/api/v1/apps/YOUR_APP_SLUG/executions/EXECUTION_ID/poll" \
  -H "x-wireflow-run-token: YOUR_POLL_TOKEN"
```

Each item in an app's `outputs` has `nodeId` and `type`, plus `urls` for media, `text`, or both. See [Apps API](/docs/api/apps) for the full shape.

## How do I see how long a job took and what it cost?

Call `GET /api/v1/workflows/{id}/executions` with your API key once the run has finished. Each run in the list includes `executionTime`, `creditsUsed`, `startedAt` and `completedAt`. The full record at `GET /api/v1/workflows/executions/{executionId}` takes a browser session only and does not accept API keys.

```bash
curl "https://www.wireflow.ai/api/v1/workflows/YOUR_WORKFLOW_ID/executions?limit=5" \
  -H "Authorization: Bearer $WIREFLOW_API_KEY"
```

Response `200 OK` (trimmed):

```json
{
  "data": [
    {
      "id": "exec_456",
      "workflowId": "cm1abc123",
      "status": "COMPLETED",
      "executionTime": 15000,
      "creditsUsed": 10,
      "startedAt": "2025-03-20T12:01:00.000Z",
      "completedAt": "2025-03-20T12:01:15.000Z"
    }
  ]
}
```

To list a workflow's recent runs, newest first, call `GET /api/v1/workflows/{id}/executions` with `limit` (default 10) and `offset`.

Last updated: 2026-10-22

---

Documentation index: fetch https://www.wireflow.ai/llms.txt for the full list of Wireflow docs.
