ArcNautical Voyage Risk API

API v1 stability

What we promise about this contract

If you are deciding whether to build on this API, this is the page that answers it. What can change without warning, what gets 90 days, and how you find out.

v1 stable Contract version: 2026-09-15 Breaking-change notice: 90 days

The promise

  • /api/v1 is stable. Breaking changes ship as a new major path — /api/v2 — never in place. A URL that works today keeps working.
  • Breaking changes get 90 days notice, published here and carried on the affected responses as Deprecation and Sunset headers. The deprecated thing keeps working for the entire window.
  • Additive changes can arrive at any time. Your client must ignore fields it does not recognise.

Two version strings do different jobs, and it is worth keeping them apart. X-ArcNautical-API-Version dates the wire contract — the shape of requests and responses. methodology_version, returned on every assessment, pins the scoring and is what makes a score reproducible. They move independently: we can improve scoring without touching the contract, and the reverse.

Additive changes — any time, no notice

These are not breaking, and we ship them without a notice period:

  • New fields on existing responses.
  • New optional request fields.
  • New endpoints.
  • New values in fields already documented as open-ended.
Read new fields defensively. An assessment is stored exactly as it was returned, and GET /api/v1/voyage-assessments/{id} replays that stored copy byte-for-byte — that is what makes it an audit record. So a field added last week will be absent from an assessment created last month. Treat every field as optional on retrieval, even ones that are always present on fresh calls.

Breaking changes — 90 days

We treat all of these as breaking:

  • Removing or renaming a response field.
  • Narrowing a type, or changing what an existing value means.
  • Removing an endpoint, or removing a value from a closed enum.
  • Tightening validation so a request we used to accept now fails.
  • Making an optional request field required.

None of these ship without the full 90-day window first. If we ever need to move faster than that, it will be for a security or legal reason, we will say so explicitly in the changelog, and it will not be quiet.

How you are told

  1. A dated entry in the changelog below, naming what is deprecated and the date it stops working. This always happens.
  2. deprecated: true on the affected field or operation in the OpenAPI document, which most client generators surface as a compile-time or lint warning. This is the signal for a field.
  3. Deprecation and Sunset response headers when an endpoint is being retired, per RFC 8594.
Those last two are deliberately not interchangeable. Sunset is defined as a statement about a resource, so putting it on a response whose endpoint is staying would tell you the whole endpoint is going away. When a single field is deprecated the OpenAPI marker is the accurate signal, and the changelog says so in words.

The changelog is the record of what actually shipped. It is not a roadmap and we do not announce work here before it exists.

Currently deprecated

WhatReplaced byStops working
source_status
on the assessment response
sources — same status, plus checked_at and basis 2026-10-31
(removal ships as v2)

Both fields are populated today and will stay that way for the whole window, so you can migrate whenever suits you. sources reports the same per-source status source_status does, and adds when each source was last known-good.

What we do not promise

Being specific about the gaps is more useful to you than a vague assurance.

  • No uptime commitment on self-serve keys. Free and self-serve use is best-efforts. On a paid agreement there is a written Service Level Agreement — 99.5% monthly, service credits, and a termination right if we miss it twice in six months.
  • Status page: status.arcnautical.com. Hosted off our own infrastructure, probing every minute, ninety days of history, with the check count printed beside every percentage and percentages rounded down. It is the measurement of record for the SLA. Incidents are opened and closed by the prober itself and can be pushed to a webhook, Slack or Teams, or read as RSS. Your own request logs remain sufficient evidence for a service-credit claim — we will not dispute a well-evidenced one on the grounds that our monitoring missed it.
  • Scores change as the world does. Reproducibility is scoped to a stored assessment, which you can always re-read by id. The same route scored again tomorrow can legitimately return a different number — that is the product working, not a contract break. methodology_version tells you whether the model changed underneath it.
  • Upstream sources can degrade. When one does, the assessment says so rather than hiding it — check source_status and missing_sources on every response.

API changelog

Newest first. Dates are the day the change reached production.

2026-09-16

A hull whose registered owner is on a sanctions list now reads HIGH ownership opacity on every surface, not only in the dossier — engine_version 32Fixed

Read this if you act on ownership.opacity, store it, or hold a vessel monitor — vessel_risk.changed fires when opacity moves.

