Handoff, insight and release-notes generation runs on a worker, not inline. Any generation endpoint returns 202 Accepted with a jobId instead of the artifact. You poll the job until it reaches a terminal state.

The jobId is the job's own id (a UUID), not the artifact id. The artifact id arrives as result_id in the job response once it completes – along with the full artifact under result.

One exception: POST /projects/{id}/qa answers synchronously and returns the answer in the response body – there is no job to poll. Scheduled digests run the other way round: the worker creates the job on its own cadence, so you never receive a 202 for them and read the output with GET /projects/{id}/digests.

A job moves through these statuses:

pending -> processing -> completed
                      -> failed
  • pending – enqueued, not yet picked up.
  • processing – the worker is generating.
  • completed – done; result_kind says where the artifact lives (handoff, insight – release notes included – or digest) and result_id points at it.
  • failed – generation failed; error and error_code explain why.

Poll GET /jobs/{jobId} every couple of seconds with a sane timeout. Stop on completed or failed.

async function waitForJob(jobId, apiKey, { intervalMs = 2000, timeoutMs = 120000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const res = await fetch(`https://www.flowrelay.it/api/v1/jobs/${jobId}`, {
      headers: { Authorization: `Bearer ${apiKey}` },
    });
    const { job, result } = await res.json();
    if (job.status === "completed") return result;
    if (job.status === "failed") throw new Error(job.error ?? "Generation failed");
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  throw new Error("Timed out waiting for job");
}
import time
import requests
 
def wait_for_job(job_id, api_key, interval=2, timeout=120):
    deadline = time.time() + timeout
    headers = {"Authorization": f"Bearer {api_key}"}
    while time.time() < deadline:
        resp = requests.get(
            f"https://www.flowrelay.it/api/v1/jobs/{job_id}", headers=headers
        )
        payload = resp.json()
        job = payload["job"]
        if job["status"] == "completed":
            return payload["result"]
        if job["status"] == "failed":
            raise RuntimeError(job.get("error") or "Generation failed")
        time.sleep(interval)
    raise TimeoutError("Timed out waiting for job")

While a job is pending or processing it may carry a progress object with the stage the worker actually reached, so you can show a real step instead of a spinner:

{
  "job": {
    "id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
    "status": "processing",
    "progress": { "phase": "generating", "at": "2026-07-24T10:02:11.482Z" }
  },
  "result": null
}

Phases run in order: loading_context (fetching events and building scope), retrieving (semantic retrieval and chunk substitution), generating (the model call – the long one) and saving (persisting the artifact). Treat progress as optional: it is null until the worker writes the first phase, and jobs generated inline in development never write one at all.

Project handoffs and architecture insights can take tens of seconds because they build context and run larger models. Use a timeout of 120 seconds and a poll interval of 2 seconds. Do not poll faster than once per second – it only burns your rate limit.

When status is completed, the job response embeds the artifact under result. result_kind tells you whether it is a handoff or an insight, and result_id is the row id you can fetch again later via the list endpoints.

See Errors for what a failed job's error_code means.