---
title: "List screening batch items"
method: GET
path: "/api/v1/screening-batches/{id}/items"
tags: ["Vessel screening"]
---

# List screening batch items

`GET /api/v1/screening-batches/{id}/items`

## Request

- Base URL: `https://arcnautical.com`
- URL: `https://arcnautical.com/api/v1/screening-batches/{id}/items`
- Auth: one of:
  - HTTP bearer
  - API key in header `X-ArcNautical-Key`

Returns cursor-paginated per-hull screening results and partial failures.

Required scope: vessel:screen.

## Path parameters

- `id` string, required

## Query parameters

- `cursor` string
- `limit` integer

## Response `200`

Successful response.

- ListScreeningBatchItemsResponse
  - `items` ScreeningBatchItem[], required
    - `item_index` integer, required
    - `status` 'queued' | 'running' | 'succeeded' | 'failed', required
    - `imo` string, nullable
    - `customer_reference` string, nullable
    - `screening_id` string, uuid, nullable
    - `result_body` Screening
      - `id` string, uuid, required
      - `imo` string, required
      - `imo_check_digit_valid` boolean, required: False means the IMO fails its check digit. The hull was still screened. Treat as a flag to verify the identifier, not as a screening failure.
      - `imo_normalized_from` string: Present ONLY when `imo` is not the value you sent. You sent this value, a valid IMO with one digit appended (e.g. "94827701"), and `imo` is the IMO we screened (9482770). This happens only on accounts where the correction is enabled, because their own traffic shows a pipeline appending one digit; every other account gets a 422 for that input. Absent on every other screening, including all written before 2026-09-25. When present, the `imo_normalized` notice says the same in words. Fix the source value on your side. If `imo` is not the hull you meant, do not rely on the verdict.
      - `customer_reference` string, nullable
      - `vessel_name` string, nullable, required: Null when identity could not be resolved. A null name is why `sanctions.status` may be INCOMPLETE rather than GREEN — name-based screening is meaningless without it.
      - `identity_sources` object[]: Which identity registers were asked for this hull and what each said. identity_basis says whether we named the hull; this says what we looked in. It exists because one sentence cannot carry two opposite facts: every register answered and none lists this hull, versus a register was UNREACHABLE and we do not know. Those have opposite retry policies, and reading the second as a clearance is the error this field prevents. named is true for every register that returned a usable name (they can disagree — a rename — and `name` on each row says what it said; `vessel_name` is the one we chose, the rest were screened as prior names). reachable: false means the lookup threw, so silence from that register is not evidence. Five registers are stores we hold (arcnautical_vessel_register, ais_registry, tokyo_mou_psc, paris_mou_thetis, apcis_tokyo_mou_history); when all five are silent, four public registers are read LIVE inside the request under a time budget (dnv_vessel_register, itf_ship_lookup, iacs_vessels_in_class, uscg_psix) — on one of those, reachable: false means it did not answer in time, its lookup is finishing in the background, and sanctions.retry_after is when it will have settled — the first slot of the automatic re-screen (see resolution). Recorded from what the resolver did, so a register that is removed stops appearing instead of being claimed forever.
        - `register` string, required: Published register name, e.g. paris_mou_thetis.
        - `named` boolean, required
        - `reachable` boolean, required
        - `name` string: What this register named the hull, present only when named is true. The name we chose is vessel_name; any other name here was screened as a prior name, and a match on it carries screened_name.
      - `identity_basis` 'sources' | 'user' | 'none': WHOSE name this hull was screened under. `sources` — we resolved it from our own identity data. `user` — we could not, and screened under the vessel_name YOU supplied; we have not confirmed that name belongs to this IMO, so verify it against the certificate before relying on the result. `none` — we could not resolve a name and you supplied none, so name-based matching did not run at all and the status is INCOMPLETE. A GREEN on `user` is not the same claim as a GREEN on `sources`. DELIBERATELY NOT REQUIRED: a stored screening is replayed verbatim as compliance evidence and is never rewritten, so screenings run before 2026-09-08 do not carry this field. Absent means the same as `sources` for those rows — the only identity path that existed.
      - `sanctions` ScreeningSanctions, required
        - `status` 'RED' | 'AMBER' | 'INCOMPLETE' | 'GREEN', required: RED — a confirmed IMO/MMSI-level match on a designating list. AMBER — a potential match needing review, NOT a designation, and NOT necessarily name-only: it is a name-only hit, OR an exact identifier listing in a source that does not itself designate (e.g. the Ukraine GUR war register), OR an identifier hit whose primary-list tag is uncorroborated and may be delisted. Read `detail` for which of the three drove it — an AMBER can carry an exact_imo match at full confidence. INCOMPLETE — fail-closed: identity or a mandatory list was unavailable, so no clearance can be defended. GREEN — screened against all reachable lists and matched none, as of the dates in `sources`.
        - `detail` string, required
        - `coverage_complete` boolean, required
        - `coverage_gaps` string[], required: Display names of lists that did not load. Non-empty means say so rather than reporting a clean result.
        - `retry_after` string, date-time, nullable: When a re-screen of this exact request may answer differently. Set ONLY on an INCOMPLETE whose cause is a hull we could not name, and only when something we set in motion can still change that: either our next scheduled port-State fetch will ask about the hull — asking placed it at the head of that demand-ordered queue, and this is the instant by which that fetch will have run (its worst case) — or a register we read live did not answer inside the request time budget (`identity_sources` marks it `reachable: false`), its lookup is finishing in the background, and this is the instant by which it will have settled. The `identity_unresolved` notice says which. Null on every other response, INCLUDING the INCOMPLETEs nothing pending can change: a failing check digit, and a hull every store has already been asked about and every live register answered without naming — there only `vessel_name` answers. Since 2026-09-15 you need not act on it: an INCOMPLETE that carries `resolution` is re-screened for you, and this instant is its first scheduled slot (`resolution.next_attempt_at`). If you do re-screen yourself, use a NEW Idempotency-Key; the old key replays the stored INCOMPLETE. Absent on records stored before 2026-09-13.
        - `matches` ScreeningMatch[], required
          - `source` 'ofac_sdn' | 'opensanctions' | 'eu_sanctions' | 'un_sanctions' | 'uk_ofsi' | 'vessel_vetting', required: The list the matched record came from. `vessel_vetting` NEVER appears on a match — it is a `sources[]` row (see `SourceStatus.source`), was listed here by mistake, and is retained only because removing a value from a closed enum is a breaking change under the stability policy; it will be removed with the 90-day notice.
          - `name` string, required: The sanctioned entity name as it appears in the source list.
          - `match_type` 'exact_imo' | 'exact_mmsi' | 'name_fuzzy', required
          - `confidence` number, required
          - `confidence_class` 'confirmed' | 'probable' | 'possible' | 'weak': How sure the match is. Only `confirmed` is a designation, and read it WITH `record_type`: `confirmed` on a `vessel` record designates this hull; `confirmed` on an `entity` record designates a company, not the ship. Report anything else as an unconfirmed match to verify — restating a name-only hit as fact is defamatory and wrong.
          - `record_type` 'vessel' | 'entity': WHAT was matched, as distinct from how well. `vessel` — the source record is a ship, so an identifier hit designates this hull. `entity` — the record is a company or organisation; an `exact_imo` hit on one means the number matched an IMO **Company** Number, which IMO issues from the same 7-digit series as ship numbers with the same check digit. That is a real finding about the operator and NOT a designation of the hull. Absent when the source publishes no type for the record — read that as unknown, never as `vessel`. Note that one source yields both: the EU list carries 728 designated vessels and 91 companies that happen to hold an IMO company number, so this cannot be inferred from `source`.
          - `drove_verdict` boolean: Whether THIS match is one the verdict came from. The list is deliberately unfiltered — a name collision we ruled out is still evidence a reviewer may want to see — so on a RED the rows with `drove_verdict: true` are the confirmed designations that carried it, and on an AMBER they are the hits that survived our collision filter. On GREEN and INCOMPLETE every row is `false`: nothing drove the status. ABSENT means NOT COMPUTABLE, never "no" — it is omitted when a driving record carries no stable source id to join on, because reporting `false` on the row that actually carried a RED would be worse than saying nothing. Use it to answer "which listing must I clear?" without re-deriving our logic; keep reading every row for review.
          - `screened_name` string: The name this record matched, when it is NOT vessel_name. Our identity registers can return more than one name for a hull — that is what a rename looks like (a class register carries the current name while an older file still carries the yard name) — and the hull is screened under every one of them, so a name hit may be against a name this response otherwise shows only in identity_sources[].name. Absent when the match was on vessel_name or on an identifier.
          - `programs` string[], required: Sanctions programs, e.g. ["RUSSIA-EO14024"].
          - `source_record_id` string: The identifier the SOURCE assigns to the matched record, so you can look it up there without repeating our search: the OFAC SDN entry number (`49248`), the UN permanent reference number (`LYe.006` — the one the Security Council's own notices cite), the OFSI Unique ID (`DPR0075`), the OpenSanctions entity id (`NK-…`). For `eu_sanctions` it depends on which of the two EU files the record came from: the EU reference number (`EU.4198.83`) on a record from the Commission's consolidated financial sanctions file, read directly; the OpenSanctions entity id on an Official Journal record (Annex XLII vessel designations), which reach us through that mirror. Present on every match screened since 2026-09-15; absent on records stored before, which are evidence and are never rewritten.
          - `source_url` string, uri: The matched record's own page at the source, where the source publishes one: OFAC's sanctions search (`Details.aspx?id=`), the OpenSanctions entity page, the FCDO UK Sanctions List search (`/designations/<Unique ID>/<Ship|Entity|Individual>`), and for an EU financial-list record the Official Journal act that lists it, on EUR-Lex. ABSENT — not null, not a list-level link — for `un_sanctions`, which has no per-entry page. Every shape emitted was fetched before it was written down, and the customer-POV gate fetches every URL a RED emits, because a link that does not resolve is worse than none in an audit record.
          - `listed_on` string, date, nullable: The date the record went on the list, as the SOURCE states it: OFAC's entry-creation date (from the SDN advanced XML — the CSV never carried one), the UN's LISTED_ON, OFSI's Date Designated, the earliest listing date any authority's designation states on an OpenSanctions entity, the EU's own designation date on a financial-list record. NULL means the edition consulted states no date for this record. It never means "not listed" — the match is still a match. Absent on records stored before 2026-09-15.
          - `list_edition` object: Which edition of the list this match was found in — the file a compliance record names, kept as two timestamps because they answer different questions. `published` is what the source itself stamps on the edition (OFAC's DateOfIssue, the UN's dateGenerated, the FCDO report date, the OpenSanctions file's Last-Modified, the Commission's generationDate on the EU financial sanctions file); `retrieved_at` is when we took the copy we screened against. It is per RECORD, not per source: `eu_sanctions` is two files — the Commission's own export, regenerated on amendment, and the daily OpenSanctions mirror of the Official Journal — and each match names the one it was found in. `sources[].checked_at` collapses these into one value with a `basis`, which answers "how current?"; this answers "which file?". Absent on records stored before 2026-09-15.
            - `published` string, date-time, nullable, required: The edition date the source states. Null when it states none or the source was unavailable.
            - `retrieved_at` string, date-time, nullable, required: When we fetched the copy screened. Null when the source was unavailable.
      - `ownership` object, required: Both fields are NULL when `vetting.assessed` is false — ownership could not be resolved for this hull, and a band is not reported unless it was measured. Never read a missing band as LOW or MEDIUM.
        - `opacity` 'LOW' | 'MEDIUM' | 'HIGH' | 'null', nullable, required
        - `score` integer, nullable, required: Higher = harder to trace the beneficial owner.
      - `vetting` ScreeningVetting, required
        - `grade` 'A' | 'B' | 'C' | 'D' | 'E' | 'null', nullable, required: NULL whenever `assessed` is false. There is no stand-in grade: a letter beside `assessed: false` gets read as a letter.
        - `score` integer, nullable, required: Higher = worse. NULL whenever `assessed` is false.
        - `status` 'acceptable' | 'marginal' | 'substandard' | 'unacceptable' | 'not_assessed' | 'not_requested', required: Grade band when assessed: `acceptable` (A-B), `marginal` (C), `substandard` (D), `unacceptable` (E). `not_assessed` — enrichment ran and produced no grade. `not_requested` — you passed `include_vetting: false`; nothing was attempted. The set has been closed since 2026-09-06; it is published as an enum from 2026-09-13.
        - `assessed` boolean, required: False means the hull could not be assessed. `grade`, `score`, `detention_count` and `factors` are then NULL/empty rather than fail-open defaults — the values are not suppressed from you, they do not exist. Branch on this before reading the grade.
        - `detention_count` integer, nullable: NULL when no port-state-control record was observed for this hull. It is not 0 in that case: "no detentions" and "we hold no inspection history" are different facts and this field reported them identically until 2026-09-06.
        - `vessel_age` integer, nullable
        - `score_floor` ScreeningScoreFloor, nullable: Present only when the composite was FLOORED rather than computed, so `factors` cannot reproduce `score`. Null whenever the weighted factors decided the grade themselves. Deliberately separate from the voyage `ScoreFloor`: the two carry different bases and a shared enum would have published a contract our own responses violate.
          - `basis` 'confirmed_sanctions_match' | 'watchlist_exact_imo', required: `confirmed_sanctions_match` — a confirmed sanctions match (exact IMO or MMSI on a monitored list) floors the composite at 90/E regardless of the weighted signals. `watchlist_exact_imo` (since 2026-09-13) — the hull is listed BY IMO on a non-designating watchlist, the same listing that raises the AMBER; the composite is floored at 61, the bottom of grade D. A value may be added to this list when a new floor is introduced; read it defensively.
          - `value` number, required: The floored score actually published as `score`.
          - `weighted_score` number, required: The composite the five `factors` DO reproduce, before the floor was applied. Reconcile charts and thresholds against this number, not against `score`.
          - `explanation` string, required
        - `factors` object[], required: The five weighted inputs behind the grade, as numbers rather than prose, so a threshold or chart can be built on them. A factor we could not observe reports score=null and observed=false — for example a hull whose most recent port-state-control record predates our plausibility horizon has no verifiable detention history, so detention_history is excluded from the grade rather than scored as clean. Threshold on observed before thresholding on score.
          - `factor` 'detention_history' | 'flag_performance' | 'vessel_age' | 'inspection_gap' | 'ownership_opacity', required
          - `score` number, nullable, required: 0 = best, 100 = worst. null when observed=false.
          - `weight_pct` integer, required: Weight in the composite. 0 when the factor was excluded and its weight redistributed.
          - `observed` boolean, required: False when this factor is a model default or an excluded unknown, not a measurement.
      - `sources` SourceStatus[], required: Which lists were read and when. `status: "error"` means a list did not load — a source we could not read is NOT a source that returned nothing, and a GREEN built on unread lists is not a clear result. Cross-check against `sanctions.coverage_gaps`.
        - `source` string, required: Signal family, e.g. sanctions, navwarn, piracy. On a screening, `vessel_vetting` covers the port-state-control, registry and build-year inputs behind `vetting` and `ownership`: it reports `error` when that enrichment did not complete (a source failure — retry) and `ok` when it ran, whose `message` then distinguishes "inputs were read" from "the sources hold no record for this identifier" (retrying will not change that). Before 2026-09-06 this row did not exist, so a failed enrichment left every source reading `ok` with the grade simply absent.
        - `status` 'ok' | 'stale' | 'error' | 'not_requested', required: `ok` — read. `error` — we tried and could not read it; a retry may succeed. `not_requested` — you asked us not to read it (see `include_vetting`), so no retry will change it. These have opposite retry policies and must not be collapsed.
        - `checked_at` string, date-time, nullable, required: Null when basis is `unknown`, or when the source returned nothing to date. Never substituted with the request time.
        - `basis` 'refreshed_at' | 'latest_event_at' | 'published_at' | 'build_constant' | 'unknown', required: What checked_at means. `refreshed_at` — when we last fetched the source. `published_at` — the publication date the source itself states, which dates the DATA rather than our copy. `latest_event_at` — the newest record we hold, NOT a refresh time: a quiet week and a dead feed look identical. `build_constant` — bundled at deploy time. `unknown` — no timestamp exists in the fetch path, INCLUDING the `vessel_vetting` row when its status is `not_requested`: nothing was attempted, so there is nothing to date.
        - `message` string: Present when the basis needs explaining.
        - `label` string: Human-readable source name. Present on screening responses (e.g. "OFAC SDN").
      - `methodology_version` string, required: The methodology family this record was computed under (`vessel_screening_external_v1`). Constant since 2026-08-02; the engine BUILD is `engine_version`.
      - `engine_version` integer: The engine build this record was computed by — the integer the stability changelog cites when it says a rule changed ("engine 30"). Compare it across two records of the same hull to tell a model change from a vessel change. Absent on records stored before 2026-09-15.
      - `disclaimer` string, required
      - `screened_at` string, date-time, required
      - `expires_at` string, date-time, required
      - `unit_charged` boolean: Whether this screening consumed a unit of the monthly allowance. `false` on every INCOMPLETE: a unit buys a verdict, and an INCOMPLETE is not one, so the reservation is refunded and `X-ArcNautical-Quota-Remaining` on the response already reflects that. ABSENT on records stored before 2026-09-13, which were all charged regardless of status. Treat absent as `true`.
      - `notices` ScreeningNotice[]: What THIS request left on the table, with the remedy beside it. Empty on a clean first screening under quota. Every entry is conditional on something true of this request that the next request can change — the array is never padded with standing advice, so a non-empty array is worth reading. Present (possibly empty) on every screening created by this endpoint since 2026-09-10; `[]` on batch and monitor screenings; ABSENT on records stored before that date, which are replayed verbatim. Treat absent as empty.
        - `code` 'repeat_screening' | 'identity_unresolved' | 'identity_contested' | 'quota_warning' | 'quota_pace' | 'enrichment_latency' | 'auto_resolution' | 'imo_normalized', required: `imo_normalized` — the `imo` you sent was 8 digits, a valid IMO with one digit appended, and this screening is for the 7-digit IMO it contains; `imo_normalized_from` on the record carries what you sent. Only on accounts where that correction is enabled (see CreateScreeningRequest.imo). Fix the trailing digit in your source data. `quota_pace` — at the trailing seven days rate, this month allowance runs out before it resets. Carries the projected date, the measured per-day rate and the counts it came from. A PROJECTION, not a refusal: nothing is blocked, and it is emitted only while quota_warning is still silent, so the two never say the same thing twice. It stays quiet under three days of history, so a new account cannot project a cliff from one busy afternoon. `repeat_screening` — your account screened this hull inside the last 24 hours (the Idempotency-Key window) and this request carried a different key, so it ran again and spent a unit; `previous_screening_id` names the earlier record and `remedy` gives the derived key that would have replayed it free. On a screening that itself came back INCOMPLETE the notice names the earlier record as the last complete answer and gives NO key advice (a derived key would replay the non-answer); it is omitted altogether when the earlier record was INCOMPLETE too. `identity_unresolved` — no source we hold names this hull and the request carried no `vessel_name`, so nothing was screened (status INCOMPLETE, `identity_basis: none`, not charged). An IMMEDIATE identical retry cannot change that. Asking has placed the hull in the queue for our next scheduled port-State fetch, and `retry_after` (on this notice and on `sanctions`) is the instant by which that fetch will have run; when `retry_after` is absent, every scheduled fetch has already asked about this hull and found nothing, and only `vessel_name` will answer it. `identity_contested` — this IMO does not identify a single vessel in our registry: we hold four or more different vessel names recorded against it, which is the signature of a number that gets entered on forms rather than one belonging to a hull (1234567 is the common case, and it passes its own check digit). We decline to name it rather than pick one, because a screening run against the wrong name returns a CLEAN answer that means nothing. Fires INSTEAD of `identity_unresolved`, never alongside it; the remedy is to check where the number came from in your own data, with the `vessel_name` fallback for a caller who does hold a name. `quota_warning` — at least 80% of the current month's live screening allowance is used (the same threshold as the `usage.quota_warning` webhook event, placed where every caller reads); `quota` carries the numbers. On a one-time allowance it counts the total since it started and `quota.resets_at` is null. `enrichment_latency` — this screening took 6s or longer TO ANSWER, measured from when the request was received rather than from when computation began, with vetting/ownership enrichment included (the default); the sanctions verdict does not depend on enrichment, so a caller acting on `sanctions.status` alone can send `include_vetting: false` and stop waiting on external sources. `auto_resolution` — this INCOMPLETE enrolled for automatic re-screening (see the record's `resolution` block): the same request is run again for you on a bounded schedule with nothing to send, a verdict arrives as a new screening whose `supersedes` names this record (and as the `screening.resolved` webhook event), and `next_attempt_at` / `expires_at` / `max_attempts` are repeated here from the record. When it is present, `identity_unresolved` on the same response no longer asks you to re-screen — two notices never give opposite advice.
        - `message` string, required: What is true of this request, in the account's own numbers.
        - `remedy` string, required: The change to the NEXT request that makes this notice go away.
        - `docs` string, uri, required
        - `previous_screening_id` string, uuid: `repeat_screening` only.
        - `previous_screened_at` string, date-time: `repeat_screening` only.
        - `previous_status` string: `repeat_screening` only — the earlier record's `sanctions.status`.
        - `retry_after` string, date-time: `identity_unresolved` only, and only when a scheduled fetch — or a live register still answering in the background — can change the answer. The same instant as `sanctions.retry_after`, repeated so the notice is self-contained.
        - `next_attempt_at` string, date-time, nullable: `auto_resolution` only — the first scheduled re-screen, as on `resolution.next_attempt_at`.
        - `expires_at` string, date-time: `auto_resolution` only — when we stop trying and say so, as on `resolution.expires_at`.
        - `max_attempts` integer: `auto_resolution` only — the schedule's length.
        - `webhook_event` string: `auto_resolution` only — the event a resolution queues (`screening.resolved`).
        - `quota` object: `quota_warning` and `quota_pace`.
          - `limit` integer, required
          - `used` integer, required
          - `remaining` integer, required
          - `resets_at` string, date-time, nullable, required: When the allowance resets. null on a one-time allowance, which does not reset.
      - `resolution` ScreeningResolution
        - `status` 'pending' | 'resolved' | 'unresolved', required: `pending`: a re-screen is scheduled. `resolved`: a later screening answered this one — `resolved_screening_id` and the record's `superseded_by` name it. `unresolved`: every scheduled attempt still came back INCOMPLETE; the `screening.unresolved` webhook event carried the remedy, and only a request from you (`vessel_name`) can answer now.
        - `cause` 'identity' | 'coverage', required: What the follow-up waits on. `identity`: no source we hold or read live names the hull; a store gaining a row is what answers it, on a daily-or-slower clock, so the schedule spans up to 30 days. `coverage`: a sanctions list did not load; outages end in minutes to hours, so the schedule is denser and gives up after 7 days.
        - `attempts` integer, required: Re-screens made so far.
        - `max_attempts` integer, required: The schedule's length; past it the status is `unresolved`.
        - `next_attempt_at` string, date-time, nullable, required: The next scheduled re-screen. Null once resolved or unresolved. The first slot is `sanctions.retry_after` when the record carries one.
        - `expires_at` string, date-time, required: The last slot of the schedule — after it, we stop trying and say so.
        - `resolved_screening_id` string, uuid, nullable, required: The screening that answered, when `status` is `resolved`.
        - `webhook_event` 'screening.resolved', required: The event a resolution queues, so a reader knows what to subscribe to without leaving the record.
      - `supersedes` string, uuid: The INCOMPLETE record this verdict answers. Set when a hull with an open follow-up is screened to a verdict by ANY path — the background re-screen, your own re-ask, a batch item or a monitor run — so the link from question to answer is one rule. Absent otherwise.
      - `superseded_by` string, uuid, nullable: The later record that answered this one. Present (possibly null) on every GET and list row since 2026-09-15 and absent from an idempotent replay — it is a column merged in at read time, because the stored body is evidence and is never rewritten. Null until a verdict for the same hull is written.
    - `error_body` ApiErrorEnvelope
      - `code` 'missing_idempotency_key' | 'invalid_idempotency_key' | 'invalid_json' | 'unauthorized' | 'invalid_api_key' | 'quota_exhausted' | 'insufficient_scope' | 'entitlement_inactive' | 'ip_not_allowed' | 'monitoring_unavailable_in_test' | 'simulation_unavailable_in_live' | 'not_found' | 'unsupported_api_version' | 'method_not_allowed' | 'unsupported_media_type' | 'idempotency_conflict' | 'idempotency_in_progress' | 'export_not_ready' | 'delivery_in_flight' | 'endpoint_disabled' | 'endpoint_deleted' | 'monitor_limit_exceeded' | 'vessel_monitor_limit_exceeded' | 'vessel_monitor_exists' | 'export_unavailable' | 'validation_failed' | 'route_not_found' | 'export_too_large' | 'webhook_url_blocked' | 'webhook_url_unresolvable' | 'rate_limit_exceeded' | 'concurrency_limit' | 'service_concurrency_limit' | 'export_concurrency_limit' | 'internal_error' | 'assessment_failed' | 'screening_failed' | 'export_failed' | 'export_row_count_grew' | 'api_paused' | 'assessment_timeout' | 'screening_timeout', required: Stable machine code. Branch on this, never on the message text, which we reword. Generated from the list the server throws from rather than restated here, because a hand-kept copy of a server-side vocabulary has drifted on us before. The HTTP status does NOT tell you whether to retry: 409 idempotency_in_progress resolves by waiting while 409 idempotency_conflict never resolves, and 500 export_row_count_grew fails identically on every retry. Retryability per code: - `missing_idempotency_key` (400) — not retryable. A POST arrived with no Idempotency-Key header. - `invalid_idempotency_key` (400) — not retryable. The Idempotency-Key was present but unusable (empty or over-long). - `invalid_json` (400) — not retryable. The body did not parse as JSON. - `unauthorized` (401) — not retryable. No credential was supplied. - `invalid_api_key` (401) — not retryable. The key is unknown, revoked, expired, or its prefix does not match the environment. - `quota_exhausted` (402) — not retryable. The allowance for this resource is spent: the month's (GET /api/v1/usage carries the reset date), or a one-time total (live screenings on an account opened from 2026-10-01, or a pilot), which does not reset. - `insufficient_scope` (403) — not retryable. The key does not hold the scope this endpoint requires. - `entitlement_inactive` (403) — not retryable. The account has no active entitlement. - `ip_not_allowed` (403) — not retryable. The key is IP-restricted and this source address is not on its list. - `monitoring_unavailable_in_test` (403) — not retryable. Vessel and corridor monitors are not available on a test key. - `simulation_unavailable_in_live` (403) — not retryable. Simulated monitor events are available only on a test key. A synthetic risk change on a live monitor would enter your compliance record as a real finding about a real vessel. - `not_found` (404) — not retryable. No such resource, or it is not yours. A malformed id answers this way too, deliberately. - `unsupported_api_version` (404) — not retryable. The path names an /api/vN this service does not publish. Answered by the edge, so it is the one code that carries no rate-limit headers. NEVER retry: our own guidance says an HTML body means a network layer answered and should be retried, and this exists so that advice is not applied to a permanent 404. - `method_not_allowed` (405) — not retryable. The path exists but not with this verb. The Allow header names the verbs that do work. - `unsupported_media_type` (415) — not retryable. Content-Type declared something other than application/json. Omitting the header is accepted; declaring the wrong one is not. - `idempotency_conflict` (409) — not retryable. This Idempotency-Key was already used with a different body. Never resolves — use a new key. - `idempotency_in_progress` (409) — RETRYABLE. The original request with this key is still running, and this retry waited for it (up to 30s) without it finishing. Retry with the same key: the next retry waits again and returns the original result the moment it lands, flagged X-ArcNautical-Idempotent-Joined: true. Carries Retry-After. - `export_not_ready` (409) — RETRYABLE. The export job has not finished. Poll it; carries Retry-After. - `delivery_in_flight` (409) — RETRYABLE. That webhook delivery is being attempted right now. - `endpoint_disabled` (409) — not retryable. The webhook endpoint is disabled. Re-enable it first. - `endpoint_deleted` (409) — not retryable. The webhook endpoint no longer exists. - `monitor_limit_exceeded` (409) — not retryable. Corridor monitor ceiling reached. Delete one first. - `vessel_monitor_limit_exceeded` (409) — not retryable. Vessel monitor ceiling reached. Delete one first. - `vessel_monitor_exists` (409) — not retryable. A monitor already watches this hull. - `export_unavailable` (410) — not retryable. The export file has aged out. The records behind it are unaffected — create a new one. - `validation_failed` (422) — not retryable. One or more fields are invalid; field_violations names each one. - `route_not_found` (422) — not retryable. Both ports are known but no sea route connects them. - `export_too_large` (422) — not retryable. The requested range exceeds the per-export row cap. Narrow the filters and export in parts. - `webhook_url_blocked` (422) — not retryable. The URL resolves to a private, loopback or link-local address. - `webhook_url_unresolvable` (422) — not retryable. The URL host does not resolve. - `rate_limit_exceeded` (429) — RETRYABLE. Over the per-key request rate. Shed RATE; back off to X-RateLimit-Reset. - `concurrency_limit` (429) — RETRYABLE. Over your own in-flight ceiling. Shed CONCURRENCY, or use the batch endpoint. Retry-After carries a live estimate, not a fixed number. - `service_concurrency_limit` (429) — RETRYABLE. The shared pool is full and your account is within its allowance. A graded screen QUEUES for that pool first and is served round-robin between accounts, so this means the queue itself ran out of time — you were not stuck behind one customer's batch. Reducing your fan-out will NOT help; retry unchanged after Retry-After. include_vetting: false skips this queue entirely, and /api/v1/screening-batches is not subject to it at all. - `export_concurrency_limit` (429) — RETRYABLE. Too many exports building at once. Wait for one to finish. - `internal_error` (500) — RETRYABLE. An unhandled failure. Retry with backoff; quote request_id if it persists. - `assessment_failed` (500) — RETRYABLE. Voyage scoring failed to produce a result. - `screening_failed` (500) — RETRYABLE. The screening engine failed to produce a verdict. No unit is charged. - `export_failed` (500) — RETRYABLE. The export job failed while building. - `export_row_count_grew` (500) — not retryable. The matching rows grew past the cap while the file was being written, so it was abandoned rather than shipped short. Narrow the filters — an identical retry fails identically. - `api_paused` (503) — RETRYABLE. The API is paused for a short while (an operator lever against abuse, rarely pulled). Nothing ran and nothing was charged. Retry the same request after Retry-After. - `assessment_timeout` (504) — RETRYABLE. Scoring exceeded its deadline. Retry; a warm route usually succeeds. - `screening_timeout` (504) — RETRYABLE. The screening exceeded its time budget (screeningComputeBudgetMs) and was abandoned before it could be stored. No unit is charged, and the Idempotency-Key is RELEASED — so retry with the SAME key; it is a clean attempt, not a replay. Deliberately raised below the socket timeout at the edge, so this arrives as JSON with a request_id rather than as a gateway 504 we never see.
      - `message` string, required
      - `request_id` string, required
      - `field_violations` FieldViolation[]
        - `field` string, required
        - `message` string, required
    - `attempts` integer
    - `updated_at` string, date-time
  - `next_cursor` integer, nullable, required