What changed. Ownership opacity carries a +30 “chain-sanctions floor” when an entity in the hull’s ownership chain is itself designated or is a high-confidence name match (≥0.97, authoritative list) to a designated entity. The floor was computed from the same evidence in the dossier and in the screening record, but the screening record looked for chain entities only in GLEIF. An owner with no LEI — most of the sanctioned fishing and shadow fleets — was never screened by name, and the hull’s own OFAC record naming its designated owner (“Linked To”) was not read. On 2026-09-15 a screening of LONG XING 629 (IMO 8687268, OFAC SDN #33789, GLOMAG, owner Dalian Ocean Fishing, SDN #33768) returned opacity: "MEDIUM", score: 40 three minutes before the same hull’s dossier said 90. The screening path now screens the registered owner by name when GLEIF resolves nothing, and reads the linked designated entity off the confirmed OFAC match, so the floor fires wherever the dossier’s would.

What to expect. ownership.score rises by 30 (and the band typically MEDIUMHIGH) on hulls whose registered owner is a designated or high-confidence-listed entity, or whose own OFAC record names one. The vetting grade can move with it — ownership is 20% of the composite — but on a hull with a confirmed vessel-level designation the grade was already floored at E. Sanctions verdicts do not change. A stored screening is evidence and is never rewritten; re-screen under a new Idempotency-Key to obtain the current figure. A vessel monitor on an affected hull fires vessel_risk.changed once at its next run.

Compatibility. No field added or removed. The dossier’s opacity can still exceed the screening record’s by up to 20 points on a hull whose only owner evidence is a registry particulars string with no LEI: the dossier floors that shallower chain at 60, the screening record does not. That difference is documented, not a defect; the sanctions floor no longer is.

2026-09-15

EU coverage now reads the Commission’s own file: eu_sanctions financial-list matches cite the EU reference number and the Official Journal act, and programs on EU matches no longer reads ["sanction"]Changed

Read this if your integration inspects programs on an eu_sanctions match, or files EU matches as evidence.

What changed. The eu_sanctions source is now two files. The Commission’s consolidated financial sanctions file (the asset-freeze instrument) is read directly from the EU — the endpoint had been treated as closed since March; it requires a public query parameter the EU publishes on every download link and we had never sent. Records from it carry the EU reference number as source_record_id (EU.4198.83), the EU’s own designation date as listed_on, and the Official Journal act that lists them as source_url. Annex XLII of Regulation 833/2014 — the vessel port-access ban, which that file does not carry — stays on this source through the OpenSanctions mirror of the Official Journal, exactly as before, with the OpenSanctions entity id and page. Each match’s list_edition names the file it came from. One value changed: programs on an eu_sanctions match used to read ["sanction"] — a topic word, not a programme — and now reads the EU programme code on a financial-list record (["PRK"], ["UKR"]) or the Official Journal dataset on a mirror record (["eu_journal_sanctions", "eu_sanctions_map"]). An integration that tested for the literal "sanction" should test for source == "eu_sanctions" instead, which is what it meant.

What to expect. No verdict changes: every hull that confirmed on an EU record still does, on the same identifier. A screening’s sources[] row for eu_sanctions explains which files loaded; if the Commission’s file does not answer, the source falls back to the mirror alone and says so. Records already stored replay as they were.

Compatibility. No field added or removed. The programs value change on eu_sanctions matches is the one behavioural difference and is listed here for that reason; programs has always been documented as free-text programme names.

2026-09-15

Every sanctions match now cites its source record: id, page, listing date and list editionAdded

Read this if you file screening records as compliance evidence, or if a reviewer on your side verifies matches at the source.

What changed. Every row of sanctions.matches[] carries four new fields. source_record_id is the identifier the source itself assigns — the OFAC SDN entry number, the UN permanent reference number (LYe.006), the OFSI Unique ID (DPR0075), the OpenSanctions entity id (which also carries the EU listing when source is eu_sanctions, because EU coverage is read from the OpenSanctions mirror). source_url is the record’s own page at the source where one exists — OFAC’s sanctions search, the OpenSanctions entity page, the FCDO UK Sanctions List search — and is absent for the UN, which publishes no per-entry page. listed_on is the date the record went on the list as the source states it (OFAC’s entry-creation date, the UN’s LISTED_ON, OFSI’s Date Designated, the earliest date any authority’s designation states on an OpenSanctions entity), and is null when the edition we consulted states none — never a stand-in for “not listed”. list_edition names the file the match was found in: published, what the source stamps on the edition, and retrieved_at, when we took the copy screened. Three of four buyers in our September evaluation asked for exactly this: a record that can cite the law it enforces.

What to expect. No verdict changes. Records stored before today replay without the fields; a stored screening is evidence and is never rewritten. A UN match carries no source_url; treat its absence as the source having no page, not as a missing value.

Compatibility. Additive. source_record_id and source_url are optional strings; listed_on is an optional nullable date string; list_edition is an optional object with two nullable date-time strings. Nothing was renamed or removed.

2026-09-15

A hull whose name equals a listed company no longer reads AMBER, and the UN list’s vessel designations now match by IMO — engine_version 31Fixed

Read this if you act on AMBER, or if you stored an AMBER whose only driving match has record_type: "entity".

What changed. Two verdicts on one account the same morning were name collisions: a hull screened under a former name that equals a Cuban retailer on the US Cuba Restricted List, and a hull named AVAX colliding with a UN-designated tanker also named AVAX but carrying a different IMO. Both read AMBER “IMO not confirmed in source; manual verification required”. From today (1) a name-only hit whose source record is a company or individual is listed under matches[] with drove_verdict: false and does not by itself drive AMBER, at any score — the rule that already applied to near-miss company names now applies to exact equality too; a name-only hit against a vessel record is unchanged; and (2) UN Consolidated records that the UN’s own text describes as vessels (the Libya committee’s designations under resolution 2146) keep their IMO, so they are matched by hull identifier and, when the IMO differs from yours, excluded as a collision with that reason in sanctions.detail. The DPRK shipping companies on the UN list, whose “IMO number” is a company number, are unchanged.

What to expect. A stored AMBER whose only driving match is an entity record would read GREEN today, with the same hit still listed and sanctions.detail saying why it was set aside. The designated tanker AVAX (IMO 9058713) reads RED as before, with un_sanctions now among its confirming sources. Re-screen under a new Idempotency-Key to obtain the current verdict; a replay returns the stored one.

Compatibility. No field added or removed. Verdicts change on hulls matching the description above; the /developers page and the OpenAPI description of POST /api/v1/screenings no longer say the UN list “carries no vessel records”, which was true of the list we screened until 2026-07-22 and not since.

2026-09-15

Submit once: an INCOMPLETE screening now resolves itself, and the verdict arrives as a new record linked to the original — engine_version 30Added

Read this if you handle INCOMPLETE, if you filter webhook event_types, or if you hold INCOMPLETE records from the last 30 days.

What changed. Until today an INCOMPLETE handed you sanctions.retry_after and left the re-asking to you. Now an INCOMPLETE we can do something about enrols itself: the record carries a resolution block (status, cause, attempts/max_attempts, next_attempt_at, expires_at, resolved_screening_id, webhook_event) and the same request is re-screened for you on a bounded schedule — up to six attempts over 30 days for an identity gap, over 7 days for a list outage. When a verdict can be given it is written as a new screening whose supersedes names the original; the original is never rewritten, but GET /api/v1/screenings/{id} and every list row now carry superseded_by, and the GET overlays the live resolution. Two webhook events: screening.resolved (the answer, with supersedes_screening_id) and screening.unresolved (the schedule ran out; the remedy is in the payload). A seventh notice code, auto_resolution, states the schedule on the INCOMPLETE response, and identity_unresolved on the same response no longer tells you to re-screen. GET /api/v1/screenings gains status and superseded filters — status=INCOMPLETE&superseded=false is the list of questions still open.

What it costs. Nothing while it stands INCOMPLETE; one screening unit when the verdict is delivered, which is the unit the INCOMPLETE did not consume. A record from before 2026-09-13, when INCOMPLETEs were still charged, resolves free. An exhausted allowance defers the attempt to the reset instead of spending it.

What to expect. Every INCOMPLETE your account held from the last 30 days that a verdict had not already superseded was enrolled at deploy, so records you stored weeks ago will gain superseded_by and their answers will appear as new screenings — and as screening.resolved events, if an endpoint subscribes to it or to everything. A verdict you obtain yourself (with vessel_name, a batch, a monitor) closes the follow-up the same way. No verdict changes.

Correction, and a new field. The two entries below dated 2026-09-13 say methodology_version “moves to 27” and “to 28”. That number was never on a field you could read: methodology_version on a screening has been the constant vessel_screening_external_v1 since the endpoint shipped, and names the methodology family, not the build. The screening record now carries engine_version (integer, 30 today), which is the number this changelog cites from here on; read the earlier entries’ 27 and 28 as engine versions. Absent on records stored before today.

Compatibility. Additive. resolution is optional and appears only on an enrolled INCOMPLETE; supersedes is an optional string; superseded_by is a nullable string on GET and list rows and absent from idempotent replays. engine_version is an optional integer. auto_resolution is a new value of the ScreeningNotice.code enum and the two events are new values of WebhookEventType; a generated client that rejects unknown enum values should regenerate from the current document. An endpoint that lists event_types receives neither new event until it adds them. Nothing was renamed or removed.

2026-09-13

Four public registers now name the hulls our stores cannot, and a renamed hull is screened under every name — methodology_version 28Added

Read this if you have hulls that stand INCOMPLETE with identity_basis: "none", or if a vessel monitor will report a status change today.

What changed, one. When none of the five identity stores we hold names a hull, the screening now reads four public registers live inside the request — DNV’s vessel register, the ITF ship lookup, the IACS vessels-in-class file and USCG PSIX — in parallel and under a time budget of a few seconds. identity_sources[] gains four rows (dnv_vessel_register, itf_ship_lookup, iacs_vessels_in_class, uscg_psix), recorded only when they were actually consulted. Measured on the account that prompted this: of thirteen hulls that had stood INCOMPLETE for weeks with nothing scheduled left to ask, eleven now screen. A register that does not answer inside the budget reads reachable: false; its lookup finishes in the background and sanctions.retry_after is the instant by which a re-ask will find it.

What changed, two. Registers disagree exactly when a hull has been renamed — a class register carries the current name while an older file still carries the yard name — so every identity_sources[] row that named the hull now carries name, the hull is screened under every name returned (including a port-State boarding name that lost to a newer record), vessel_name is the one we chose, and a match on one of the others carries matches[].screened_name so you can see which name it matched. A name-based AMBER can therefore appear on a hull that read GREEN under a single name; that is recall, not noise, and the match row says why.

What to expect. A previously blind hull under a vessel monitor will emit one vessel_risk.changed when it first screens; methodology_version moves to 28. Blind screens take up to a few seconds longer than before, and only those — a hull a store names never waits on the live registers. Records already stored are replayed verbatim.

Compatibility. Additive. identity_sources[].name and matches[].screened_name are optional strings; the four register names are new values of a free-text field, not an enum. Nothing was renamed or removed.

2026-09-13

A watchlist listing by IMO now floors the vetting grade at D, and an unrated flag no longer scores at full weight — methodology_version 27Fixed

Read this if you act on vetting.grade, or if you monitor hulls and will see a vessel_risk.changed event today.

What changed, one. A hull listed by IMO on a watchlist that does not designate — Ukraine’s GUR war register is the common case, a possibly-delisted aggregator tag the other — raises an AMBER on the sanctions side. On the vetting side it printed whatever the five weighted factors said, grade B included, because a listing is not one of the five. It is a fact about this hull, so the composite is now floored at 61, the bottom of grade D. The response discloses it: vetting.score_floor.basis is watchlist_exact_imo, with the pre-floor weighted_score the factors still reproduce. A confirmed designation keeps its higher floor at 90. A name-only hit on a watchlist floors nothing — that is a collision until proven otherwise.

What changed, two. A flag that no port-State regime rates was scored at the unknown-flag default of 30 with its full 25% weight, and published as observed: true. The same rule that already applied to an unknown build year and a missing inspection history now applies to the flag: the weight is redistributed across the factors we did observe, and vetting.factors[].flag_performance reads observed: false, weight_pct: 0, score: null. A grade you have to defend must reproduce from the numbers beside it.

What changed, three. The EU’s vessel designations (Annex XLII of Regulation 833/2014) reach us as two aggregator datasets that were not in our enumerated confirming set, so their confirmation rested on an upstream topic tag — and a hull on both the EU list and the GUR register hit the war-list veto first and read AMBER. Both datasets now confirm in their own right; such a hull reads RED.

What to expect. Grades move only for hulls that are (a) listed by IMO on a non-designating watchlist, (b) flagged with a state absent from both MoU performance lists, or (c) EU-designated and also GUR-listed. A vessel monitor on any of those will emit one vessel_risk.changed with the new grade; it is the model changing, not the vessel, and methodology_version moves to 27 so you can tell. Records already stored are replayed verbatim.

Compatibility. Additive with one caveat: watchlist_exact_imo is a new value in the score_floor.basis enum, and vetting.status is now published as an enum of the six values it has always carried (acceptable, marginal, substandard, unacceptable, not_assessed, not_requested). A generated client that rejects unknown enum values should regenerate from the current document. Nothing was renamed or removed.

2026-09-13

Contract corrections: drove_verdict reconciles on caller-named screens, coverage_complete means list coverage, and the headers are all declaredFixed

Read this if you generate a client from the OpenAPI document, or branch on drove_verdict or coverage_complete.

  • drove_verdict on a screen you named. When you supply vessel_name (identity_basis: "user") a name hit whose record carries a different IMO cannot be ruled a collision, and sanctions.detail counted it as driving the AMBER — while drove_verdict on that same row read false. The two now agree, as the documentation always said they did. Also: on a verdict whose driving records all have ids, a non-driving row without one now reads false rather than omitting the field.
  • coverage_complete on an identity INCOMPLETE. The field means “every sanctions source loaded”. On an INCOMPLETE caused by an unresolved identity or a failing check digit it was hardcoded false with coverage_gaps: [] and every sources[] row ok — a contradiction. It now carries the computed value; the INCOMPLETE lives in status, where it always did.
  • Impossible numbers publish nothing. On a check-digit failure identity_sources is now [], like vessel_name and matches; a register that happened to hold a row for a mistyped number no longer reads named: true beside identity_basis: "none".
  • A real name beginning “IMO ” is no longer nulled. vessel_name keys on identity_basis now, not on a string prefix.
  • Declared headers. X-RateLimit-* are now declared on every error response in the document (they were always sent); the quota headers are described for the allowance the call draws on rather than as “assessment units”. previous_status on a repeat_screening notice is omitted rather than "UNKNOWN" when the earlier record predates the status field.

Compatibility. No field renamed or removed. ScreeningMatch.source still lists vessel_vetting, a value that never appears on a match and belongs to sources[]; it stays until the 90-day notice has run.

2026-09-13

An INCOMPLETE screening is no longer chargedFixed

Read this if any of your screenings have ever come back sanctions.status: "INCOMPLETE".

What changed. A unit buys a verdict. An INCOMPLETE is the absence of one — we could not name the hull, or a core list could not be read — and until today it consumed a unit of the monthly screening allowance exactly as a GREEN did. From today the reservation is refunded on every INCOMPLETE, on every path: the synchronous POST /api/v1/screenings, screening batches, and vessel monitors. The response says so in two places you already read: X-ArcNautical-Quota-Remaining is the figure after the refund, and the record carries a new boolean, unit_charged, so your invoice and your evidence file agree.

What did not change. An INCOMPLETE is still stored, still retrievable by id, and still replays under the same Idempotency-Key for 24 hours — it is a record of what we could not answer, which is itself evidence. It is still not a clearance. A later screening of the same hull that does resolve is charged once, as any verdict is.

Compatibility. Additive. unit_charged is absent on records stored before today, all of which were charged; treat absent as true. Nothing was renamed or removed and methodology_version does not change. Units already consumed by earlier INCOMPLETEs are not retroactively refunded by this change. One consequence worth knowing: the 80% quota_warning is evaluated at reservation time, so it can fire on a screening that is then refunded; the figures it quotes are correct for the instant it fired.

2026-09-13

An INCOMPLETE now says when to ask again — sanctions.retry_afterAdded

Read this if you retry INCOMPLETE screenings, or have stopped retrying them because it never helped.

What changed. When we cannot name a hull, asking about it puts it at the head of our next scheduled port-State record fetch — the queue is ordered by what customers actually screen — and once that fetch has run the same request often resolves. The response used to say “the same request later may resolve” with no later. It now carries sanctions.retry_after: an ISO-8601 instant by which that fetch will have run, in its worst case. It is derived from the fetch’s own schedule, not typed in, and our test suite fails if the two ever disagree. The identity_unresolved notice repeats it as retry_after and points at it in prose.

How to read it. A string: re-screen under a new Idempotency-Key after that instant (the old key replays the stored INCOMPLETE). null on every other response, including the INCOMPLETEs no scheduled fetch can change — a failing check digit, and a hull every one of our stores has already been asked about and holds no record for. There, only vessel_name answers, and the notice says exactly that instead of suggesting you wait.

Two notices no longer contradict each other. Until today an INCOMPLETE re-ask could carry repeat_screening (“derive the key from the day, so the repeat replays free”) beside identity_unresolved (“re-screen later”). A client following the first would have replayed the non-answer for the rest of the day. On an INCOMPLETE, repeat_screening now names your earlier record as the last complete answer you hold and gives no key advice; when that earlier record was INCOMPLETE too, it is omitted and retry_after is the whole advice.

Compatibility. Additive: sanctions.retry_after (nullable) and ScreeningNotice.retry_after. Absent on records stored before today. The contract version moves to 2026-09-13; methodology_version does not change.

2026-09-10

West Africa and Latin America are no longer read from a frozen copyAdded

Read this if you screen tonnage calling at Lagos, Tema, Abidjan, Dakar, Luanda, Monrovia, Panama, Valparaíso, Callao, Cartagena, Barranquilla, Veracruz or Buenos Aires.

What changed. Earlier today the Abuja MoU and the Viña del Mar Agreement started reaching your screenings, but from second-hand records that had stopped being updated. Both now come from the regimes themselves. Abuja MoU goes from 641 inspections and 8 detentions to 40,756 inspections and 266 detentions across 13,919 hulls, reaching back to 2010 and current to the same day. The Viña del Mar Agreement — Panama, Chile, Peru, Colombia, Mexico, Brazil, Argentina and eight more — goes from 10 detentions to 191, with the deficiency detail the regime publishes: individual deficiency codes, action codes, convention references, classification society, recognised organisation and ISM company.

What to expect. The same direction as the other coverage changes today: worse and more honest, never better. A hull detained at a West African or Latin American port can now read a detention_count above zero where it read zero yesterday, with no change in the vessel. Roughly one hull in five that we screen has an Abuja inspection on record; almost none of them did before. The inspection-gap factor also gains an anchor in both regions, so a hull boarded cleanly at Tema or Callao no longer scores as though nobody had inspected it.

Where the numbers come from matters, and we chose the harder source on purpose. The Viña del Mar Agreement publishes its detentions two ways: a web page and a set of quarterly documents. We read the documents. The web page is easier to read and it is wrong — it attributes one vessel’s IMO number to a different vessel’s detention, and misstates year of build and tonnage. We do not file an inspection against a hull unless the source we read names that hull.

Compatibility. No field was added, renamed or removed, and methodology_version does not change — the method is the same, it is reading more. A result with screened_at on or after 2026-09-10 was computed with this coverage; earlier ones were not, and records already stored are replayed verbatim.

2026-09-10

Six more port-state-control regimes now reach your screeningsAdded

Read this if you compare vetting.detention_count or vetting.grade across screenings of the same hull, or if you screen tonnage trading to India, Sri Lanka, Iran, South Africa, East Africa, the Mediterranean, the Black Sea, West Africa, the Caribbean or Latin America.

What changed. Nine PSC regimes inspect the world’s shipping, and until today your screenings read three of them: Tokyo MoU, Paris MoU and the US Coast Guard. Detentions recorded by the Indian Ocean MoU, the Viña del Mar Agreement, the Mediterranean MoU, the Black Sea MoU, the Abuja MoU and the Caribbean MoU did not reach a screening at all — for four of those six we held the records and nothing read them. That is now 12,603 inspections and 394 detentions newly visible to the score, and two of the six (Mediterranean and Caribbean) are harvested live from here on, the Mediterranean with no publication lag.

What to expect. As with the Tokyo history change earlier today, grades move worse and more honest, not better. A hull detained at Mumbai, Alexandria, Valparaíso, Lagos or Istanbul can now read a detention_count above zero where it read zero yesterday, with no change in the vessel. The inspection-gap factor also gains an anchor in those regions: a hull boarded cleanly at an Indian Ocean or Mediterranean port no longer scores as though nobody had inspected it. Nothing here weakens a clean hull — a zero is still reported only when a source actually answered, and a hull in a region we have not reached is scored exactly as before rather than assumed clean.

Detentions are attributed to the regime that recorded them. Where a screening names the authority behind a detention, it names the actual regulator — “Indian Ocean MoU”, “Mediterranean MoU” — and never a collective label. If we hold a detention we cannot attribute, we count it and say so rather than assign it to a regime that did not record it.

Compatibility. No field was added, renamed or removed. methodology_version does not change for a coverage change — the method is the same, it is reading more. A result with screened_at on or after 2026-09-10 was computed with the wider coverage; earlier ones were not, and records already stored are replayed verbatim.

2026-09-10

Flag performance now reads the MoU lists valid from 1 July 2026, and a clean Tokyo MoU boarding anchors the inspection gapFixed

Read this if you store our vetting letters, compare vetting.grade across screenings of the same hull, or screen Panama-, Liberia-, Malaysia-, US- or Antigua-flagged tonnage.

What was wrong. The flag-performance factor (25% of the vetting composite) was read from a table copied from the 2024 Paris MoU White-Grey-Black list and the 2023/2024 Tokyo MoU list, and nothing re-read the sources after the copy. Both regimes published new lists valid from 1 July 2026. Measured against them on 2026-09-10: 102 placements in our table differed from the current lists, and 15 flags on the lists were not in the table at all — among them Mongolia, São Tomé and Príncipe, Sint Maarten and Djibouti, all Tokyo MoU low-performance registries, each scoring our “unrated” default while its regime rated it worst-tier. Panama, the largest registry in the world, was carried as grey on both MoUs; it is White on both current lists. A screening record we emailed that morning said the opposite.

What changed. The table is now generated from the two sources and checked against them nightly, so it can no longer drift past a 1 July unnoticed. A flag that a MoU does not carry on its current list is reported as unlisted for that MoU rather than as a placement copied from an older edition; its tier comes from the regime that measured it. A flag on neither list scores the no-data default, as before. Separately, the inspection-gap factor on screenings now anchors on the hull’s most recent Tokyo MoU boarding of any outcome: a hull inspected cleanly last month no longer scores that factor off a detention from the year before (the same record read “Inspection gap 40/100” with a clean boarding 37 days old in our own cache).

What to expect. Grades move in both directions and only through those two factors. A Panama-flagged hull with nothing else changed loses 12.5 points of composite (grey→white at 25% weight) and can move a letter; a São Tomé-flagged hull gains 17.5. methodology_version does not change — the method is the same, it is reading the current lists and one more feed. Records stored before today are replayed verbatim; a result with screened_at on or after the deploy was computed on the current lists.

2026-09-10

An AMBER driven by an exact-IMO listing no longer calls itself a name matchFixed

Read this if you triage AMBER results by reading sanctions.detail, or if you have cleared an AMBER as a name collision.

What was wrong. Every AMBER carried the same sentence: “N potential name match — IMO not confirmed in source; manual verification required.” That is the right sentence for a name-only hit. It is the wrong sentence for the other thing that raises AMBER: an exact IMO or MMSI hit on a record that policy keeps below RED — a listing in a non-designating source such as Ukraine’s GUR war register (a watchlist, not a designation), or an aggregated record whose OFAC, UK or UN tag the current primary list no longer corroborates. For those, both halves of the sentence were false: it was not a name match, and the IMO was in the source. We found it on 2026-09-10 in a screening record we had emailed that morning for a hull the GUR register names by IMO, with the sentence sitting two lines above a match reading exact_imo · 100% · ua_war_sanctions. A reader could reasonably have dismissed it as a name collision.

What changed. The sentence now says what drove the verdict. An identifier hit on a non-designating source reads “N listing matched on identifier in a non-designating source (a watchlist or advisory list, not an OFAC, EU, UK or UN designation); manual verification required.” An uncorroborated tag reads “… in an aggregated record whose designation tag the current primary list does not corroborate (possibly delisted) …”. A result with both kinds names both counts. A name-driven AMBER keeps the exact sentence it always had.

What did not change. The status. A watchlist listing was AMBER before and is AMBER now; RED still requires a confirmed designation on a vessel identifier. matches[], match_type, confidence_class and programs were already correct for these records and are untouched — a client reading the structured fields was never misled; only the prose was wrong.

Compatibility. sanctions.detail is descriptive text and has never been a stable contract to parse; if you do match on it, the phrase “potential name match” now appears only on name-driven results. Records stored before today are replayed verbatim.

2026-09-10

An IMO that does not identify a vessel now says so, instead of asking you for its nameAdded

Read this if any part of your pipeline can emit a placeholder IMO — which in practice means any pipeline with a human-entered field upstream of it.

What changed. A fifth notices[] code, identity_contested. It fires when our registry holds four or more different vessel names recorded against the IMO you sent. Past that point the number is not a hull with a rename history; it is a number people type. We decline to name it rather than pick one, because a screening run against the wrong name returns a CLEAN answer that means nothing. It fires instead of identity_unresolved, never alongside it, so an existing handler for that code sees one fewer occurrence and one new code rather than two notices about the same thing.

Why the distinction is worth a separate code. Both mean the status is INCOMPLETE, but they have different causes and different remedies. identity_unresolved is a gap in our data, and sending the name you hold fixes it. identity_contested is a number that never described a vessel, and sending a name buys you one screened answer while whatever produced the number emits it again tomorrow. 1234567 is the case we see most: it passes its own check digit, which is precisely why it survives validation and reaches us looking like a hull, and our registry holds sixteen distinct names against it. The remedy now points upstream, at where the number came from, with the vessel_name fallback kept for a caller who genuinely does hold a name.

Compatibility. Additive. No field, status code or verdict changed, and a hull we can name is unaffected — the check runs only on a screening that already failed to resolve an identity. If you validate notices[].code against a closed enum, add identity_contested to it; the published spec lists it. Records stored before today are replayed verbatim.

2026-09-10

A deploy no longer strands your Idempotency-KeyFixed

Read this if you retry with the same Idempotency-Key after a timeout — which is the behaviour we ask for.

What was wrong. When we restarted a server, any request executing at that moment died with it, and the key it had claimed stayed claimed. A retry under that key then found a request that looked in-flight, waited for it, and was answered 409 idempotency_in_progress once the wait ran out — for an original that no longer existed and would never finish. We measured one on 2026-09-10: the key was claimed three seconds before a restart, the retry arrived thirty-five seconds later, and it spent 31.7 seconds waiting before being refused. The earlier note that such a key was “reclaimed on the next retry” was true only after the orphan window elapsed; inside it, the retry paid the full wait and then got the 409.

What changed. A server going down now releases the keys it will never finish, as part of shutting down. A retry arriving afterwards claims the key and runs immediately rather than waiting on a request that is gone. Nothing about the key’s meaning changed: a completed result is still recorded and still replayed, and a key whose request genuinely is still running still joins it as before.

The one case where you may see a recomputation. If a request completes and returns its answer during the last two seconds of a shutdown, its key can be released even though the work succeeded. A later retry under that key then computes again instead of replaying, and spends a unit. The window is two seconds on a process that is already being terminated, and we chose it deliberately: recomputing is a correct answer arriving twice, where the alternative was a refusal.

Compatibility. No field, header or status code changed. 409 idempotency_in_progress still means what it always meant — a request under this key is genuinely still running — and the correct client behaviour is unchanged: retry with the same key. You should simply see it far less around our deploys.

2026-09-10

Port-state-control history now reaches back past twelve monthsAdded

Read this if you compare vetting.detention_count or vetting.grade across screenings of the same hull, or if you store our vetting letters.

What changed. Our Tokyo MoU port-state-control input was a rolling twelve-month extract of the regime’s inspection database. We now hold the regime’s full inspection history for the hulls you screen — for the hulls we hold today, 96.9% of the inspections on file are older than that twelve-month window and were invisible to the score until today. Three fields move as a result, all in the same direction: vetting.detention_count can rise, because a detention from 2018 is a detention on record; vetting.vessel_age can appear where it was null, because the same source records the keel-laying date; and the inspection-gap factor now anchors on the most recent inspection of any outcome, where before a hull inspected cleanly ten times and never detained had no anchor at all. No field was added, renamed or removed.

What to expect. Grades across a book get worse and more honest, not better. A hull that read B with detention_count: 0 on Monday can read C with detention_count: 2 today with no change in the vessel — the record was always there; we were not reading it. Nothing here weakens a clean hull: a zero is still reported as a zero only when a source actually answered. Coverage is not yet complete for every hull, and a hull we have not reached is scored exactly as before rather than assumed clean.

Compatibility. methodology_version does not change for a coverage change — the method is the same, it is reading more. A result with screened_at on or after 2026-09-10 was computed with the wider history; earlier ones were not, and records already stored are replayed verbatim.

2026-09-10

Screening responses now say what the request left on the tableAdded

Read this if you generate a fresh Idempotency-Key per attempt, or if you have INCOMPLETE results you keep retrying unchanged.

What changed. POST /api/v1/screenings responses carry a new notices[] array. It is additive and empty on a clean first screening under quota; nothing about the existing fields moved. Three codes: repeat_screening (your account screened this hull inside the last 24 hours under a different key, so a unit was spent that a derived key would have saved — the earlier record is named), identity_unresolved (no source names this hull and you sent no vessel_name, so nothing was screened and retrying unchanged cannot help), and quota_warning (80% of the month’s allowance, the same threshold as the webhook event, for accounts without a webhook endpoint). Each carries the fact in message, in your account’s own numbers, and the change to your next request in remedy.

Why. We measured one integration for a month: 42% of its screenings were same-day repeats billed only because the client generated a UUID per attempt, and its INCOMPLETE hulls were re-asked up to six times without the one field that would have screened them. Both remedies were on the developer page. The page is not where an integration learns; the response is. So the response now says it, and only when it applies — a standing notice on every body would be skipped within a day.

Also today: a same-key retry joins the original instead of being refused. A second request under an Idempotency-Key whose first request is still running used to answer 409 idempotency_in_progress immediately. Measured on one integration whose client times out at about eight seconds and retries with the same key — correctly — that 409 was the last thing they saw of every slow hull: the original completed seconds later, spent a unit, and was never read. The retry now waits for the original (up to 30 seconds) and returns its result, flagged X-ArcNautical-Idempotent-Joined: true. A fourth notice, enrichment_latency, appears on any screening that ran five seconds or longer with the default enrichment, naming include_vetting: false for callers who act on sanctions.status alone. And a key left pending by a server restart mid-request is reclaimed on the next retry rather than answering 409 for its full 24-hour life.

Compatibility. Clients that ignore unknown fields are unaffected. Records stored before today have no notices key and are replayed verbatim; read absent as empty. Batch and monitor screenings carry []. Clients that handled 409 idempotency_in_progress by waiting and retrying still work; they simply see it far less.

2026-09-09

A vessel’s name matching a sanctioned company no longer raises AMBER

Read this if you screen hulls with common or short names, or if you have been clearing AMBER results by hand.

What was wrong. A name-only match scoring 0.90 or above against a record from a confirming regime raised AMBER — a “potential name match, manual verification required”. That rule exists for vessel-name drift, where a hull appears on a list under a transliterated spelling (KAPITAN SHCHERBAKOV against OFAC’s KAPITAN SCHERBAKOV) and dropping it would be a false clear. It had no check that the record it matched was a vessel. So a ship whose name resembled a designated company or individual was flagged on that resemblance alone: a bulk carrier named ANTIPAXOS against a listed organisation, a ship named MARTINE against two designated people, one named PEGASUS against a tour operator.

What it was not. These were never confirmed matches and were never RED. Each response said match_type: name_fuzzy, confidence_class: possible, and in words: “IMO not confirmed in source; manual verification required.” Nothing asserted that the hull was designated. But it was work you should not have had to do, on hulls nothing was wrong with.

What changed. A name-only hit now raises AMBER only when the record it matched is itself a vessel listing. Ownership exposure is unaffected and is not weakened by this: a sanctioned owner is found by resolving the hull’s actual owner and screening that company, never by observing that the ship’s name reads like a designated one. Confirmed identifier matches — exact IMO or MMSI, including an exact IMO hit on a company carrying that IMO Company Number — are untouched, as is name-only screening where you give us no IMO to disambiguate with. Excluded hits are still returned in matches[] for your own review; they simply no longer drive the verdict.

And the explanation is now true. When hits are excluded the result is GREEN with a sentence saying why. That sentence used to assert “the source record carries a different IMO” in every case — which was already wrong for low-similarity near-misses, whose records carry no IMO at all, and plainly wrong for a company. The reason is now derived from the evidence it describes, and names the company case as a company case.

If this affected you. Any hull you cleared by hand off one of these AMBERs was clean on our side too. Re-screening is not necessary for correctness — but if you keep a record of why a hull was reviewed, the current answer is the accurate one.

2026-09-06

Our aggregated sanctions feed had been frozen since April 2025Fixed

Read this if you rely on us for EU vessel designations, or for anything OpenSanctions carries that OFAC, the UK, the UN and the EU consolidated list do not.

We read one of our five sanctions sources — the OpenSanctions bulk file — from a collection that upstream stopped updating on 1 April 2025. We did not notice for seventeen months, and the reason we did not notice is the part worth stating plainly: the freshness we published for that source was the time we downloaded the file, which was always that morning. It said ok with a current timestamp every single time. The file behind it had not moved since April 2025.

What was affected. Any designation that reached us only through that aggregator, added after April 2025 — most consequentially the EU’s vessel designations under Annex XLII of Regulation 833/2014, including the entire 21st package of 23 July 2026. A customer found this by taking the 32 hulls in that package that OFAC has not also listed and screening every one of them: eleven came back GREEN. They were right, and the cause was this.

What was not affected. Our other four sanctions sources are direct feeds and were current throughout — OFAC SDN, UK OFSI, UN Consolidated and the EU consolidated financial list. A hull designated by any of those since April 2025 was still matched. The gap was specifically designations for which the aggregator was our only path.

Correction, 2026-09-09. The sentence above overstated one of those four, and we are leaving it in place rather than editing it away. The UN Consolidated List contains no vessel records — 736 individuals and 275 entities, and not one vessel entry. So “a hull designated by any of those was still matched” was not true of the UN: a UN vessel designation reaches us through OpenSanctions, which is the feed this entry is about. In practice UN-designated hulls are also carried by OFAC, the UK or the EU and did match on those, and we have found no hull that was missed because of this. But the reassurance as written was wider than the facts, and on this page that matters. UN Consolidated is a real and current source for entity and ownership-chain screening, which is what it is now described as; it was never a hull-matching source.

Fixed. We now read the sanctions collection, which publishes daily. EU-designated vessels in our corpus went from about 2 to 728, and the hulls that customer tested now match on their IMO with eu_journal_sanctions and eu_sanctions_map in matches[].programs — so an EU designation is now evidenced as an EU designation rather than surfacing only when some other authority happened to mirror it.

And so it cannot happen quietly again. The freshness we publish for this source is now the file’s own publication date, not our download time, so checked_at answers “how current is the data” rather than “how recently did you fetch”. A nightly probe reads that date independently and alarms if it stops moving. Port-state-control detention records, which used to travel inside the old collection, are now fetched from their own dataset and are fresher than they were.

We would rather write this than have you find it. If you have screened EU-flagged or EU-designated tonnage with us and cleared it, re-screen it.

2026-09-06

Graded screens now have their own, much lower concurrency ceilingFixed

The vetting and ownership grade is the only part of a screening that calls an external company registry, and our access to it is capped at five simultaneous calls for the whole service. A single hull needs about three of them. That means fewer than two hulls can be graded at once before the queue starts timing out — and our published concurrency limits were set before anyone had located that bottleneck, so they permitted a level of traffic the engine cannot actually serve.

What that looked like, measured on 6 September. At twelve screenings in flight — inside our own published limits — every one of the twelve lost its grade, all five sanctions sources failed on all twelve, and four hulls that return a clear RED or GREEN on a quiet system came back INCOMPLETE. Two of those four were designated vessels whose RED was lost. Every one of them was billed.

The system failed closed throughout — not one of those became a false GREEN, which is the promise in the SLA and it held under conditions we engineered specifically to break it. But a screening that cannot answer is not worth paying for, and you had no way to know in advance that you were asking for more than we could deliver.

So screenings that compute the grade now have their own ceiling, set at the highest level we have measured cleanly. Past it you get a 429 service_concurrency_limit, which costs nothing, instead of a billed 201 that could not complete. The refusal names the remedy: include_vetting: false does not enter this queue at all. The same twelve hulls, in the same minute, screened without the grade, degraded zero times — so if you compute your own vessel risk score, this limit will never apply to you.

Batch screening is unaffected: it drains one hull at a time and cannot create this contention. Vessel monitors are unaffected for the same reason.

2026-09-06

Every error code is published, with whether it is worth retryingAdded

ApiErrorEnvelope.code was a bare string. Two customers asked for an enum, independently. One counted 14 codes on the wire against one in the document and noted the ratio had got worse rather than better since their previous visit, because we kept adding codes. There are in fact 36 — they could only see the ones they managed to trigger, which is the whole problem: nobody should have to discover a vocabulary by provoking failures in production.

The enum is now in the spec, generated from the list the server actually throws from rather than restated by hand — a hand-kept copy of a server-side vocabulary drifted on us once before, in the webhook event list, and an endpoint configured from our own documentation received none of the five missing events. A test now fails if a code is thrown and not published, so the two cannot separate again.

Each code says whether it is retryable, because the status does not. Two 409s mean opposite things: idempotency_in_progress resolves by waiting, idempotency_conflict never resolves and needs a new key. A 500 is normally worth retrying, but one of ours is not. If your client decides on status alone it is wrong on both pairs, and one customer told us theirs treats every 500 as retryable and would hammer it.

One error code meant two different thingsFixed

export_too_large was returned both as a 422 when the range you asked for exceeded the per-export row cap, and as a 500 when the matching rows grew past that cap while the file was being written. Same code, opposite advice: the first says narrow the range and try again, the second is a race we deliberately fail loudly rather than ship a file that is silently short. The second case now has its own code, export_row_count_grew, and is published as not retryable — an identical retry fails identically.

2026-09-06

What we said about EU vessel coverage was wrongFixed

The eu_sanctions row in sources[] carried a sentence saying EU vessel designations “reach this screen through OpenSanctions and appear in matches[].programs as eu_journal_sanctions / eu_sanctions_map”. That was inferred from a count of IMO-bearing vessels in those datasets and never tested against the actual designations. It is not true. Tested against the vessel annex to the EU’s 21st package (Reg (EU) 2026/1848, 23 July 2026): of the 32 hulls it designates that OFAC has not also listed, 31 carried no EU attribution.

Why it was not true. Our direct EU source is the FSF consolidated list — an asset-freeze instrument that is overwhelmingly companies and people, carrying roughly two vessel entities. Annex XLII of Regulation 833/2014 is a different instrument: a port-access and services ban naming 673 vessels, the one that prohibits bunkering or supplying a designated hull. It was supposed to reach you through our OpenSanctions source, and the route existed — but the file at the end of it had not moved since April 2025, so older EU designations appeared in our results only because another authority, usually UK OFSI, had independently listed the same hull. The newest package was the least covered, which is the opposite of what you need.

Now fixed — see the entry above. Reading the daily OpenSanctions collection took EU-designated vessels in our corpus from about 2 to 728, and the hulls in that 21st-package test now match on their IMO with eu_journal_sanctions and eu_sanctions_map in matches[].programs. Two caveats we would rather state than let you discover: that attribution arrives on the opensanctions source rather than eu_sanctions, so a client filtering matches[].source for EU will not see it — promoting Annex XLII to a first-class source with its own row is still open work; and if you cleared EU tonnage with us between April 2025 and today, re-screen it.

2026-09-06

Screen without the vetting grade — include_vettingAdded

POST /api/v1/screenings and POST /api/v1/screening-batches accept include_vetting. Leave it out and nothing changes. Set it false and you get the sanctions verdict and the cargo screen without the vetting and ownership enrichment.

Why it exists. The enrichment is the only part of a screen that depends on a shared external registry, and that dependency is capped across all customers at once. When several screens run concurrently they queue behind it, and the ones that lose the race return a complete sanctions verdict with assessed: false and no grade — billed, correct, and less useful than you expected. If you compute your own vessel risk score, you were paying that cost for an answer you discard. Opting out takes your calls out of that queue: the screen is faster and does not thin out under your own concurrency.

A skipped source is not a failed one. vetting.status and the vessel_vetting row in sources[] both report not_requested, a new value alongside ok and error. The distinction is the point: error means we could not read a source and a retry may help; not_requested means you told us not to and no retry will ever change it. Reporting the second as the first would send you round a loop you deliberately switched off; reporting it as ok would claim we read something we never called. Vessel monitors always run the full screen — they compare against a stored baseline, and a quietly lighter screen would look like a grade disappearing.

?limit=abc returned 500 on every list endpointFixed

A non-numeric limit reached the database as an invalid value and came back 500 internal_error, on all nine paginated collections. It is now 422 naming the field and the accepted range, like the sibling parameters on the same handlers always were. Numeric edge cases were never affected: 0 and negatives clamp to 1, oversized values clamp to the maximum, and a fractional value truncates. Found by a customer fuzzing query parameters, whose client treated our 500 as retryable.

PublicVetting.grade and score are declared nullableFixed

The keyless endpoint stopped inventing a grade for unassessed hulls on 2026-09-06 and began returning null. The schema still declared both fields non-nullable, so a client generated from our own OpenAPI document could not deserialise our own response. PublicOwnership beside it was corrected at the time and this was missed. Reported by a customer who generated that client.

Two retryable 409s now carry Retry-AfterFixed

export_not_ready and idempotency_in_progress both mean “wait and try again”, and both sent nothing a client could pace against — the header was emitted only for 429. Both now carry it. The values are poll cadences, not predictions of when the work finishes.

A shared-pool refusal said it was your own limitFixed

When screening capacity filled across all accounts, the refusal reused the per-account message and told you that you had six screenings in flight — a statement about your usage that could be false, blaming you for another customer’s load, with the wrong remedy attached. That case now has its own message and its own code, service_concurrency_limit, so a client can tell “slow down” from “we are busy, retry unchanged” without parsing prose. Both refusal messages also now name POST /api/v1/screening-batches, which is the supported way to run volume and is not subject to that limit.

2026-09-05

A status page you can check without asking usAdded

status.arcnautical.com is live. It runs on Cloudflare’s edge with no dependency on our API, our database or our host, because a status page that lives on the machine it monitors reports that everything is fine right up until it stops answering at all. Four components are probed every minute: the website, the application server together with PostgreSQL and Redis, the /api/v1 surface, and a real vessel screen.

It is now the measurement of record for the SLA, so the arithmetic is stated rather than implied. Uptime is passed checks over total checks — no maintenance exclusion, nothing removed after the fact — and the check count is printed beside every percentage so a thin sample is visible rather than flattering. Percentages round down: 99.99% is never shown as 100%. One failed check is degraded, two consecutive are down, and a single failure still counts against uptime even though it is not called an outage.

The screening probe is the one worth explaining. The keyless check endpoint caches for an hour, so a 200 from it proves the cache is warm rather than that anything screened. The probe therefore requires a verdict that is assessed and no more than three hours old, and treats assessed: false as a failure — reporting “operational” while the screener was handing out defaulted verdicts would assert exactly what we tell you never to believe.

GET /api/status.json is the machine-readable feed and GET /healthz answers 503 during an outage, if you would rather alert on it than read it. What the page cannot see is on the page: it runs on Cloudflare and measures through Cloudflare, so a Cloudflare-wide outage would take it down alongside the service.

The page carries an incident log, and nobody here writes it. An incident opens automatically on the second consecutive failed check, is dated from the first of those failures rather than the second, appends an entry if the cause changes, and closes on the first check that passes — stating how long it ran and how many checks failed. There is no authoring route and no way to edit or delete an entry, so the incident log and the uptime figures are the same evidence and cannot be made to disagree. The cost is that during an outage the page tells you what failed and for how long, but not why; that explanation reaches your technical contact by mail and lands here if it changed anything you integrate against. Days before the page began measuring are not listed at all, because a day nobody watched cannot be reported as a quiet one.

You can be told rather than having to look. Subscribe any HTTPS endpoint from the page — a Slack or Microsoft Teams incoming webhook works unchanged, since the payload leads with a text field and carries the structured incident alongside it. You get one message when an incident opens, one if its cause changes, one when it closes, and nothing else. Deliveries are signed with X-ArcNautical-Signature: sha256=<hmac> using a secret handed over once at subscription, and your endpoint must accept a test delivery before we store it, so nobody can subscribe a server that never asked. /feed.xml and /feed.atom carry the same log for anything that reads feeds. Notifications are sent from the Worker itself, not from our servers, so they do not go quiet at the moment they matter.

2026-09-05

Rotation, IP restrictions and the audit trail have buttons nowAdded

These three shipped earlier today as endpoints, with correct documentation and no controls anywhere in the application. That is a real gap rather than a cosmetic one: the person who most needs to rotate a key is an account admin who has just learned it leaked, and “write a curl command” is not a control that person has. A security questionnaire asks whether keys can be rotated and restricted; answering “yes, through the API” answers a different question.

The key table in Developer API now shows each key’s expiry and its IP restriction — both were returned by the API from the day they shipped and dropped by the interface, which made a restricted key indistinguishable from an unrestricted one, and an expiring key indistinguishable from a permanent one until the 401 arrived. Rotation offers 24 hours, 7 days, or immediately, with immediately presented as a first-class choice rather than buried, because for a leaked key downtime is the right trade. The replacement secret appears in the same one-time panel a newly minted key uses, and says which key it replaced and when that one stops.

IP restrictions are edited one address per line. The dialog states what is accepted (exact IPv4, IPv4 CIDR, exact IPv6 — an IPv6 range is refused rather than silently ignored) and confirms before narrowing a key, because the restriction applies on the very next request: a wrong list is an immediate outage in which the key is valid, the request is well formed, and the answer is still no.

Account activity is a new read-only card under Settings → Team: who created, revoked or rotated a key, who changed a key’s IP restrictions, and who invited, promoted or removed a colleague. Any member can read it, not only an admin — “who removed me” deserves an answer too. A colleague who has since been removed is still named, because the actor is recorded on the row rather than looked up afterwards. Kept 730 days, in English, Korean and Japanese.

Nothing changed about the endpoints themselves; every one of these does exactly what it did this morning.

2026-09-05

Bulk export, and five webhook events that were unsubscribableAdded

POST /api/v1/exports turns your screening history, cargo screenings or access log into a single CSV or JSONL file. It returns 202 with an id; poll GET /api/v1/exports/{id} or subscribe to export.completed, then fetch GET /api/v1/exports/{id}/download. Screening evidence is retained ten years, and an auditor asking for “everything we screened in Q3” wants one artifact to file, not a paging loop to write and then prove correct.

A range over 500,000 rows is refused, not truncated. The rows are counted before the job is accepted and 422 export_too_large names the real number. A file that silently omits rows is worse than no file: everyone downstream of the person who read the flag treats it as complete. For the same reason filters.created_before is pinned to the moment of your request when you leave it out, and returned to you — otherwise a job picked up a minute later sweeps in rows written in between, and the same request produces a different file tomorrow. A filter that does not apply to the resource is a 422 rather than a silent no-op.

Each resource is governed by the scope that already governs reading it — vessel:screen, cargo:screen, logs:read — re-checked at download rather than only at creation. An export reserves no quota and screens nothing. The file's SHA-256 is published on the resource and in X-ArcNautical-Export-Sha256, so an archived copy can be shown years later to be the one we generated. CSV carries a UTF-8 BOM so non-Latin vessel names survive Excel (read it with encoding="utf-8-sig"); JSONL carries none, and each screening line is exactly what GET /api/v1/screenings/{id} returns. Files live 7 days; the records behind them are unaffected.

Five webhook events were emitted but not subscribableFixed

Read this if your webhook endpoint lists event_types. That field is a filter: an endpoint that names types receives only those. The published list carried 7 of the 12 events we emit, and the five missing ones were vessel_risk.changed, vessel_monitor.ready, vessel_data.degraded, vessel_data.recovered and screening_batch.completed. An endpoint configured from our documentation therefore received no vessel-monitor notifications at all — the monitor looked correctly set up and simply never delivered. Endpoints that left event_types empty were unaffected; empty has always meant everything.

All twelve are now published, along with export.completed and export.failed, and the list in the OpenAPI document is generated from the one the server validates against rather than kept by hand — two copies is how it drifted. An unrecognised event type is now rejected when you write it, with the valid types named, instead of being accepted and silently matching nothing forever. If you subscribed to a subset, re-read your endpoint and add what you are missing.

2026-09-05

Your own access log, and the webhook delivery recordAdded

GET /api/v1/access-log returns every authenticated request made with a key on your account, in that environment — method, path, status, the machine error_code on a refusal, duration, source address, user agent, which key was used, and the request_id you were handed at the time, so a row joins straight to your own logs. Filter by outcome, status, method, endpoint, key_id or date. Retained 365 days, under the new logs:read scope.

Refusals are recorded, not only successes — usually the half worth reading. Unauthenticated requests are absent by construction: a rejected key carries no account to attribute them to, and the response says so rather than leaving you to infer it.

GET /api/v1/webhook-deliveries is the same idea for events we sent you. Delivery is attempted immediately, then after 5min, 30min, 2h and 12h; after the fifth failure the status is dead and it is not retried. Until now a dead delivery existed nowhere you could see it, so an event could be attempted against an endpoint that was down for a morning and simply disappear. Filter with ?status=dead to find events you never received, read one back with its payload byte-for-byte as it was signed so you can re-verify the HMAC yourself, and queue a fresh attempt with POST /api/v1/webhook-deliveries/{id}/redeliver.

A replay inserts a new delivery and leaves the original exactly as it ended. The record that an event failed five times is part of your audit trail and a successful replay must not erase it; the two are linked by replay_of and share an event_id. dead_count and in_flight_count are account-wide rather than page-local, because the question is whether anything of yours is lost right now.

Both are additive and use the existing webhooks:manage scope where relevant. logs:read has been added to existing keys in place, so you do not need to rotate.

2026-09-05

Key expiry, rotation, IP restriction, and individual loginsAdded

API keys can now be rotated without downtime. POST /core/rotate-api-key mints a replacement inheriting the old key’s name, scopes, environment and limits, and puts the outgoing key on a deadline — 24 hours by default, up to 7 days, or 0 to cut it off immediately. Both work during the window, so you can deploy the new value before the old one stops. The outgoing key is expired rather than deleted, so your access log can still say which key made a call last month.

Keys can carry an expiry at creation (expires_in_days) and can be restricted to your own IP addresses via POST /core/update-api-key-allowlist — exact IPv4 addresses, CIDR ranges, or exact IPv6 addresses. A refused request names the address we actually observed, because the usual cause is a call from a NAT gateway or a new CI runner rather than the office you allowlisted. Restrictions apply on the next request, not at the end of a cache window.

Existing keys are unchanged: no expiry and no IP restriction, exactly as before. Both are opt-in, because an expiry that arrives unannounced is a 401 nobody is watching for.

Colleagues can now be invited to their own logins instead of sharing one credential. Invitations are single-use and expire in 14 days. Roles are admin, manager and analyst; managing the team and creating or revoking API keys require admin. Removing someone ends their access immediately rather than when their session happens to lapse. POST /core/list-account-audit records who minted or revoked a key and who invited, promoted or removed a colleague, readable by any member and retained 730 days.

2026-09-05

Cargo control screeningAdded

POST /api/v1/cargo-screenings screens a goods description, and an HS code when you have one, against the Common High Priority Items List — the 50 six-digit headings the US, EU, UK and Japan jointly identify as items Russia seeks for its weapons programmes — against the discharge jurisdiction, and, for medicine and medical devices, against the published humanitarian general-licence context. Returns OK, WARNING or CRITICAL under the new cargo:screen scope, added to existing keys in place.

Every response names the lists it read in lists_screened and the lists it did not in lists_not_screened. This is not an export-control classification: it assigns no ECCN and no EU dual-use category, and it does not assess end use or end user. Read lists_not_screened before relying on an OK.

Supplying an imo carries that hull’s last known verdict alongside the goods result. A hull you have not screened is reported as not screened, never as clear.

2026-09-05

Screening evidence is retained for ten yearsFixed

Screening records and their evidence were expiring after 30 days along with voyage assessments. That was wrong: OFAC extended its recordkeeping requirement from five to ten years effective 12 March 2025, and sanctions screening records are named in scope — so we were destroying your evidence roughly 120× sooner than you are obliged to hold it, and silently emptying the screening history endpoint, which filters on expiry.

Screening evidence is now retained 3,650 days. Voyage assessments, batches and idempotency keys keep their 30-day operational window; they are a cache, not a compliance record. Existing screenings still within their old window were re-stamped onto the longer one, measured from when they were taken.

2026-09-04

Fleets, and screening historyAdded

POST /api/v1/fleets registers a named list of hulls, with exposure and change views over it. A fleet holds membership only and no verdict of its own: its summaries read through to the screening records behind them, so a fleet can never assert something those records do not. Registering does not screen and reserves no quota.

GET /api/v1/screenings returns your screening history, newest first — the audit view. Filter by date to answer “everything we screened in Q3”, or by customer_reference to find the screening behind one order. Both use the existing vessel:screen scope and an opaque keyset cursor.

2026-09-04

ownership is now null when a hull was not assessedFixed

Read this if you parse ownership. When a screening could not be completed to standard, the response set assessed: false but still returned ownership.opacity: "MEDIUM" and ownership.score: 50 — a placeholder that was indistinguishable from a measured result. Under concurrent load this was not rare: in a controlled test every one of twelve simultaneous screenings returned that placeholder, against none run sequentially.

ownership.opacity and ownership.score are now null whenever assessed is false. The field types widen from string and integer to nullable; nothing is removed or renamed. If your client assumed a non-null band it should now branch on assessed first, which was always the correct reading — a defaulted band is not a low-risk one.

Change notifications are gated the same way. A hull whose earlier screen had the placeholder and whose fresh screen fails closed previously reported an ownership improvement; a transition into or out of an unassessed state is no longer reported as a change in the underlying risk.

2026-08-03

Hull screening and vessel monitorsAdded

Sanctions screening is now a keyed part of the API rather than only a rate-limited public demo. POST /api/v1/screenings screens one hull and POST /api/v1/screening-batches takes up to 50, both under the new vessel:screen scope. The response carries the full match evidence with confidence classes, the five weighted vetting factors, and the same sources freshness contract the assessment endpoint uses.

POST /api/v1/vessel-monitors watches a hull and fires vessel_risk.changed when its sanctions status, ownership opacity, or vetting grade moves — under the new vessel:monitor scope, at a 24-hour cadence, 50 hulls on a self-serve key. A change is only sent once a second screen confirms it, and vessel_data.degraded fires when a screen returns INCOMPLETE so silence never needs interpreting.

Screening is metered on its own monthly allowance — 5,000 live, 500 test — separate from voyage assessments, so a fleet screen cannot drain your scoring quota. GET /api/v1/usage gains a screenings block alongside the existing assessments one, and a vessel_monitors block alongside monitors. Both existing blocks keep their exact shape.

Nothing here is breaking: new paths, new scopes, new response fields. Existing keys have had the two new scopes added in place, so you do not need to rotate. Keys where scopes were set explicitly were left untouched.

2026-08-02

Assessments now report per-source freshnessAdded Deprecates

Assessment responses carry a new sources array: each signal’s status as before, plus checked_at and a basis saying what that timestamp means. Previously the response said a source was ok without ever saying when it was last good, so a sanctions list refreshed four days ago was indistinguishable from one refreshed four minutes ago.

checked_at is null wherever we do not genuinely know it — two of the eleven signals carry no timestamp anywhere in their fetch path, and those report basis: "unknown" rather than a filled-in request time. Read basis before comparing timestamps across sources: latest_event_at dates the newest record we hold, which is not the same claim as refreshed_at.

source_status is deprecated by this and keeps working until 2026-10-31. Both fields are populated in the meantime.

2026-08-02

Corridor monitor and webhook endpoints now work with self-serve keysFixed

Keys created through the developer portal were minted with only voyage:score and usage:read, while 12 of the 18 published operations require voyage:monitor or webhooks:manage. Those endpoints returned 403 insufficient_scope to every self-serve key, even though the account’s GET /api/v1/usage reported corridor monitors as available.

New keys now carry all four scopes. Existing keys that still held the old two-scope default have been widened in place — you do not need to rotate. Keys where scopes were set explicitly were left untouched.

2026-08-02

API key creation fixed for accounts created without a passwordFixed

Two of the four sign-up paths did not grant the external API entitlement, so createApiKey returned 403 for accounts created through them and no key could be issued at all. All sign-up paths now grant it, and key creation provisions it on demand if it is ever missing.

2026-07-31

Corridor monitors included in the self-serve allowanceAdded

Monitor runs are now metered against the same monthly assessment quota as interactive calls, so the feature could be granted rather than withheld. Self-serve keys carry 10 corridor monitors at a 6-hour cadence.

2026-07-30

Live API keys became self-serveAdded

Live and test keys are both issued from the developer portal without a manual provisioning step. Live carries 10,000 assessments a month, test 1,000. Neither expires and neither is a trial.