---
title: "Screen one hull"
method: POST
path: "/api/v1/screenings"
tags: ["Vessel screening"]
---

# Screen one hull

`POST /api/v1/screenings`

## Request

- Base URL: `https://arcnautical.com`
- URL: `https://arcnautical.com/api/v1/screenings`
- Auth: one of:
  - HTTP bearer
  - API key in header `X-ArcNautical-Key`

Synchronously screens one vessel against OFAC SDN, OpenSanctions, EU FSD, UN Consolidated and UK OFSI. The HULL IDENTIFIER (IMO/MMSI) is matched against every record that carries one — OFAC SDN, OpenSanctions, EU FSD, UK OFSI, and the UN Consolidated List's vessel designations (the Libya committee under resolution 2146). The rest of the UN list is individuals and entities and contributes at the name and ownership-chain layer. A name-only hit against a company or individual record is listed under `matches[]` but does not by itself drive an AMBER (since 2026-09-15, engine_version 31). Returns the sanctions verdict with its match evidence — each match citing the source's own record id, its page at the source where one exists, the listing date the source states, and the edition of the list it was found in — the ownership-opacity band, the A-E vetting grade with its five weighted factors, and per-source freshness. Typically 1-4 seconds, and answers within about seven: the sanctions lists are read locally, and the vetting and ownership enrichment (the only part that reaches outside) is given the remainder of that window; when it cannot finish, the verdict still lands on time and sources[].vessel_vetting reads error with a re-screen instruction. Use POST /api/v1/screening-batches for a fleet. An INCOMPLETE result (the hull could not be named, so no list was read against a name) is stored and replays under the same Idempotency-Key like any other, but it is NOT charged: `unit_charged` is false and the quota headers already reflect the refund. Submit once: an INCOMPLETE that carries a `resolution` block is re-screened for you on a bounded schedule, the verdict arrives as a new record whose `supersedes` names this one (and as the `screening.resolved` webhook event), and this record reads `superseded_by` on GET. That verdict costs the one unit the INCOMPLETE did not.

Required scope: vessel:screen.

## Headers

- `Idempotency-Key` string, required

## Request body

- CreateScreeningRequest
  - `imo` string, required: IMO number. Accepts 9811000, "IMO9811000" or "IMO 9811000". An IMO whose CHECK DIGIT fails is still screened, and the response reports `imo_check_digit_valid: false` — dark-fleet hulls routinely carry fabricated IMOs that are nonetheless the identifier they are listed under, so refusing them would decline to screen the highest-risk inputs. A value with no 7-digit core is rejected with 422, and the 422 names the IMO it most likely contains. ONE EXCEPTION, enabled PER ACCOUNT (since 2026-09-25) only where the account's own traffic shows a pipeline appending one digit to valid IMOs: an 8-digit value whose first seven digits pass the check digit and whose last seven do not is screened as the first seven. 94827701 is screened as 9482770, and the response carries `imo_normalized_from: "94827701"` and an `imo_normalized` notice. On every other account that value gets the 422. A leading extra digit, an 8-digit value whose two readings are both valid IMOs, and 9 digits or more get the 422 on every account.
  - `customer_reference` string: Your own identifier, echoed back on the result.
  - `vessel_name` string: OPTIONAL. A vessel name you can confirm for this hull, used ONLY when our own identity sources hold none. When a hull is absent from them the screen answers `sanctions.status: INCOMPLETE` because name-based matching cannot run — supplying the name from the certificate or Q88 lets it run, and the response reports `identity_basis: "user"` so the result is never mistaken for one we verified ourselves. IGNORED when we DID resolve a name: you cannot steer a hull onto another vessel's name. Rejected with 422 if it could not plausibly be a ship name (fewer than three letters or digits, or our own "IMO 9811000" placeholder).
  - `include_vetting` boolean: Run the vetting/ownership enrichment. Default true. Set FALSE to get the sanctions verdict alone, which is faster, immune to enrichment outages, and does not contend for the shared upstream that produces the A–E grade — enrichment is the only part of a screen with that dependency, and it is the part that degrades under concurrency. Use it when you compute your own vessel risk score and only need the designation answer. The response then carries `vetting.status: "not_requested"` and a `vessel_vetting` source row with status `not_requested`, so a skipped enrichment is never mistaken for a failed one. Sanctions coverage is identical either way.

## Response `201`

Screening completed.

- 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.

## 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)