## Other responses

- `400`: Validation error. `code` is one of: - `missing_idempotency_key` — A POST arrived with no Idempotency-Key header. - `invalid_idempotency_key` — The Idempotency-Key was present but unusable (empty or over-long). - `invalid_json` — The body did not parse as JSON.
- `401`: Missing or invalid API key. `code` is one of: - `unauthorized` — No credential was supplied. - `invalid_api_key` — The key is unknown, revoked, expired, or its prefix does not match the environment.
- `402`: The allowance for this resource is used up: the month's, or a one-time total (live screenings on an account opened from 2026-10-01, or a pilot), which does not reset (then there is no reset instant and no Retry-After). The refused request is NOT metered. message states used/limit and the reset instant when there is one; a replay under an Idempotency-Key already answered is still free and is not metered, reads are never metered, and a test-environment key has its own allowance. `code` is one of: - `quota_exhausted` — The allowance for this resource is spent: the month's (GET /api/v1/usage carries the reset date), or a one-time total (live screenings on an account opened from 2026-10-01, or a pilot), which does not reset.
- `403`: The key is not entitled to this environment or scope. `code` is one of: - `insufficient_scope` — The key does not hold the scope this endpoint requires. - `entitlement_inactive` — The account has no active entitlement. - `ip_not_allowed` — The key is IP-restricted and this source address is not on its list. - `monitoring_unavailable_in_test` — Vessel and corridor monitors are not available on a test key. - `simulation_unavailable_in_live` — Simulated monitor events are available only on a test key. A synthetic risk change on a live monitor would enter your compliance record as a real finding about a real vessel.
- `404`: Resource not found. `code` is one of: - `not_found` — No such resource, or it is not yours. A malformed id answers this way too, deliberately. - `unsupported_api_version` — The path names an /api/vN this service does not publish. Answered by the edge, so it is the one code that carries no rate-limit headers. NEVER retry: our own guidance says an HTML body means a network layer answered and should be retried, and this exists so that advice is not applied to a permanent 404.
- `405`: The path exists but not with this method. The Allow header names the methods that do. `code` is one of: - `method_not_allowed` — The path exists but not with this verb. The Allow header names the verbs that do work.
- `409`: Idempotency conflict. `code` is one of: - `idempotency_conflict` — This Idempotency-Key was already used with a different body. Never resolves — use a new key. - `idempotency_in_progress` (retryable) — The original request with this key is still running, and this retry waited for it (up to 30s) without it finishing. Retry with the same key: the next retry waits again and returns the original result the moment it lands, flagged X-ArcNautical-Idempotent-Joined: true. Carries Retry-After. - `export_not_ready` (retryable) — The export job has not finished. Poll it; carries Retry-After. - `delivery_in_flight` (retryable) — That webhook delivery is being attempted right now. - `endpoint_disabled` — The webhook endpoint is disabled. Re-enable it first. - `endpoint_deleted` — The webhook endpoint no longer exists. - `monitor_limit_exceeded` — Corridor monitor ceiling reached. Delete one first. - `vessel_monitor_limit_exceeded` — Vessel monitor ceiling reached. Delete one first. - `vessel_monitor_exists` — A monitor already watches this hull.
- `415`: Content-Type describes a body that is not JSON (form-encoded, multipart, XML). application/json, text/plain and an absent header are all accepted. `code` is one of: - `unsupported_media_type` — Content-Type declared something other than application/json. Omitting the header is accepted; declaring the wrong one is not.
- `422`: Request or route validation failed. Also returned for a body field this endpoint does not read — field_violations names it and lists the accepted keys. `code` is one of: - `validation_failed` — One or more fields are invalid; field_violations names each one. - `route_not_found` — Both ports are known but no sea route connects them. - `export_too_large` — The requested range exceeds the per-export row cap. Narrow the filters and export in parts. - `webhook_url_blocked` — The URL resolves to a private, loopback or link-local address. - `webhook_url_unresolvable` — The URL host does not resolve.
- `429`: Rate limit exceeded. `code` is one of: - `rate_limit_exceeded` (retryable) — Over the per-key request rate. Shed RATE; back off to X-RateLimit-Reset. - `concurrency_limit` (retryable) — Over your own in-flight ceiling. Shed CONCURRENCY, or use the batch endpoint. Retry-After carries a live estimate, not a fixed number. - `service_concurrency_limit` (retryable) — The shared pool is full and your account is within its allowance. A graded screen QUEUES for that pool first and is served round-robin between accounts, so this means the queue itself ran out of time — you were not stuck behind one customer's batch. Reducing your fan-out will NOT help; retry unchanged after Retry-After. include_vetting: false skips this queue entirely, and /api/v1/screening-batches is not subject to it at all. - `export_concurrency_limit` (retryable) — Too many exports building at once. Wait for one to finish.
- `500`: Internal or upstream error. `code` is one of: - `internal_error` (retryable) — An unhandled failure. Retry with backoff; quote request_id if it persists. - `assessment_failed` (retryable) — Voyage scoring failed to produce a result. - `screening_failed` (retryable) — The screening engine failed to produce a verdict. No unit is charged. - `export_failed` (retryable) — The export job failed while building. - `export_row_count_grew` — The matching rows grew past the cap while the file was being written, so it was abandoned rather than shipped short. Narrow the filters — an identical retry fails identically.
- `503`: Service temporarily unavailable. `code` is one of: - `api_paused` (retryable) — The API is paused for a short while (an operator lever against abuse, rarely pulled). Nothing ran and nothing was charged. Retry the same request after Retry-After.
- `504`: The request exceeded its server-side time budget and was abandoned. Raised by us, below the socket timeout at the edge, so it arrives as JSON with a request_id. `code` is one of: - `assessment_timeout` (retryable) — Scoring exceeded its deadline. Retry; a warm route usually succeeds. - `screening_timeout` (retryable) — The screening exceeded its time budget (screeningComputeBudgetMs) and was abandoned before it could be stored. No unit is charged, and the Idempotency-Key is RELEASED — so retry with the SAME key; it is a clean attempt, not a replay. Deliberately raised below the socket timeout at the edge, so this arrives as JSON with a request_id rather than as a gateway 504 we never see.

---

[API](https://skmtc.dev/arcnautical/apis/arcnautical-api.md) · [All operations](https://skmtc.dev/arcnautical/apis/arcnautical-api/llms.txt) · [OpenAPI document](https://skmtc.dev/arcnautical/apis/arcnautical-api/revisions/40cd2659cdd7?raw)
