Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: ArcNautical Public vessel check API
version: v1
description: 'Vessel sanctions screening (single hull, batches of 50, standing monitors, fleets, cargo), voyage risk scoring, corridor monitoring, signed webhooks, exports and usage reporting. One keyless GET screens a hull; everything else runs on a self-serve key.
Limits and expiry are entitlement-specific. Use GET /api/v1/usage to read the authenticated key environment, quotas, batch limits, and live-monitor capacity.
The API exposes normalized route and risk outputs only. It does not expose raw third-party feeds, internal scoring weights, or AI-generated briefing prose.
FIELD NAMES ARE snake_case, AND UNKNOWN FIELDS ARE REFUSED. A body property this API does not read returns 422 with field_violations naming it and listing the accepted keys — it is never accepted and ignored. A camelCase spelling of a real field is named explicitly in the violation. Note that the keyless GET /api/v1/vessels/{imo}/check RESPONSE is camelCase; that is a different contract and does not carry over to request bodies here.
CORS: preflight (OPTIONS) returns Access-Control-Allow-Origin: * with Allow-Methods and an echo of your Access-Control-Request-Headers, so this API is callable from a browser. Authorization is not a CORS-safelisted header, so every cross-origin call — including GET — will preflight. Credentials are a bearer key you supply, never an ambient cookie, which is why * is both correct and permitted.
HTTP conventions: HEAD is accepted anywhere GET is and returns the same headers with no body. A valid path reached with the wrong method returns 405 with an Allow header, never 404. Content-Type may be application/json, text/plain, or absent — text/plain is accepted deliberately because that is what fetch() sets for a string body — but a header describing a genuinely different format (form-encoded, multipart, XML) returns 415 rather than being parsed as JSON anyway. limit is validated, never clamped — 0, a negative, a fraction, or a value above the maximum returns 422 rather than being silently adjusted. A path under /api/vN for a version this service does not publish returns a JSON envelope with code unsupported_api_version, so it is distinguishable from the HTML a network layer would return.'
x-api-version: '2026-10-29'
contact:
name: ArcNautical
url: https://arcnautical.com/developers/
email: admin@arcnautical.com
termsOfService: https://arcnautical.com/terms.html
x-logo:
url: https://arcnautical.com/logo-512.png
altText: ArcNautical
servers:
- url: https://arcnautical.com
description: Production
- url: http://localhost:3001
description: Local development
tags:
- name: Public vessel check
description: Unauthenticated hull screening. IP rate-limited, cached, and not retained against a customer. Requires no key and no account — this is the endpoint the documentation leads with.
paths:
/api/v1/vessels/{imo}/check:
get:
operationId: checkVesselPublic
summary: Screen a hull by IMO (no key)
description: Screens one hull against sanctions, ownership opacity, and vetting. Requires NO API key and no account. Rate-limited to 100 requests/hour per IP and cached for one hour. The response reports X-RateLimit-Limit, -Remaining and -Reset so a client can pace itself without being refused first. Results are not retained against a customer and carry no quota. This screens a VESSEL, not a voyage — voyage scoring is POST /api/v1/voyage-assessments and needs a key.
tags:
- Public vessel check
security: []
parameters:
- name: imo
in: path
required: true
description: 'IMO number. Accepts 9811000 or IMO9811000 (URL-encode a space: IMO%209811000), the same forms POST /api/v1/screenings accepts. Anything without exactly one 7-digit number in it is refused with 400 `invalid_imo`, which is not counted against the hourly allowance.'
schema:
type: string
example: '9811000'
responses:
'200':
description: Screening verdict.
headers:
X-RateLimit-Limit:
description: Requests allowed per hour, per IP.
schema:
type: integer
Cache-Control:
description: Verdicts are cached for one hour. INCOMPLETE is never cached.
schema:
type: string
content:
application/json:
schema:
$ref: '#/components/schemas/PublicVesselCheck'
example:
imo: '9274446'
sanctions:
status: RED
detail: 4 confirmed matches on vessel identifier.
detailKey: verdict.sanctions.confirmed_match_other
detailParams:
count: 4
coverageComplete: true
coverageGaps: []
identityScope: current-name-and-identifiers
ownership:
opacity: MEDIUM
score: 40
vetting:
grade: E
score: 90
status: unacceptable
assessed: true
checkedAt: '2026-09-12T08:10:40.616Z'
provider: ArcNautical
fullReport: https://arcnautical.com/check?imo=9274446
docs: https://github.com/arcnautical/vessel-check-api
tier: keyless
tierDetail: 'Verdict summary only. A self-serve API key is free — no card — and returns the full record: every sanctions match with its source list, programme and confidence class, each source reported separately, retained as a citable screening record; 2,000 screenings on its own allowance (one-time on a new account), not this per-address limit. Mint one at https://arcnautical.com/get-a-key?from=keyless. This endpoint ignores credentials.'
fullRecord: https://arcnautical.com/developers
'400':
description: '`invalid_imo`: the path segment does not contain exactly one 7-digit number. The message says what is wrong with the value sent (no digits, too few, or which valid IMO an 8-digit value contains). Not counted against the hourly allowance, and it carries the X-RateLimit headers. Before 2026-09-30 a value containing a letter was answered 404 `not_found` by the edge instead.'
content:
application/json:
schema:
$ref: '#/components/schemas/PublicCheckError'
'429':
description: 'Rate limit exceeded: the per-IP hourly allowance, or a burst refused before it. Also code `concurrency_limit`: more than 2 checks from this address waiting for a screening slot at once; send the next when one returns. All carry Retry-After and the {code, message, request_id} envelope.'
'500':
description: Screening failed.
'503':
description: '`keyless_busy`: at most 4 hulls that are not already cached are screened at once across every keyless caller, and this check could not be seated within 10s. It is not counted against the hourly allowance; retry after Retry-After. A hull anyone checked in the last hour answers from cache and never waits. `keyless_paused`: keyless checks are temporarily paused under unusual load; Retry-After says when to try again. Neither applies to the keyed lane (POST /api/v1/screenings), which runs separately.'
components:
schemas:
PublicCurrentContext:
type: object
description: Freshness of INCIDENT CONTEXT for this hull. Distinct from sanctions.coverageComplete, which is about sanctions-source coverage.
properties:
coverage:
type: string
enum:
- fresh
- refreshing
- stale
- unavailable
description: How recently incident context was refreshed for this hull.
checkedAt:
type:
- string
- 'null'
format: date-time
lookbackStart:
type: string
incidents:
type: array
items:
type: object
PublicOwnership:
type: object
description: Ownership opacity. HIGH is the dark-fleet signature (no LEI plus flag of convenience). Both fields are NULL when the top-level `assessed` is false.
properties:
opacity:
type:
- string
- 'null'
enum:
- LOW
- MEDIUM
- HIGH
- null
score:
type:
- integer
- 'null'
minimum: 0
maximum: 100
PublicCheckError:
type: object
description: 'Error body for the keyless check endpoint: the {code, message, request_id} shape every other error uses, plus `usage` and a worked `example`. (Until 2026-09-07 it was {error, usage, example}; this schema kept describing that shape until 2026-09-30.)'
required:
- code
- message
- request_id
properties:
code:
type: string
enum:
- invalid_imo
description: Branch on this, never on the message.
message:
type: string
request_id:
type: string
description: Also in the X-Request-Id header. Quote it to support.
usage:
type: string
example: GET /api/v1/vessels/{imo}/check
example:
type: string
example: GET /api/v1/vessels/9034690/check
PublicSanctionsVerdict:
type: object
description: 'Sanctions outcome plus how complete the SOURCE COVERAGE was. Note: coverageComplete/coverageGaps describe which sanctions lists were reachable. That is a different axis from currentContext.coverage, which describes incident-context freshness. Same word, different meaning.'
required:
- status
- detail
- coverageComplete
properties:
status:
type: string
enum:
- RED
- AMBER
- INCOMPLETE
- GREEN
description: 'RED = confirmed match. AMBER = possible match needing review. GREEN = no match against the sources reached. INCOMPLETE = no match, but the screen cannot be attested as clear: a CORE source (OFAC SDN, OpenSanctions, EU FSD) could not be loaded, OR no source could name the hull (the identifiers were screened, a name was not), OR the IMO fails its check digit and belongs to no vessel. `detail` says which. INCOMPLETE FAILS CLOSED — it must never be read or rendered as clear, and it is deliberately never cached.'
detail:
type: string
description: Human-readable explanation of the verdict.
coverageComplete:
type: boolean
description: True when every sanctions source loaded. A GREEN with coverageComplete=false means "clear against the sources we reached, one supplementary source was unavailable" — treat it as a distinct state and re-screen, not as a clean pass.
coverageGaps:
type: array
description: 'Display names of sources that did NOT load. One of: OFAC SDN, OpenSanctions, EU FSD, UN Consolidated, UK OFSI.'
items:
type: string
detailKey:
type: string
description: Stable message key behind `detail` (e.g. verdict.sanctions.confirmed_match_other), for a client that renders its own wording or another language. `detail` is the English rendering of this key with `detailParams`.
detailParams:
type: object
description: Parameters for `detailKey`, such as the match count.
additionalProperties: true
identityScope:
type: string
enum:
- current-name-and-identifiers
description: 'What the lists were matched against: the identifiers and current name of the hull, plus the other names our identity sources hold for it (named in `detail`). A former name none of our sources holds is not screened, so a hull renamed after a designation under a name we never saw can still read GREEN. The value keeps its original spelling for compatibility.'
PublicVetting:
type: object
description: 'Vetting grade. Both `grade` and `score` are NULL when the top-level `assessed` is false — there is no stand-in grade, and a letter beside `assessed: false` gets read as a letter. They were declared non-nullable here until 2026-09-06 while the endpoint already returned null, so a client generated from this document could not deserialise the response.'
properties:
grade:
type:
- string
- 'null'
enum:
- A
- B
- C
- D
- E
- null
score:
type:
- integer
- 'null'
minimum: 0
maximum: 100
status:
type: string
enum:
- acceptable
- marginal
- substandard
- unacceptable
- not_assessed
PublicVesselCheck:
type: object
description: Screening verdict for one hull. Returned by the keyless GET /api/v1/vessels/{imo}/check.
required:
- imo
- sanctions
- ownership
- vetting
- assessed
- checkedAt
properties:
imo:
type: string
description: The seven-digit IMO that was screened.
vesselName:
type:
- string
- 'null'
description: The name our sources hold for this hull, so you can confirm WHICH vessel was screened. Null when no source names it.
alsoRecordedAs:
type: array
description: Other names our sources hold for this hull, each with the source that saw it. Every one was screened on this call.
items:
type: object
properties:
name:
type: string
source:
type: string
identityBasis:
type: string
enum:
- sources
- user
- none
description: sources = our records name the hull; none = nothing named it (the identifiers were screened, a name was not).
checkDigitValid:
type: boolean
description: False when the IMO fails its check digit — almost always a typo. The hull is still screened; treat a clear result with suspicion.
sanctions:
$ref: '#/components/schemas/PublicSanctionsVerdict'
ownership:
$ref: '#/components/schemas/PublicOwnership'
ownershipFactors:
type:
- array
- 'null'
description: 'What ownership.score is made of: the lines it is the sum of (clamped to 0-100), each with its points, so "no LEI record on an open register" can be told from a hidden owner. Null when the hull was not assessed, and null when the lines would not add up to the score shown (we never send a breakdown that disagrees with its total). A record cached before 2026-10-02 omits it. Labels are sentences for a person to read, not codes: display them, do not parse them.'
items:
type: object
required:
- label
- points
properties:
label:
type: string
points:
type: number
description: Contribution to ownership.score. Negative for a relief.
vetting:
$ref: '#/components/schemas/PublicVetting'
assessed:
type: boolean
description: 'False means the hull was not assessed: ownership opacity/score and the vetting grade/score are then NULL and vetting.status is "not_assessed" — there is no stand-in value. Render "not assessed", never a grade. The sanctions verdict is unaffected.'
currentContext:
$ref: '#/components/schemas/PublicCurrentContext'
checkedAt:
type: string
format: date-time
provider:
type: string
fullReport:
type: string
description: Human-readable report URL for this hull.
docs:
type: string
description: Documentation URL for this endpoint.
tier:
type: string
enum:
- keyless
description: Always "keyless" here. Says which tier produced the body, so a client that also holds a key can tell the two record shapes apart.
tierDetail:
type: string
description: What this tier does NOT contain, that a self-serve key is free, what it grants, and where to mint one. Rides on every response because this endpoint is the one channel a keyless integration is guaranteed to read.
fullRecord:
type: string
description: Where the authenticated screening record is documented.
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: arc_live_... or arc_test_...
description: 'Preferred authentication. Use Authorization: Bearer arc_live_... or arc_test_....'
legacyApiKey:
type: apiKey
in: header
name: X-ArcNautical-Key
description: Compatibility header for customers that cannot send Bearer tokens.
externalDocs:
description: Human quickstart, integration rules, and endpoint guide
url: https://arcnautical.com/developers/