Temporal workflow signal from an AI video webhook

Start the video job in a Temporal activity, wait on a signal your webhook receiver sends by workflow id, and keep a timer that reads the run anyway.

6 min readSume
All posts

A Temporal workflow signal is an asynchronous message that a client sends to a running workflow execution, addressed by its workflow id. The workflow handles it in a signal handler, which can change the workflow's state but can't return a value, and the sender doesn't wait for the workflow to process it. That makes a signal the natural way to wake a workflow when an outside job, such as an AI video render, sends its webhook.

Here the outside job is a Sume Format run: an activity starts it, your webhook receiver signals the workflow when Sume POSTs the result, and a timer covers runs that never POST. Temporal facts come from its message passing, Python message passing, Workflow Definition, Activities, and Retry Policies pages; Sume facts from Create a run, Runs and results, and Run webhooks. All were read on 2026-09-27. Sume has no Temporal integration: your activities call its HTTPS API.

Why does the API call go in an activity, not the workflow?

Temporal requires workflow code to be deterministic so it can be replayed, and tells you to put API calls in activities, which run outside the replay path and are retried automatically. The default retry policy starts at a 1-second interval with a backoff coefficient of 2.0 and unlimited attempts, which is why Temporal recommends that activities be idempotent.

For a paid API that matters: a retried activity must not buy a second video. Send Sume an Idempotency-Key built from the same business id as the workflow id. The same key with the same body returns 200 and the original run, with no second run and no second charge; the same key with a different body is 409 idempotency_conflict, so build the whole body from that id too.

  • start_run(order_id) POSTs /v1/formats/acme/product-promo/runs with Idempotency-Key: video-<order_id>, the string you also use as the workflow id, and a communication.webhook_url such as https://hooks.example.com/sume?workflow_id=video-<order_id>. It returns data.id.
  • read_run(run_id) GETs /v1/format-runs/{run_id} and returns only status, primary_output_url, and error from the receipt.

How does the workflow wait for the signal?

It starts the run, then blocks on workflow.wait_condition until the signal handler has recorded that run's id. With a timeout, wait_condition throws asyncio.TimeoutError when time runs out, and the workflow reads the run once either way:

import asyncio
from datetime import timedelta
from temporalio import workflow

@workflow.defn
class VideoWorkflow:
    def __init__(self) -> None:
        self.ended_run_id: str | None = None

    @workflow.signal
    def run_ended(self, run_id: str) -> None:
        self.ended_run_id = run_id  # a repeated delivery sets the same value

    @workflow.run
    async def run(self, order_id: str) -> dict:
        t = timedelta(seconds=30)
        run_id = await workflow.execute_activity(start_run, order_id, start_to_close_timeout=t)
        try:
            await workflow.wait_condition(
                lambda: self.ended_run_id == run_id, timeout=timedelta(minutes=100))
        except asyncio.TimeoutError:
            pass  # no webhook came: canceled, or every delivery refused
        return await workflow.execute_activity(read_run, run_id, start_to_close_timeout=t)

How does the webhook receiver send the signal?

First it verifies Sume's signature on the raw body: refuse an empty signing secret, then check HMAC-SHA256 over <timestamp>.<raw_body>, a match on any sume-v1= entry in constant time, and a five-minute timestamp window. Python webhook HMAC verification in FastAPI and Django has that code. Then it signals by workflow id. A receiver usually doesn't import the workflow class, so it uses Temporal's untyped get_workflow_handle and passes the signal's name:

  • signal returns when the Temporal server accepts it, without waiting for it to be delivered to the workflow, so the receiver can answer 2xx inside the 10 seconds Sume gives each attempt.
  • Signals reach only executions that haven't closed, and a failed signal raises an RPCError, with status NOT_FOUND when the workflow doesn't exist. Answer 2xx for a workflow that's gone, or Sume retries, up to 10 attempts; for other errors, a non-2xx answer gets you a retry.
  • Each signal is recorded in the workflow's Event History, and the handler only stores a value, so a webhook that lands before wait_condition runs is not lost.
  • Send only the run id. A Sume receipt can reach 1 MiB, while Temporal's self-hosted defaults warn at 256 KB for a payload; read_run fetches the receipt instead.
from temporalio.client import Client

async def on_verified_delivery(client: Client, workflow_id: str, event: dict) -> None:
    handle = client.get_workflow_handle(workflow_id)  # from ?workflow_id=
    await handle.signal("run_ended", event["run_id"])

Should the receiver use Signal-With-Start?

No. Signal-With-Start sends a signal and starts the workflow execution if it isn't already running; in Python you pass start_signal to start_workflow. From a webhook receiver, that turns a late or repeated delivery into a new workflow for a video that already exists. Keep it for messages that should create work, and send results with a plain signal.

How long should the workflow wait?

Past Sume's own deadline: a run still in progress is force-finalized as failed 90 minutes after created_at, so with a 100-minute timeout, the run has ended by the time the workflow reads it. A timeout creates a Temporal timer, and timers are persisted, so the wait survives a worker restart. The timer also catches the runs that never signal:

From Sume's Runs and results and Run webhooks pages, read 2026-09-27.
What happened to the runWebhookWhat the workflow sees
Completed or failedOne signed POST, retried up to 10 attemptsThe signal
CanceledNever sentThe timeout; read_run shows canceled
Your endpoint refused all 10 attemptsDelivery failed; the run is unchangedThe timeout; read_run has the result

Sources

Related posts

More in Integrations

All Integrations posts

Written by Sume