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.

  • status is in_review — the project as a whole is in its human stage.
  • review.status is in_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