Project lifecycle
GET /v1/projects/{id} reports progress through two independent fields. status is the project pipeline. review is the human stage.
They move separately. A stalled human stage shows on review.status and leaves status exactly where it was — so a loop that watches only status never sees it.
On this call against a live key the review block is always present, with status null and service_plan [] until a review is requested. Do not test review for null to decide whether a project has a human stage — test review.status. On list rows and on the create response the whole block is null, so poll GET /v1/projects/{id}, not GET /v1/projects?status=…, if you need the human stage at all.
When to stop polling
| Condition | What to do |
|---|---|
review.status is awaiting_payment |
The human stage has not started. Confirm, then resolve — see below. status does not change |
review.status is cancelled or failed |
Stop. The human stage did not deliver in full. Some or all deliverables are the AI output |
status is awaiting_payment |
Hold — intelligence credits. Top up, then resume |
status is completed or published |
Done. Download deliverables |
status is failed or cancelled |
Stop. There is nothing to download |
status is refunded |
Stop. Deliverables may still be listed if the project finished before the refund, but the work was refunded — do not treat it as delivered |
| anything else | Keep polling |
Order matters. Test review.status before you act on status. Cancelling a review promotes the project to completed, and a stalled one can sit at completed too, so status alone will tell you a project is delivered when its human stage never ran.
Wait for awaiting_payment to settle before acting. review.status is derived on read rather than stored, and it means one narrow thing: the workflow has a human stage, the AI stage has finished, and no review job exists yet. For a few seconds between the AI stage finishing and the stage starting, that is true of a perfectly healthy project, and a short poll interval lands in that window regularly. Give it a minute before you treat it as stalled.
Check the cheap signal first, then resolve. review.status does not say why there is no review job. assessment.credits.trust.sufficient, on the same response, tells you whether trust credits are the reason — no extra call and nothing charged. If it is true, credits are not the problem and the send is stuck for some other reason; either way the resume call below is what restarts it.
published is set from the arbitr web app and refunded is issued by Straker support, so neither is something your integration causes. A client polling that project will still see them.
The poll loop
import time
DELIVERED = {"completed", "published"}
FINISHED = DELIVERED | {"failed", "cancelled", "refunded"}
class ReviewNotStarted(Exception):
"""The human stage has not started. Resolve it with the call below."""
class ReviewNotDelivered(Exception):
"""The human stage ended without delivering. Do not ship the output."""
deadline = time.monotonic() + 6 * 60 * 60
review_hold_since = None
while True:
if time.monotonic() > deadline:
raise TimeoutError("project did not finish in time")
resp = client.get(f"/v1/projects/{project_id}")
if resp.status_code == 429 or resp.status_code >= 500:
time.sleep(int(resp.headers.get("Retry-After", 30)))
continue
if resp.status_code != 200:
err = resp.json()["error"]
raise RuntimeError(f"{err['code']} ({err['request_id']})")
body = resp.json()
status = body["status"]
review = body.get("review") or {}
# Both branches below have to precede the `status` checks: `status` reads
# `completed` on a project whose human stage never delivered.
if review.get("status") in ("cancelled", "failed"):
raise ReviewNotDelivered(project_id) # deliverables may be AI-only
if review.get("status") == "awaiting_payment":
# True for a few seconds while the human stage starts, so let it
# settle.
if review_hold_since is None:
review_hold_since = time.monotonic()
elif time.monotonic() - review_hold_since > 60:
raise ReviewNotStarted(project_id)
else:
review_hold_since = None
if status == "awaiting_payment":
raise RuntimeError("top up intelligence credits, then resume")
if status in DELIVERED:
break
if status in FINISHED:
raise RuntimeError(status)
time.sleep(5)
Always set a deadline. A project parked for credits stays parked until somebody tops up in the web app, and there is no published call that tells you it happened.
Sleep a few seconds between calls, and honour Retry-After. Tight loops hit rate limits. Any other non-2xx carries the standard envelope — branch on error.code and keep error.request_id. See Errors.
Resolving a human stage that never started
Once review.status has read awaiting_payment for long enough to be real, ask the API to start the stage rather than guessing why it did not. POST /v1/projects/{id}/review/resumptions re-attempts the same send the pipeline would have made, and its answer is the diagnosis:
| Response | What it means |
|---|---|
200 |
It went through. charged_tc reports what it cost; resume polling |
402 payment_required |
A genuine trust-credit shortfall. error carries required, available and shortfall. Top up in the arbitr web app, then call again |
409 not_awaiting_payment |
There was nothing to resume — the workflow has no human stage, a review job already exists, or the project is not at a point where the stage can start (the AI stage has not finished, or the project has moved past it) |
This call needs the verify:submit scope, while polling needs only verify:read. A read-only key gets 403.
Do not send it as a probe while you are still confirming the hold. Inside that first minute the call is not a no-op: it creates and charges a review job, racing the send that was about to happen anyway.
The normal path
AI only
extracting -> agent_selection -> translating -> scored -> completed
With a human stage
... -> scored -> in_review -> completed
Holds
intelligence credits translating -> awaiting_payment -> translating
trust credits scored, with review.status = awaiting_payment
Ends
completed -> published published from the web app
completed / failed / cancelled -> refunded
Credits are charged when machine translation launches, so the project is already translating when an intelligence shortfall parks it.
What the intermediate values mean
These are the values you can see today. Do not branch on them. New intermediate values can appear, and existing ones can change, without an API version bump. The only values with a stable meaning are the ends and the two holds above. Everything here is for logs and dashboards.
| Value | What is happening |
|---|---|
pending |
Created, pipeline not started — see below, this is usually a fault |
extracting |
Reading the text out of your source files |
agent_selection |
An internal pre-flight pause. Projects created through the API are pre-armed and pass straight through |
translating |
Machine translation and automatic verification are running |
scored |
The AI stage is finished. Delivery or a human stage comes next |
in_review |
A human translation or edit stage is with a linguist |
Two kinds of awaiting_payment
There are two wallets, so there are two holds, and they do not look the same on the wire. See Workflows & credits.
| Hold | Where it shows | Resume with |
|---|---|---|
| Intelligence — AI stage | status becomes awaiting_payment |
POST /v1/projects/{id}/resumptions |
| Trust — human stage | review.status becomes awaiting_payment, and status stays scored or completed |
POST /v1/projects/{id}/review/resumptions |
The trust hold is the one that breaks naive clients. The project sits at scored, which is not an end, so a loop with no review.status check and no deadline runs until you kill it. Note that review.status does not distinguish a shortfall from any other reason the stage has not started — only the resume call does.
Full walkthrough: Resume after payment.
in_review means two different things
in_review is a value of both fields, and they are not the same thing.
statusisin_review— the project as a whole is in its human stage.review.statusisin_review— the review job itself is being worked on.
They agree once the work is genuinely in progress, but not from the start: status flips to in_review the moment the review job exists, while review.status still reads queued until the work is picked up. They diverge outright on a stall, where review.status reads awaiting_payment and status does not move.
review.status takes queued, in_review, completed, failed, cancelled or awaiting_payment, and is null when no review was ever requested.
If create returns pending
A successful create returns an early status, normally extracting. If it returns pending, the pipeline failed to start. The project exists and the response was 201, but nothing will advance it: there is no published route that restarts a project, and the resume routes answer 409 because the project is not awaiting payment.
Do not retry the poll. Create a new project, and send the failed project id and your request_id to Support.
Values you will not see on a live project
| Value | Why |
|---|---|
draft |
Web app drafts. They are left out of the default list; ?status=draft returns them. Nothing you create through the API is ever a draft |
processing |
The retired name for translating. Nothing sets it today, though projects created long ago may still carry it. A test key always returns it |
preflight |
Another retired name — it was the original default on a new project. Nothing sets it today, though very old projects may still carry it |
extracted, reviewing |
Not project statuses at all — they belong to files, batches and review assignments |
Filtering by status
GET /v1/projects?status= takes one value and matches it exactly. Any status value is accepted; an unknown one is not an error, it just returns an empty list. Over 64 characters is a 422. Filter on translating, not processing. See List and filter projects.
Test mode
A test project is created as processing and stays there — nothing runs, so nothing completes. Deliverables are available immediately, so list them instead of polling. A loop that waits for completed against abr_test_… never exits.
Two more differences that matter to a loop written against this page. A sandbox project carries review as null, even on the detail call, so keep the body.get("review") or {} guard. And the list filters are ignored — every sandbox project comes back on one page regardless of ?status=, ?modified_after=, ?page= or ?limit=. POST /v1/projects/{id}/review/resumptions always answers 409 not_awaiting_payment on a test key. See Test mode.
Next
- Poll project status — the endpoint, its scopes and the rest of the response
- Resume after payment — both holds, end to end
- Download deliverables — once the project is delivered