API v1 documentation · see real output before you sign up
Vessel sanctions screening API
Screen any hull by IMO for sanctions, ownership opacity and a vetting grade; screen a whole book in batches, keep a standing monitor on it, and receive a signed webhook only when a verdict moves. Voyage risk scoring runs on the same key. Start by running something real — no key, no account.
Try it without a key
Screen any hull by IMO with one unauthenticated GET. No key, no account, 100 requests/hour per IP. Bursts are smoothed as well: a tight loop is refused with 429 and a Retry-After before the hourly allowance is spent, in the same JSON envelope as every other error — honor it and the loop completes. It is a sample for evaluation and occasional lookups, not a data feed: an automated client that runs a product on it can be held to a small monthly sample, and the refusal names the free key that replaces it. A hull anyone checked in the last hour answers from cache at once; one that needs a fresh screen takes one of 4 screening slots shared by every keyless caller, and waits up to 10 seconds for one. Past that the check gets 503 keyless_busy with Retry-After, and it is not counted against your hourly allowance. More than 2 of your own checks waiting at once get 429 concurrency_limit: send the next when one returns. Under unusual load the keyless check can be paused (503 keyless_paused, with Retry-After). None of this touches the keyed lane, which runs separately. The IMO goes in the path as 9811000 or IMO9811000, the forms POST /api/v1/screenings accepts; a value without exactly one 7-digit number in it gets 400 invalid_imo, whose message says what is wrong with the value (no digits, too few, or which valid IMO an 8-digit value contains), and it is not counted against your hourly allowance. This screens a vessel — sanctions, ownership opacity and vetting. Voyage scoring is a different endpoint and needs a key.
curl https://arcnautical.com/api/v1/vessels/9169067/check
const res = await fetch(
'https://arcnautical.com/api/v1/vessels/9169067/check'
);
const verdict = await res.json();
console.log(verdict.sanctions.status, verdict.ownership.opacity);
{
"imo": "9169067",
"sanctions": {
"status": "GREEN",
"detail": "No matches across OFAC SDN, OpenSanctions, EU FSD, UN Consolidated, UK OFSI.",
"coverageComplete": true,
"coverageGaps": []
},
"ownership": { "opacity": "HIGH", "score": 70 },
"vetting": { "grade": "C", "score": 41, "status": "marginal" },
"assessed": true,
"checkedAt": "2026-07-30T08:19:46.440Z"
}
Why a hull reads opaque. ownershipFactors (trimmed above) lists the lines ownership.score adds up to, each as {label, points}. Read it before treating a HIGH band as a finding: an open-register flag with no LEI record and an owner nobody can name can reach the same number for different reasons. Show the labels to a person; they are sentences, not codes. The field is null when assessed is false.
Don’t know a port’s UN/LOCODE?
Routes are addressed by UN/LOCODE. Resolve one from a port name with a second keyless GET — it covers every port the routing engine can compute against, so a code returned here is always routable. An exact code matches itself first, which makes this also the way to confirm one you already hold. A port’s official local or former name finds it too — lisboa, chattogram, antwerpen, bombay — and the result carries the name we use. Auto-select only an exact_locode or exact_name match; show anything weaker to a person.
curl 'https://arcnautical.com/api/v1/ports?query=rotterdam&limit=3'
{
"ports": [
{ "locode": "NLRTM", "name": "Rotterdam", "country": "Netherlands",
"country_code": "NL", "lat": 51.9, "lon": 4.5,
"match_score": 95, "match_type": "exact_name" }
]
}
Want a voyage score without a key? Run one in the browser playground — it uses the production scoring engine and consumes no quota. To score voyages from your own code, mint a key; it takes about a minute.
Quickstart
- Mint a key — test or live — and store it as
ARCNAUTICAL_API_KEY. - Screen a hull — every resource-creating
POSTneeds anIdempotency-Key; derive it from the question (screening:<imo>:<date>) so a retry, or a second order for the same hull today, replays the stored answer free. - Score a voyage on the same key, if you route cargo.
- Read
GET /api/v1/usagefor your environment, quota, and limits.
curl --fail-with-body https://arcnautical.com/api/v1/screenings \
-X POST \
-H "Authorization: Bearer $ARCNAUTICAL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: screening:9274446:2026-09-12" \
-d '{"imo":"9274446","customer_reference":"order-48213"}'
bash, zsh, WSL, macOS and Linux. On Windows use the PowerShell or Windows cmd tab: cmd does not continue a line with \ and passes single-quoted JSON through with its double quotes stripped, which reaches us as 400 invalid_json.
$imo = "9274446"
$day = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd")
Invoke-RestMethod -Method Post -Uri "https://arcnautical.com/api/v1/screenings" `
-Headers @{ Authorization = "Bearer $env:ARCNAUTICAL_API_KEY"; "Idempotency-Key" = "screening:${imo}:$day" } `
-ContentType "application/json" `
-Body (@{ imo = $imo; customer_reference = "order-48213" } | ConvertTo-Json)
# A 4xx throws; the JSON error body is in $_.ErrorDetails.Message
In PowerShell 5.1 curl is an alias for Invoke-WebRequest, not curl, so the cURL tab fails there before a request is sent.
curl.exe --fail-with-body -X POST https://arcnautical.com/api/v1/screenings -H "Authorization: Bearer %ARCNAUTICAL_API_KEY%" -H "Content-Type: application/json" -H "Idempotency-Key: screening:9274446:2026-09-12" -d "{\"imo\":\"9274446\",\"customer_reference\":\"order-48213\"}"
One line, and the JSON in double quotes with each inner quote written \".
const imo = '9274446';
const res = await fetch('https://arcnautical.com/api/v1/screenings', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARCNAUTICAL_API_KEY}`,
'Content-Type': 'application/json',
// Derived from the question, not the attempt: the same hull asked again
// today replays the stored record free instead of spending a unit.
'Idempotency-Key': `screening:${imo}:${new Date().toISOString().slice(0, 10)}`
},
body: JSON.stringify({ imo, customer_reference: 'order-48213' }),
// A screen with vetting enrichment can take several seconds. Do not go below 15s.
signal: AbortSignal.timeout(30_000)
});
const record = await res.json();
if (res.status !== 201) throw new Error(`${record.code}: ${record.message}`);
console.log(record.sanctions.status, record.vetting.grade); // RED E
import datetime
import os
import requests
imo = "9274446"
response = requests.post(
"https://arcnautical.com/api/v1/screenings",
headers={
"Authorization": f"Bearer {os.environ['ARCNAUTICAL_API_KEY']}",
# Derived from the question, not the attempt: the same hull asked again
# today replays the stored record free instead of spending a unit.
"Idempotency-Key": f"screening:{imo}:{datetime.date.today()}",
},
json={"imo": imo, "customer_reference": "order-48213"},
# A screen with vetting enrichment can take several seconds. Do not go below 15s.
timeout=30,
)
response.raise_for_status() # 201
record = response.json()
print(record["sanctions"]["status"], record["vetting"]["grade"]) # RED E
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.LocalDate;
var imo = "9274446";
var body = """
{"imo":"%s","customer_reference":"order-48213"}
""".formatted(imo);
var request = HttpRequest.newBuilder(URI.create("https://arcnautical.com/api/v1/screenings"))
.header("Authorization", "Bearer " + System.getenv("ARCNAUTICAL_API_KEY"))
.header("Content-Type", "application/json")
// Derived from the question, not the attempt: the same hull asked again
// today replays the stored record free instead of spending a unit.
.header("Idempotency-Key", "screening:" + imo + ":" + LocalDate.now())
// A screen with vetting enrichment can take several seconds. Do not go below 15s.
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 201) {
throw new IllegalStateException(response.statusCode() + " " + response.body());
}
System.out.println(response.body()); // "sanctions":{"status":"RED", ...}
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var imo = "9274446";
using var http = new HttpClient
{
BaseAddress = new Uri("https://arcnautical.com"),
// A screen with vetting enrichment can take several seconds. Do not go below 15s.
Timeout = TimeSpan.FromSeconds(30)
};
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("ARCNAUTICAL_API_KEY"));
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/screenings")
{
Content = new StringContent(
JsonSerializer.Serialize(new { imo, customer_reference = "order-48213" }),
Encoding.UTF8, "application/json")
};
// Derived from the question, not the attempt: the same hull asked again
// today replays the stored record free instead of spending a unit.
request.Headers.Add("Idempotency-Key", $"screening:{imo}:{DateTime.UtcNow:yyyy-MM-dd}");
using var response = await http.SendAsync(request);
var json = await response.Content.ReadAsStringAsync();
if (response.StatusCode != HttpStatusCode.Created)
throw new InvalidOperationException($"{(int)response.StatusCode} {json}");
using var record = JsonDocument.Parse(json);
Console.WriteLine(record.RootElement.GetProperty("sanctions").GetProperty("status").GetString()); // RED
{
"id": "dfa1bde9-...",
"imo": "9274446",
"vessel_name": "HS STAR",
"sanctions": {
"status": "RED",
"matches": [
{ "name": "HS STAR", "source": "ofac_sdn", "programs": ["RUSSIA-EO14024"],
"match_type": "exact_imo", "confidence_class": "confirmed", "drove_verdict": true,
"source_record_id": "47220",
"source_url": "https://sanctionssearch.ofac.treas.gov/Details.aspx?id=47220",
"listed_on": "2024-01-18",
"list_edition": { "published": "2026-09-15T00:00:00.000Z", "retrieved_at": "2026-09-15T09:08:41.312Z" } }
],
"coverage_complete": true
},
"ownership": { "opacity": "MEDIUM", "score": 40 },
"vetting": { "grade": "E", "status": "unacceptable", "detention_count": 1, "factors": [ ... ] },
"sources": [ { "source": "ofac_sdn", "status": "ok", "checked_at": "..." }, ... ],
"customer_reference": "order-48213",
"expires_at": "..."
}
Score a voyage on the same key
curl --fail-with-body https://arcnautical.com/api/v1/voyage-assessments \
-X POST \
-H "Authorization: Bearer $ARCNAUTICAL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: voyage-8c21d5f4-6ab3-4e90-9f12-7d40ae5b3c68" \
-d '{
"customer_reference": "lane-NLRTM-CNSHA-001",
"route": {
"origin": { "locode": "NLRTM" },
"destination": { "locode": "CNSHA" },
"vessel_type": "container",
"load_condition": "laden",
"speed_knots": 16,
"dwt_tonnes": 100000
}
}'
const response = await fetch(
'https://arcnautical.com/api/v1/voyage-assessments',
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARCNAUTICAL_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
'X-Request-Id': crypto.randomUUID()
},
body: JSON.stringify({
customer_reference: 'lane-NLRTM-CNSHA-001',
route: {
origin: { locode: 'NLRTM' },
destination: { locode: 'CNSHA' },
vessel_type: 'container',
load_condition: 'laden',
speed_knots: 16,
dwt_tonnes: 100000
}
})
}
);
const body = await response.json();
if (!response.ok) throw new Error(`${body.code}: ${body.message}`);
console.log(body);
import os
import uuid
import requests
response = requests.post(
"https://arcnautical.com/api/v1/voyage-assessments",
headers={
"Authorization": f"Bearer {os.environ['ARCNAUTICAL_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
"X-Request-Id": str(uuid.uuid4()),
},
json={
"customer_reference": "lane-NLRTM-CNSHA-001",
"route": {
"origin": {"locode": "NLRTM"},
"destination": {"locode": "CNSHA"},
"vessel_type": "container",
"load_condition": "laden",
"speed_knots": 16,
"dwt_tonnes": 100000,
},
},
timeout=60,
)
response.raise_for_status()
assessment = response.json()
curl --fail-with-body https://arcnautical.com/api/v1/usage \
-H "Authorization: Bearer $ARCNAUTICAL_API_KEY" \
-H "X-Request-Id: integration-check-001"
Open in Postman
The whole contract as a collection: every endpoint in resource folders, the same request bodies as this page, Idempotency-Key generated per Send, one bearer on the collection. It is derived from the OpenAPI document on every deploy and a test fails when it is stale, so it cannot drift from what the API enforces.
Open the public collection in Postman →
- On that page, Fork it into your own workspace (or Import → Link in your Postman and paste
https://arcnautical.com/developers/postman/ArcNautical-API-v1.postman_collection.json). The OpenAPI URL imports too, but arrives without the examples and the auth wiring. - Import the environment the same way —
https://arcnautical.com/developers/postman/ArcNautical.postman_environment.json— and setapiKeyto a key from Developer API. - The Public vessel check and Port lookup folders run with no key at all. Start there.
Download the collection · Download the environment
Use it from an AI assistant (MCP)
An MCP server wraps this API as tools for Claude, ChatGPT, Cursor, VS Code or any MCP client. Two of its tools — check_vessel and find_port — need no key, so an assistant with the server added can answer “is IMO 9274446 sanctioned?” with nothing configured. The rest use your key. It is served two ways.
Remote endpoint — nothing to install. https://mcp.arcnautical.com/mcp (MCP Streamable HTTP, stateless). Add it with no authentication and the two keyless tools work at once; send your key as an Authorization: Bearer header on the MCP request to unlock the rest.
- claude.ai — Settings → Connectors → Add custom connector → URL
https://mcp.arcnautical.com/mcp. - ChatGPT — Settings → Connectors → Create (developer mode) → server URL
https://mcp.arcnautical.com/mcp, no authentication. - Claude Code —
claude mcp add --transport http arcnautical https://mcp.arcnautical.com/mcp, with--header "Authorization: Bearer arc_live_..."for the keyed tools. - Cursor —
.cursor/mcp.json:{ "mcpServers": { "arcnautical": { "url": "https://mcp.arcnautical.com/mcp" } } }, plus aheadersobject for the key.
curl -s https://mcp.arcnautical.com/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"check_vessel","arguments":{"imo":"9274446"}}}'
The remote endpoint is a thin client of the API on this page: a keyless call counts against your 100-per-hour allowance (your address is forwarded, not the endpoint’s), and a keyed call is metered on your key exactly as a direct call would be. Every response carries an X-Request-Id. GET /mcp answers 405 — there is no server-to-client stream to hold — and GET https://mcp.arcnautical.com/health reports the endpoint’s version.
Local server — for clients that launch a process. Source and per-client setup: github.com/arcnautical/arcnautical-mcp (npm @arcnautical/mcp). Listed as com.arcnautical/mcp on the MCP registry and on Smithery.
{
"mcpServers": {
"arcnautical": {
"command": "npx",
"args": ["-y", "@arcnautical/mcp"],
"env": { "ARCNAUTICAL_API_KEY": "arc_live_..." }
}
}
}
Claude Code: claude mcp add arcnautical -- npx -y @arcnautical/mcp. Leave env out for the keyless tools only.
Authentication
Send the API key as a Bearer token on every authenticated request. Keys are shown once and should be stored in a server-side secret manager.
Authorization: Bearer arc_test_... # evaluation
Authorization: Bearer arc_live_... # production
A new key carries every scope below. Scopes are fixed when a key is created: to change them, create a new key and revoke the old one. A call outside the key's scopes returns 403 insufficient_scope.
| Scope | Allows |
|---|---|
vessel:screen | Hull screenings, screening batches and fleets, plus exports of screening records. self-serve |
vessel:monitor | Vessel monitors: a standing re-screen per hull, with its history and change events. self-serve |
cargo:screen | Cargo screenings, plus exports of them. self-serve |
voyage:score | Synchronous and batch assessments, plus retained-result retrieval. self-serve |
voyage:monitor | Live corridor monitors and monitor history. self-serve |
webhooks:manage | Webhook endpoint lifecycle, signed test delivery, the delivery log and redelivery. self-serve |
usage:read | Entitlement limits and current consumption. self-serve |
logs:read | Your account's API access log for the key's environment, plus exports of it. self-serve |
Coverage and confidence
Two different fields are called “coverage”, and they answer different questions. Getting them confused is the most likely way to misread a verdict, so they are documented separately here.
Sanctions-source coverage
sanctions.coverageComplete and sanctions.coverageGaps describe which sanctions sources were reachable when the verdict was computed. The sources are OFAC SDN, OpenSanctions, EU FSD, UN Consolidated, and UK OFSI. Two spellings, one meaning: the keyless /api/v1/vessels/{imo}/check uses coverageComplete / coverageGaps; the keyed POST /api/v1/screenings uses sanctions.coverage_complete / sanctions.coverage_gaps. The table below applies to both.
Not every source screens the same way, and the difference matters. A hull identifier (IMO/MMSI) can only match a record that carries one. OFAC SDN, OpenSanctions, EU FSD and UK OFSI publish vessel records. The UN Consolidated List is almost entirely individuals and entities — it designates, for instance, the DPRK shipping companies, whose “IMO number” is an IMO company number and is never matched against a hull — and it contributes at the name and ownership-chain layer. It does carry the occasional vessel designation (the Libya committee, under resolution 2146), and since 2026-09-15 those records keep their IMO, so an exact_imo match can come from un_sanctions for exactly those hulls. A source being reachable is not the same as a source being able to match the thing you asked about.
A name that equals a listed company is not evidence about a ship. Ships carry ordinary words as names, old ships carry several, and the lists carry thousands of companies, hotels and people. Since 2026-09-15 (engine_version 31) a name-only hit whose source record is a company or individual (record_type: "entity") is listed under matches[] with drove_verdict: false and does not by itself make the verdict AMBER, at any score — the same treatment a near-miss on a company name already had. A name-only hit against a vessel record still does. A genuine link to a designated company is ownership, and arrives through the ownership arm, not through the hull’s name.
| Value | Meaning | How to treat it |
|---|---|---|
GREEN + coverageComplete: true | No match, every source loaded. | Clear. |
GREEN + coverageComplete: false | No match against the sources reached; a supplementary source was unavailable. coverageGaps names it. | Not a clean pass. Treat as a distinct state and re-screen. |
AMBER | Possible match requiring review. It takes one of three shapes, and sanctions.detail says which: a name-only match (the IMO is not in the source record); an exact identifier listing in a non-designating source — a watchlist or advisory list such as Ukraine’s GUR war register, which names hulls by IMO but is not an OFAC, EU, UK or UN designation; or an exact identifier hit on an aggregated record whose designation tag the current primary list does not corroborate (possibly delisted). matches[].match_type and confidence_class carry the same distinction per record. | Review before acting. An identifier listing is about this hull; do not clear it as a name collision. |
RED | Confirmed match on a vessel identifier. | Stop. |
INCOMPLETE | No clear verdict is possible, for one of two reasons. Almost always: we could not resolve the hull’s identity from the IMO, so name-based screening never ran — send vessel_name and the same call answers. Rarely: a core source (OFAC SDN, OpenSanctions, EU FSD) could not be loaded, which is named in sanctions.coverage_gaps. | Fails closed. Never read as clear. On the keyless check it is never cached, so a retry gets a fresh attempt. On POST /api/v1/screenings it is stored and replays under the same Idempotency-Key like any other record, it is not charged (unit_charged: false, and the quota headers already reflect the refund), and sanctions.retry_after says when a scheduled fetch may change it — re-screen under a new key after that instant. |
Which listing actually carried the verdict
matches[] is unfiltered on purpose. A name collision we ruled out is still evidence a reviewer may want to see, so a RED can arrive with five entries of which four are the designations that caused it. Each match therefore carries drove_verdict:
- On a
RED—trueon the confirmed designations that carried it. Apossiblefuzzy hit on a company name sitting alongside them readsfalse: real evidence, but it confirmed nothing.sanctions.detailcounts exactly these, so the sentence and the flags always reconcile. - On an
AMBER—trueon the hits that survived our name-collision filter. A name hit whose source record carries a different IMO is a collision against a positively identified hull, and readsfalse. - On
GREENandINCOMPLETE— every row isfalse. Nothing drove the status. - Absent means not computable, never “no”. The flag is omitted when a driving record carries no stable source id to join on. Reporting
falseon the row that actually carried a RED would be worse than saying nothing, so we say nothing.
Use it to answer “which listing must I clear?” without re-deriving our logic — and keep reading every row, because what we ruled out is part of the review.
What each match cites
A match is only as defensible as the entry it points at. Since 2026-09-15 every row of matches[] carries the four things a compliance file needs to verify it at the source and defend the decision without repeating our search:
| Field | What it is | Per source |
|---|---|---|
source_record_id | The identifier the source assigns to the record — the one you type into its own search. | OFAC: the SDN entry number (49248). UN: the permanent reference number (LYe.006), which the Security Council’s own notices cite. UK: the OFSI Unique ID (DPR0075). OpenSanctions: the entity id (NK-…). EU: it depends on which of the two EU files the record came from — the EU reference number (EU.4198.83) on a record from the Commission’s consolidated financial sanctions file, which we read directly; the OpenSanctions entity id on an Official Journal record (the Annex XLII vessel designations), which reach us through that mirror. |
source_url | The record’s own page at the source. Absent — not null, not a list-level link — where the source publishes none. | OFAC sanctions search, the OpenSanctions entity page, the FCDO UK Sanctions List search (/designations/<id>/<Ship|Entity|Individual>), and for an EU financial-list record the Official Journal act that lists it, on EUR-Lex. The UN consolidated list has no per-entry page, so a UN match carries no URL. Every URL shape was fetched before it was written down, and our deploy gate fetches every URL a RED emits. |
listed_on | The date the record went on the list, YYYY-MM-DD, as the source states it. | OFAC’s entry-creation date (from the SDN advanced XML — the CSV never carried one), the UN’s LISTED_ON, OFSI’s Date Designated, the earliest listing date any authority’s designation states on an OpenSanctions entity, the EU’s own designation date on a financial-list record. null means the edition we consulted states no date for this record. It never means “not listed” — the match is still a match. |
list_edition | Which edition of the list the match was found in: published is what the source stamps on the file, retrieved_at is when we took the copy we screened against. | OFAC’s DateOfIssue, the UN’s dateGenerated, the FCDO report date, the OpenSanctions file’s Last-Modified, the Commission’s generation date on the EU financial sanctions file. It is per record, not per source: eu_sanctions is two files (the Commission’s export, regenerated on amendment, and the daily mirror of the Official Journal) and each match names the one it was found in. sources[].checked_at collapses these into one value with a basis and answers “how current?”; this answers “which file?”. |
Records stored before 2026-09-15 replay without these fields: a stored screening is evidence and is never rewritten. Nothing about the verdict changed — the same match now says where it came from.
INCOMPLETE is not a soft GREEN. It means we could not answer the question. An integration that folds it into a pass has turned a known unknown into a silent clear — the exact failure a screening API exists to prevent.assessed: false means the hull was not assessed, and the ownership and vetting values beside it are null — grade: null, score: null, opacity: null — never a stand-in band. There is no default letter to mis-render; if your client requires a value, render “not assessed”. (Until 2026-09-05 these carried fail-open defaults — grade C, score 50, opacity MEDIUM — and were read as findings; records stored before then replay as they were.)
Incident-context freshness
currentContext.coverage is a different axis, and it exists only on the keyless /api/v1/vessels/{imo}/check: it describes how recently incident context was refreshed for that hull — fresh, refreshing, stale, or unavailable. It says nothing about which sanctions lists were reached, and it is not a field of the keyed screening.
Reproducibility
Every assessment carries methodology_version. A score is reproducible against the methodology that produced it, which is what makes a stored result defensible in an audit months later. Pin it alongside the score in your own records. A screening carries the same field for its methodology family and, since 2026-09-15, engine_version — the integer the changelog cites when a rule changes (32 today) — so two records of one hull tell a model change from a vessel change.
Test and live environments
| Key | Data | Quota | Monitoring |
|---|---|---|---|
arc_test_... | Real route-risk computation | Separate test bucket | Cannot create active corridor monitors |
arc_live_... | Real route-risk computation | Separate live bucket | Available with scope and entitlement |
The environment is selected by the issued key, not by changing the base URL. Resources, usage, batches, monitors, and webhook endpoints are isolated by environment.
Assessment contract
Routes use uppercase five-character UN/LOCODEs. If you don’t have one, resolve it first with GET /api/v1/ports?query=rotterdam — no key required. Vessel assumptions are optional and belong inside the route object.
Required
route.originroute.destination
Optional
customer_referenceroute.via, up to 20 coordinates- vessel type, load, speed, DWT, fuel, bunker price
Observed response shape
{
"id": "76e0b22d-d46d-4f76-82fa-0b54341ce1fb",
"customer_reference": "lane-NLRTM-CNSHA-001",
"route": {
"origin_locode": "NLRTM",
"dest_locode": "CNSHA",
"via_waypoints": [],
"distance_nm": 10082.3,
"geometry": { "type": "Feature", "geometry": { "type": "LineString" } }
},
"score": 80,
"risk_level": "high",
"drivers": [
{
"source": "jwc_listed_area",
"label": "JWC Listed Areas",
"summary": "...",
"event_count": 1,
"signal_score": 82,
"weight_pct": 15,
"contribution_pct": 18,
"contribution_band": "critical",
"sets_floor": true,
"relative_rank": 1
},
{
"source": "cii",
"label": "Country instability",
"summary": "...",
"event_count": 1,
"signal_score": 74,
"weight_pct": 12,
"contribution_pct": 13,
"contribution_band": "medium",
"sets_floor": false,
"relative_rank": 2
}
],
"jwc_intersected_areas": [
{ "id": "jwc-red-sea", "name": "Southern Red Sea", "risk_level": "war", "route_fraction": 0.118 }
],
"eez_transit": [
{
"country_code": "YE",
"country_name": "Yemen",
"risk_category": "sanctioned",
"route_fraction": 0.043,
"sanctions": { "is_sanctioned": true, "regimes": ["EU", "UNSC"] }
}
],
"score_floor": {
"basis": "listed_area_active_hostility",
"value": 80,
"explanation": "Route transits Southern Red Sea — a JWC listed area with active hostile activity on this track.",
"weighted_score": 61
},
"confidence": 1,
"missing_sources": [],
"sources": [
{ "source": "sanctions", "status": "ok", "checked_at": "2026-07-16T02:50:00.000Z", "basis": "published_at" },
{ "source": "piracy", "status": "ok", "checked_at": "2026-07-16T16:05:00.000Z", "basis": "refreshed_at" },
{ "source": "weather", "status": "ok", "checked_at": "2026-07-16T17:12:04.000Z", "basis": "refreshed_at",
"message": "Open-Meteo last delivered a forecast to ArcNautical at this time (source ledger); the forecast itself carries no model-run timestamp, and a cached forecast in this response may be older." },
{ "source": "cii", "status": "ok", "checked_at": null, "basis": "unknown",
"message": "Computed from static country risk data; carries no refresh timestamp." }
],
"source_status": { "piracy": "ok", "weather": "ok" },
"methodology_version": "voyage_risk_external_v1",
"assessed_at": "2026-07-16T17:40:00.000Z",
"expires_at": "2026-08-15T17:40:00.000Z",
"disclaimer": "..."
}
Every sources[] row that reads ok says when its source last answered, and basis says what that timestamp means: published_at is the date the source stamps on its own data (OFAC’s DateOfIssue), refreshed_at is when we last fetched it, latest_event_at is the newest record we hold (a quiet week and a dead feed look identical, which is why it is named), and build_constant dates the deploy. A provider that stamps nothing on its data — the weather forecast — is dated by our source ledger, the record of every fetch we make: refreshed_at is then the last time that provider answered us, and the message says so. When a source is unavailable on an assessment its row still carries the last time it answered, so you can tell an outage of an hour from one of a month. Only cii, computed from compiled-in country data, and the bundled JWC listing carry no timestamp, and each says why in message.
score_floor appears only when a categorical fact set the score instead of the weighted signals — a Joint War Committee listed-area transit, a call at a port in a JWC Named Country, or a transit of a sanctioned state's EEZ. When it is present, score is deliberately not reproducible from drivers: the drivers are a weighted average, and an average cannot represent "this voyage crosses a war-risk listed area". basis is one of listed_area_transit, listed_area_active_hostility, named_country_port_call or sanctioned_eez_transit; listed_area_active_hostility means live feeds place hostile activity — a hostile navigational warning, an attack, or AIS/GPS disruption — inside the listed area the route crosses. Use score for the verdict and weighted_score to rank two lanes that both floored into the same band.
Retrieve the same customer-owned result with GET /api/v1/voyage-assessments/{id} until its expires_at timestamp.
Screen a hull, then watch it
The keyless check you ran at the top is the demo — 100 requests an hour per IP, verdict only. POST /api/v1/screenings runs the same engine on your key and returns what that engine already computed and the free endpoint throws away: every sanctions match with its confidence class, the five weighted vetting factors, and per-source freshness.
curl -X POST https://arcnautical.com/api/v1/screenings \
-H "Authorization: Bearer $ARCNAUTICAL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3f7c1e2a-9b04-4d1e-8a77-2c5f0b6d1e93" \
-d '{"imo":"9811000","customer_reference":"fleet-7"}'
What you can send
Required
imo—9811000,"IMO9811000"or"IMO 9811000". An 8-digit value gets a422naming the IMO it most likely contains (below).
Optional
vessel_name— the fix forINCOMPLETE.customer_reference— echoed back on the result.include_vetting—falseskips the A–E grade.
INCOMPLETE for identity, you can send vessel_name for an answer now — or do nothing, and the answer comes to you (submit once). We resolve identity from an IMO registry, flag tables and port-state-control records. When a hull is in none of them — new-builds and recent renames especially — we hold no name, so the name-matching half of sanctions screening cannot run and the verdict is INCOMPLETE rather than a clearance we cannot defend. A name you can confirm closes that gap immediately:
-d '{"imo":"1019826","vessel_name":"NORDIC ASTREA","customer_reference":"fleet-7"}'
INCOMPLETE is not charged — unit_charged: false — and an immediate identical retry cannot produce a different answer. But asking about a hull we cannot name puts it at the head of our next scheduled port-State fetch, and sanctions.retry_after is the instant by which that fetch will have run. You need not act on it: the record carries a resolution block, we re-screen the same request for you on a bounded schedule, and the verdict arrives as a new screening linked to this one — see submit once. When retry_after is null, every scheduled fetch has already asked about this hull and found nothing; the scheduled re-screens then wait for a store to gain a row, and vessel_name is the only way to answer now.INCOMPLETE on a number that looks perfectly well-formed, neither is charged, and neither is something a later retry can change — retry_after is null on both.
- The check digit. The seventh digit is a checksum over the first six (IMO res. A.1078(28)), so a number that fails it cannot belong to any vessel — the single most likely error being a transposed digit off a nomination. We screen it as a bare identifier, we do not screen it as a name, and the response carries
imo_check_digit_valid: false.1111111is the common example. - Conflicting identity. Our AIS registry is keyed on MMSI, so one hull legitimately appears under several rows — a rename, a reflag, a new transponder — and the most recent name wins. But an AIS identity is self-reported: nothing upstream verifies that the vessel broadcasting an IMO owns it. When many unrelated vessels claim the same number, our own data no longer identifies the hull, and we return no name rather than the most recent claimant.
1234567is claimed by fifteen different vessels in our registry, among them a website and a piece of test equipment. Memorable numbers behave this way; real ones do not.
1234567 is telling you nothing you can rely on for a real hull. If you genuinely mean a vessel we refused to name, send vessel_name and the answer is returned as identity_basis: "user".GET /api/v1/fleets/{id}/changes— put the hulls in a fleet and ask what moved since a cursor, instead of re-screening the whole book.POST /api/v1/vessel-monitors— a standing subscription per hull. We re-screen on our schedule and you are told only when a verdict actually changes. See the Vessel monitors — stop polling section below.
INCOMPLETE needs no re-screen from you at all — it re-screens itself.About Idempotency-Key. Every POST requires one, and it must be unique per distinct request — any string will do: a UUID, your own order id, a hash of the payload. Reusing a key with a different body is refused with 409 idempotency_conflict, which is the whole point: it stops a retry from quietly creating something you did not intend. Generate it however your language does (crypto.randomUUID(), uuid.uuid4(), UUID.randomUUID()). Note that shell examples often reach for $(uuidgen) — that command does not exist on Windows or on minimal Linux images, where it expands to nothing and sends an empty header, so the request is refused for a header you did in fact send.
Choose the key deliberately — it decides what you pay. A replay under a key we have already answered returns the stored response with X-ArcNautical-Idempotent-Replay: true, and does not spend a unit; the key is kept for 24 hours. So the key is not only a safety mechanism, it is your deduplication lever. If you generate a fresh UUID per attempt, two questions about the same hull on the same day are two questions and you are billed for both. If you derive the key from the thing you are asking about — screening:9468293:2026-09-09 — the second and every later ask that day is free and instant. Which you want depends on the call: a genuine re-check after a list update needs a new key, while the same hull screened again because a second order came in for it does not. On one integration we measured, 42% of a month’s screenings were same-day repeats of a hull already answered — every one of them billable only because the client generated a random key. The examples below use randomUUID() because it is the safe default for a one-off write; for a high-volume gate, derive it.
Do not need the A–E grade? Pass "include_vetting": false and you get the sanctions verdict and cargo screen alone. This is worth knowing if you run volume: the vetting and ownership enrichment is the only part of a screen that depends on a shared upstream, and it is the part that degrades when many screens run at once. Opting out removes your calls from that queue entirely, so the answer is faster and does not thin out under your own concurrency. The response says so rather than going quiet — vetting.status and the vessel_vetting row in sources[] both read not_requested, which is deliberately distinct from error: one means we tried and could not, the other means you told us not to, and only the first is worth retrying. Sanctions coverage is identical either way. Set it per hull, or once on a screening-batches request to apply to all of them.
Screening is metered on its own allowance — 2,000 live screens on a new account, once (a one-time allowance), and 500 test screens a month — deliberately separate from voyage assessments so a fleet screen never silently drains your scoring quota. Both counters are reported side by side at GET /api/v1/usage. A screen typically takes 1–4 seconds and answers within about seven: the sanctions lists are read locally, and the vetting and ownership enrichment — the only part that reaches outside — is given the remainder of that window. If it cannot finish, the verdict still arrives on time and sources[].vessel_vetting reads error with a re-screen instruction; the lookups it started complete into our caches, so the re-screen has them.
Three fields to read before you act on a result
sanctions.statushas a fourth value the marketing copy does not:INCOMPLETE. It means a mandatory list, or the vessel's identity, was unavailable — so no clearance can be defended. It is not a clean result and must not be rendered as one. Readresolutionbefore retrying: anINCOMPLETEis never charged, and when the block is present the hull is re-screened for you —next_attempt_atis the first slot,expires_atthe last, and the verdict arrives as a new record (submit once).sanctions.retry_afteris that first slot when a fetch we set in motion can name the hull;nullmeans the scheduled re-screens wait for a store to gain a row and onlyvessel_nameanswers now. Whenresolutionis absent on anINCOMPLETE, nothing we do can change it (a failing check digit, a contested number) and the notices say which.sources[]tells you which lists were actually read and when.status: "error"means that list did not load. A source we could not read is not a source that came back empty, and a GREEN built on unread lists is not a clear result — cross-checksanctions.coverage_gaps.vetting.assessed: falsemeans the hull was not graded:gradeandscorearenull, never a stand-in letter. Render “not assessed”.vetting.score_floor, when present, means a categorical fact set the score and the fivefactorsreproduceweighted_scoreinstead. Two bases:confirmed_sanctions_match(an exact IMO/MMSI designation floors the composite at 90, grade E) and, since 2026-09-13,watchlist_exact_imo(the hull is listed by IMO on a non-designating watchlist such as Ukraine’s GUR register — the same listing that raises theAMBER— and the composite is floored at 61, grade D). A factor withobserved: falsecarriesweight_pct: 0and moved nothing; the grade reproduces from the observed factors alone.
What we looked in, when we could not name the hull
identity_basis: "none" says we could not name the hull. It does not say what we read — and one sentence cannot carry two opposite facts: every register answered and none lists this hull, versus a register was unreachable and we do not know. Those have opposite retry policies, and treating the second as a clean look is how a false clearance gets written into a file. So every screening carries identity_sources[]:
"vessel_name": "FOLK DAMMAM",
"identity_basis": "sources",
"identity_sources": [
{ "register": "arcnautical_vessel_register", "named": false, "reachable": true },
{ "register": "ais_registry", "named": false, "reachable": true },
{ "register": "tokyo_mou_psc", "named": false, "reachable": true },
{ "register": "paris_mou_thetis", "named": false, "reachable": true },
{ "register": "apcis_tokyo_mou_history", "named": false, "reachable": true },
{ "register": "dnv_vessel_register", "named": true, "reachable": true, "name": "FOLK DAMMAM" },
{ "register": "itf_ship_lookup", "named": true, "reachable": true, "name": "FOLK DAMMAM" },
{ "register": "iacs_vessels_in_class", "named": true, "reachable": true, "name": "VEGA COLIGNY" },
{ "register": "uscg_psix", "named": false, "reachable": false }
]
The first five are stores we hold, read on every screening. The last four — DNV’s vessel register, the ITF ship lookup, the IACS vessels-in-class file and USCG PSIX — are public registers we read live, inside the request, only when all five stores are silent, in parallel and under a time budget of a few seconds. A hull a store names never waits on them — and a name one of these registers returns is retained in our own register, so a hull they named once is answered from the first store on every later screening and is never again exposed to that register’s availability (retained for 30 days, then re-read so a rename is not frozen). named is true for every register that returned a usable name, and name on that row is what it said: 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 the response screens the hull under every name returned, vessel_name is the one we chose, and a sanctions match on one of the others carries screened_name so you can see which name it matched. reachable: false on a store means the lookup threw — that register is absent or down; on a live register it means it did not answer inside the budget, its lookup is finishing in the background, and sanctions.retry_after says when to re-ask. Either way its silence is not evidence of anything, and the identity_unresolved notice says so in prose too. The list is recorded from what the resolver actually did, so a register we stop using stops appearing rather than being claimed forever.
And asking is not pointless — it is what sets the answer in motion. Our port-State harvest is ordered by what customers actually screen, so a hull we cannot name enters that queue by being asked about; a live register that outran the request’s budget finishes its lookup in the background. sanctions.retry_after (repeated on the identity_unresolved notice) is the instant by which whichever of those was set in motion will have run, and the notice says which. Until 2026-09-15 the next step was yours: re-screen under a new key after that instant. It is not yours any more.
Submit once — an INCOMPLETE resolves itself
An INCOMPLETE we can do something about enrols itself for background re-screening the moment it is written. The record carries a resolution block that says so, in that hull’s own instants:
"sanctions": { "status": "INCOMPLETE", "retry_after": "2026-09-15T13:30:00.000Z", … },
"unit_charged": false,
"resolution": {
"status": "pending",
"cause": "identity",
"attempts": 0,
"max_attempts": 6,
"next_attempt_at": "2026-09-15T13:30:00.000Z",
"expires_at": "2026-10-15T09:52:41.118Z",
"resolved_screening_id": null,
"webhook_event": "screening.resolved"
},
"superseded_by": null
From then on the same request — same include_vetting, same customer_reference — is run again by us, on a bounded schedule, with nothing to send on your side. cause says what it waits on and picks the schedule: identity (no source we hold or read live names the hull; a store gaining a row is what answers it, so the attempts spread over up to 30 days) or coverage (a sanctions list did not load; outages end in hours, so the attempts are denser and give up after 7 days). The first slot is sanctions.retry_after when the record carries one, because that instant is our own promise about a fetch this ask queued.
When a verdict can be given, it is written as a new screening — its own id, its own screened_at, identity_sources[] showing which register finally named the hull — with supersedes naming the record it answers. The original is never rewritten: it is evidence, and an idempotent replay returns it exactly as it was. Instead, GET /api/v1/screenings/{id} on the original overlays what is true now: superseded_by carries the new id and resolution.status reads resolved. The list does the same per row, so GET /api/v1/screenings?status=INCOMPLETE&superseded=false is exactly “which of my questions are still open”. A fleet’s /changes sees the new record as a change on that hull, like any other.
To be told rather than to poll, subscribe to screening.resolved (POST /api/v1/webhook-endpoints with it in event_types, or leave event_types empty for everything). Its data carries screening_id (the answer), supersedes_screening_id (your question), imo, customer_reference, vessel_name, identity_basis, sanctions_status, vetting_grade, ownership_opacity, screened_at and unit_charged. A pending follow-up never ends in silence: when the last slot passes still INCOMPLETE, resolution.status reads unresolved and one screening.unresolved event carries the remedy — for an identity gap, the name you hold; for a coverage gap that outlived a week, our outage and a later re-screen.
Any verdict closes the promise, whoever asked for it. If you re-screen the hull yourself with vessel_name, or a batch item or a vessel monitor screens it to a verdict first, that record carries supersedes, the original gains superseded_by, and screening.resolved fires the same way; the background attempts stop. One open follow-up exists per hull per account, so a hull asked ten times while blind has one re-screen in flight, pointed at the newest record, and re-asking cannot extend the schedule.
What it costs. Nothing while it stands INCOMPLETE, and one screening unit when the verdict is delivered — a unit buys a verdict, and this is the one you asked for. If your allowance is exhausted when a slot comes due, the attempt waits for the reset rather than counting against the schedule. A record from before 2026-09-13, when an INCOMPLETE was still charged, resolves free: that verdict was already paid for. Every INCOMPLETE your account held from the last 30 days was enrolled when this shipped, so questions asked before it exist are answered by it too.
Not enrolled, deliberately, and resolution is absent to say so: a hull whose IMO fails its check digit (no register will ever name a number that cannot belong to a vessel), and a contested number (identity_contested — re-reading the registry tomorrow finds the same fifteen claimants). Both remedies are in your data, and the notice on the response says which.
The response tells you what you left on the table
Nobody reads this page twice. So the screening response carries a notices[] array that says, in your account’s own numbers, what this request could have done better — and only when it could have. A clean first screening under quota has an empty array. It is never padded with standing advice, which means a non-empty array is worth opening. There are eight codes, each with the fact in message and the change to your next request in remedy:
imo_normalized— only on an account where the appended-digit correction is enabled (below). Theimoyou sent was eight digits, a valid IMO with one digit appended, and this screening is for the seven-digit IMO it contains.imo_normalized_fromon the record carries what you sent. The remedy is to fix the trailing digit in your source data, and to screen the correct IMO if the one we screened is not the hull you meant.repeat_screening— your account screened this hull inside the last 24 hours and this request carried a differentIdempotency-Key, so it ran again and spent a unit.previous_screening_idnames the earlier record; the remedy is the derived key that would have replayed it free. If this screening came backINCOMPLETE, the notice instead names the earlier record as the last complete answer you hold and gives no key advice (a derived key would replay the non-answer); it is omitted when the earlier record wasINCOMPLETEtoo.identity_unresolved— no source we hold or read live names this hull and the request carried novessel_name, so nothing was screened and nothing was charged. An immediate identical retry cannot change the answer; the notice carriesretry_afterwhen a scheduled fetch can, and says so when none can. The remedy for an answer now is the request with the name you hold; when the record enrolled for automatic re-screening the remedy says nothing further is needed otherwise — two notices never give opposite advice.auto_resolution— thisINCOMPLETEenrolled for automatic re-screening. The message states the first scheduled slot, the number of attempts, the give-up instant, where the verdict will appear (a new screening whosesupersedesnames this one;superseded_byon this record; thescreening.resolvedevent) and what it costs (one unit, on the verdict).next_attempt_at,expires_at,max_attemptsandwebhook_eventare repeated on the notice fromresolution. The remedy is the subscription, or the poll, orvessel_namefor an answer now.identity_contested— this IMO does not identify a single vessel in our registry: we hold four or more different vessel names recorded against it. Past that point a number is one that gets entered on forms rather than one that belongs to a hull —1234567is the common case, and it passes its own check digit, which is exactly why it survives validation and flows down a pipeline. We decline to name it rather than pick one, because a screening run against the wrong name returns a CLEAN answer that means nothing. This fires instead ofidentity_unresolved: the remedy is to check where the number came from in your own data, because sending a name buys one answer and leaves whatever produced it emitting the same number tomorrow.quota_pace— at the trailing seven days’ rate, the month’s allowance runs out before it resets. It carries the projected date, the measured per-day rate, and the counts behind both. This is a projection, not a refusal — nothing is blocked — and it exists becausequota_warningis a level: a level arrives days before the wall, and raising an allowance is an internal conversation on your side, not a same-day switch. It goes quiet oncequota_warningstarts (one fact, one notice) and stays quiet under three days of history, so a new account cannot project a cliff from one busy afternoon.quota_warning— at least 80% of the month’s live screening allowance is used. The same threshold fires theusage.quota_warningwebhook event; this is the copy for accounts with no webhook endpoint, which is most of them. On a one-time allowance it counts the total since it started andquota.resets_atisnull;quota_pacestays quiet there, because there is no reset to arrive before.enrichment_latency— this screening took six seconds or longer to answer, measured from when we received the request, with vetting and ownership enrichment included, which is the default. Enrichment is the part that reaches external sources and is where the time varies; the sanctions verdict does not depend on it. If you act onsanctions.statusalone,include_vetting: falsegives you the identical verdict without the wait.
What one looks like, on the second screening of a hull seven minutes after the first:
"notices": [{
"code": "repeat_screening",
"message": "Your account screened IMO 9683001 7 minutes ago (screening 921e800e-…, GREEN), and that record is still retrievable at GET /api/v1/screenings/921e800e-…. This request carried a different Idempotency-Key, so it ran again and spent a unit; a repeat under the same key returns the stored result at no cost. This month your account has screened 447 hulls 840 times.",
"remedy": "Derive the Idempotency-Key from the question rather than the attempt — for example screening:9683001:2026-09-10 — so the same hull asked again on the same day replays free. Use a new key only when you want the lists re-read.",
"docs": "https://arcnautical.com/developers#idempotency",
"previous_screening_id": "921e800e-…",
"previous_screened_at": "2026-09-10T00:40:01.412Z",
"previous_status": "GREEN"
}]
Notices are stored with the screening and replayed with it, so a record that spent a unit on a hull answered minutes earlier says so for as long as the record exists. Screenings from before 2026-09-10 have no notices key; read absent as empty. Batch and monitor screenings carry [] — no caller is choosing a key there.
A hull whose IMO fails its check digit is still screened, and the response says so via imo_check_digit_valid: false. That asymmetry is deliberate: dark-fleet vessels routinely carry fabricated IMOs that are nonetheless the identifier they are listed under, so refusing them at the door would decline to screen exactly the hulls worth screening. An input with no 7-digit core has one of three shapes: too few digits, more than one 7-digit number in the same string (a hull and its ex-IMO, say), or a contiguous run longer than seven such as 96991891. That last one is the common one, and it is usually a real hull with a stray character.
We refuse it rather than trimming it: an eight-digit value has two possible readings, and answering the wrong one would return a sanctions verdict on a vessel you did not ask about. The 422 does the next best thing. Where exactly one reading carries a valid IMO check digit, the field_violations message names it, so the correction is obvious rather than a support thread:
"96991891" is not a single 7-digit IMO number. "96991891" is 8 digits,
1 more than an IMO number. 9699189 is a valid IMO number and is contained
in it, so this looks like 9699189 with an extra digit.
Per account, since 2026-09-25: a pipeline that appends one digit. Where an account’s own traffic shows its pipeline appending one digit to valid IMOs, we enable a correction for that account. When the value is exactly eight digits, its first seven pass the IMO check digit and its last seven do not, we screen the first seven and say so on every response: imo is the IMO we screened, imo_normalized_from is the value you sent, and the imo_normalized notice says the same in words. Screenings and batch items both do this (a batch record carries imo_normalized_from; batch notices are always empty). It is per account because the evidence is: for a pipeline we have watched append the same digit to dozens of valid IMOs, the correction fixes a measured defect. For an account we know nothing about, one mistyped digit could put a verdict on another hull. So every other account gets the 422 above for the same value. A leading extra digit, an eight-digit value whose two readings are both valid IMOs, and nine digits or more get the 422 on every account, and so do monitors, fleets and filters. If you think your integration qualifies, the refusals in GET /api/v1/access-log are the evidence we would look at.
POST /api/v1/screenings {"imo": "96991891"} # on an account with the correction enabled
201 "imo": "9699189", "imo_check_digit_valid": true, "imo_normalized_from": "96991891",
"notices": [{"code": "imo_normalized", ...}]
Where both readings are valid IMOs, the message names both and picks neither, so you send the one you meant. Choosing one for you would be a guess about a hull identity, which is the one thing this endpoint must never do:
"92377471" is not a single 7-digit IMO number. "92377471" is 8 digits,
1 more than an IMO number, and 2 different valid IMO numbers are contained
in it (9237747, 2377471). Send the one you meant.
For a fleet, POST /api/v1/screening-batches accepts up to 50 hulls and answers 202. That is a consequence of the timing above, not a preference — 50 sequential screens cannot fit in a request. Poll the batch or subscribe to screening_batch.completed.
The audit trail you can read back
Three endpoints exist so you never have to take our word for what happened.
GET /api/v1/screeningsis your screening history, newest first. Filter by date to answer “everything we screened in Q3”, or bycustomer_referenceto find the screening behind one order. Screening evidence is retained ten years, because sanctions screening records are subject to a ten-year recordkeeping rule — your audit record has to outlive your subscription.GET /api/v1/access-logis every authenticated request made with a key on your account: method, path, status, the machineerror_codeon a refusal, source address, which key, and therequest_idyou were given at the time. Refusals included — usually the half worth reading. 365 days,logs:readscope.GET /api/v1/webhook-deliveriesis every event we tried to send you and what happened to it.?status=deadfinds events you never received after all five attempts, andPOST /api/v1/webhook-deliveries/{id}/redeliverqueues a fresh one. A replay is recorded as a new delivery; the failed original is left exactly as it ended, because that is the part of the trail a successful replay must not erase.POST /api/v1/exportsturns any of the three into one file you can archive. See below.
Exports — one file, not a paging loop
An auditor asking for “everything we screened in Q3” wants an artifact to put in an evidence folder, not 200 pages to reassemble. POST /api/v1/exports starts a job and answers 202 with an id; the file is usually ready inside a minute.
curl -sS https://arcnautical.com/api/v1/exports \
-H "Authorization: Bearer $ARCNAUTICAL_API_KEY" \
-H "Idempotency-Key: b5490ac7-1d62-4f38-ae05-9c73f28d641b" \
-H "Content-Type: application/json" \
-d '{
"resource": "screenings",
"format": "csv",
"filters": { "created_after": "2026-07-01T00:00:00Z", "created_before": "2026-10-01T00:00:00Z" }
}'
# poll, then download
curl -sS https://arcnautical.com/api/v1/exports/$ID \
-H "Authorization: Bearer $ARCNAUTICAL_API_KEY"
curl -sS -OJ https://arcnautical.com/api/v1/exports/$ID/download \
-H "Authorization: Bearer $ARCNAUTICAL_API_KEY"
Three resources: screenings (vessel:screen), cargo_screenings (cargo:screen) and access_log (logs:read). An export is governed by the same scope that governs reading those records one at a time, checked again at download — it is a different shape of the same read, never a way around its permission.
An export reserves no quota and screens nothing. It reads records you already hold.
Four behaviours worth knowing before you build on it:
- Too large is a refusal, not a truncation. The rows are counted before the job is accepted, and a range over 500,000 rows comes back
422 export_too_largenaming the real number. We will not hand you a file that is silently short — everyone downstream of whoever read the flag would treat it as complete. created_beforeis pinned. Leave it out and it is set to the moment of your request and returned infilters. Without that, a job picked up a minute later would sweep in rows written in between, and the same “Q3” request would produce a different file tomorrow.- A filter that does not apply is rejected. Send
imowithresource: "access_log"and you get a422naming what that resource does accept, rather than a file that quietly ignored it. - The SHA-256 is published, on the export resource and in
X-ArcNautical-Export-Sha256. Years later that is how you show the copy in your evidence folder is the copy we generated.
CSV is a flat summary with a UTF-8 byte-order mark, so vessel names in Greek, Korean or Japanese survive Excel on Windows. In Python read it with encoding="utf-8-sig". A cell that begins with =, +, - or @ is prefixed with an apostrophe so a spreadsheet renders it as text rather than running it as a formula.
JSONL is one complete record per line and carries no BOM. For screenings each line is byte-for-byte what GET /api/v1/screenings/{id} returns — the full match evidence and per-source freshness that a spreadsheet column cannot hold. If the export is going into an audit file rather than a pivot table, take JSONL.
Files are downloadable for 7 days and then deleted; DELETE /api/v1/exports/{id} removes one sooner. That expiry is about the download only — the records the file was built from keep their own retention, ten years for screening evidence. Subscribe to export.completed if you would rather not poll, and to export.failed so a job that could not build tells you instead of sitting at queued.
Vessel monitors — stop polling
POST /api/v1/vessel-monitors watches one hull and fires vessel_risk.changed when its sanctions status, ownership opacity, or vetting grade moves. Re-screened every 24 hours, because that is how often the underlying lists actually publish — a shorter cadence would spend four times the quota re-reading the same file. Fifty hulls on a self-serve key, and each run costs one screening unit.
Changes are reported in both directions. If you are waiting for a vessel to clear, you need the good news too, otherwise you are back to re-checking by hand.
Two properties worth building on:
- Nothing is emitted on a single reading. Screening degrades to neutral defaults on an upstream failure rather than erroring, so one screen can flip a field during an OFAC or GLEIF outage. A change is re-screened and only the fields both screens agree on are sent. It costs an extra unit on the rare tick that matters and it is what makes the webhook safe to act on.
vessel_data.degradedfires when a screen returns INCOMPLETE, so silence never needs interpreting. If a hull is quiet and not degraded, nothing changed. While degraded the baseline is deliberately not updated — adopting an INCOMPLETE as the new reference would discard the last good verdict and hide the real change when the source recovers.
The first run emits vessel_monitor.ready, never a change event. Establishing a baseline is not a change, so a monitor created on an already-RED hull does not open with an alert about something that did not happen.
Batch workflow
POST /api/v1/voyage-assessment-batcheswith a unique idempotency key.- Poll
GET /api/v1/voyage-assessment-batches/{id}untilcompleted,partial, orfailed. - Read results from
GET /api/v1/voyage-assessment-batches/{id}/items.
Test keys accept up to the test_max_routes shown by /usage; live keys use live_max_routes. Only accepted route items reserve quota.
Endpoint guide
| Method | Path | Scope | Success |
|---|---|---|---|
| POST | /api/v1/voyage-assessments | voyage:score | 201 |
| GET | /api/v1/voyage-assessments/{id} | voyage:score | 200 |
| POST | /api/v1/voyage-assessment-batches | voyage:score | 202 |
| GET | /api/v1/voyage-assessment-batches/{id} | voyage:score | 200 |
| GET | /api/v1/voyage-assessment-batches/{id}/items | voyage:score | 200 |
| POST | /api/v1/screenings | vessel:screen | 201 |
| GET | /api/v1/screenings/{id} | vessel:screen | 200 |
| POST | /api/v1/screening-batches | vessel:screen | 202 |
| GET | /api/v1/screening-batches/{id} | vessel:screen | 200 |
| GET | /api/v1/screening-batches/{id}/items | vessel:screen | 200 |
| POST | /api/v1/vessel-monitors | vessel:monitor | 201 |
| GET | /api/v1/vessel-monitors | vessel:monitor | 200 |
| POST | /api/v1/corridor-monitors | voyage:monitor | 201 |
| GET | /api/v1/corridor-monitors | voyage:monitor | 200 |
| GET | /api/v1/screenings | vessel:screen | 200 |
| POST | /api/v1/cargo-screenings | cargo:screen | 201 |
| GET | /api/v1/cargo-screenings | cargo:screen | 200 |
| GET | /api/v1/cargo-screenings/{id} | cargo:screen | 200 |
| POST | /api/v1/fleets | vessel:screen | 201 |
| GET | /api/v1/fleets | vessel:screen | 200 |
| GET | /api/v1/fleets/{id}/exposure | vessel:screen | 200 |
| GET | /api/v1/fleets/{id}/changes | vessel:screen | 200 |
| POST | /api/v1/webhook-endpoints | webhooks:manage | 201 |
| GET | /api/v1/webhook-deliveries | webhooks:manage | 200 |
| GET | /api/v1/webhook-deliveries/{id} | webhooks:manage | 200 |
| POST | /api/v1/webhook-deliveries/{id}/redeliver | webhooks:manage | 202 |
| GET | /api/v1/access-log | logs:read | 200 |
| POST | /api/v1/exports | the resource's own scope | 202 |
| GET | /api/v1/exports | the resource's own scope | 200 |
| GET | /api/v1/exports/{id} | the resource's own scope | 200 |
| GET | /api/v1/exports/{id}/download | the resource's own scope | 200 |
| DELETE | /api/v1/exports/{id} | the resource's own scope | 200 |
| GET | /api/v1/usage | usage:read | 200 |
| GET | /api/v1/vessels/{imo}/check | none — no key | 200 |
| GET | /api/v1/ports?query= | none — no key | 200 |
This is the map, not the territory. Open the full API reference for every parameter, schema, response code, update, delete, history and pagination detail — it is rendered live from the OpenAPI 3.1 contract, so it cannot drift from what the API actually does.
Reliability contract
Idempotency
Every resource-creating POST requires Idempotency-Key. Results are replayable for 24 hours, and a replay costs no unit — so a stable, derived key (screening:<imo>:<date>) is how you avoid paying twice for the same question, while a fresh UUID per attempt bills every repeat. Reusing a key with a different body returns 409 idempotency_conflict.
A retry under a key whose first request is still running waits for it and returns its result. You timed out and retried before we finished — the right thing to do, and the retry now joins the original rather than being refused: it holds for up to 30 seconds and returns the original’s response the moment it lands, flagged X-ArcNautical-Idempotent-Replay: true and X-ArcNautical-Idempotent-Joined: true. One computation, one unit, both callers answered. Only if the original is still running after that hold do you see 409 idempotency_in_progress with Retry-After; repeat the same key and the next retry waits again. Nothing is lost and no unit is charged for a retry. If you see the 409 often, your client timeout is shorter than a screen takes — a screen typically runs 1–4 seconds with enrichment and answers within about seven, because the vetting and ownership enrichment is given only what is left of that window: when it cannot finish, the response still lands on time with the sanctions verdict, and the vessel_vetting row in sources[] reads error with a re-screen instruction rather than holding your call — and the enrichment_latency notice above names the field that removes the tail.
Retries
Retry GET/HEAD requests and idempotency-keyed POST requests after transport failures or HTTP 408, 425, 429, 500, 502, 503, and 504. Honor Retry-After; otherwise use exponential backoff with jitter. Do not automatically retry non-idempotent mutations.
Limits and tracing
- Per-key rate limit: 120 requests per minute.
- Read
X-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit-Reset. They are on every authenticated response, errors included — a429tells you when to come back, a422still tells you how much budget you have — and absent only on401, where there is no key to count against. - Assessment and screening responses include
X-ArcNautical-Quota-Limit,X-ArcNautical-Quota-Remaining, andX-ArcNautical-Quota-Resetfor the allowance that call drew on. On a screening that came backINCOMPLETEthe remaining figure is after the refund. On a one-time allowance there is no reset, soX-ArcNautical-Quota-Resetis absent. - Send a unique
X-Request-Idand retain the returned value for support.
Contract stability
/api/v1 is stable and breaking changes ship as /api/v2, never in place. Additive fields can arrive at any time, so ignore fields you do not recognise; anything breaking gets 90 days notice, published in the changelog and carried as Deprecation and Sunset response headers. Read the stability policy and changelog.
Error handling
All API errors use the same envelope. Validation errors add field_violations, and each violation names the field and the call that resolves it.
{
"code": "validation_failed",
"message": "Request validation failed.",
"request_id": "req_6f3d...",
"field_violations": [
{ "field": "route.destination",
"message": "UN/LOCODE is required. Look one up at GET /api/v1/ports?query=rotterdam (no key needed)." }
]
}
A five-letter code we don’t carry is a validation_failed violation on the offending side — route.origin or route.destination — not a routing error, and it costs no assessment unit. route_not_found means something narrower: both ports are known, but no sea route connects them.
| Status | Meaning | Action |
|---|---|---|
| 400 / 422 | Invalid JSON, headers, or route fields | Correct the request; do not retry unchanged. |
| 401 | No key, a malformed key, a key whose prefix does not match its environment, or a key that is revoked, expired or on an inactive entitlement | No key yet: they are free and self-serve at arcnautical.com/get-a-key — the message carries the same link. Revocation takes effect on the next request. |
| 403 | The key is valid but may not do this: a missing scope, a source address outside its allowed_ips, or an operation its environment does not allow (e.g. monitors on a test key) | Read code. insufficient_scope names the scope. Scopes are fixed when a key is created: create a new key (new keys carry every scope), then revoke the old one. |
| 402 | Monthly screening or assessment allowance used up, or a one-time total | The refused request is not metered. Read the message — it states used/limit, the reset instant, and what still works. Retry-After is the seconds until reset; on a one-time allowance, which does not reset, there is neither. |
| 409 | Idempotency or resource-state conflict | Inspect code. idempotency_in_progress — repeat the SAME key after Retry-After. idempotency_conflict — the key was reused with a different body; do not retry, use a new key. |
| 429 | Rate or concurrency limit | Honor Retry-After. |
503 api_paused | The API is paused for a short while: an operator lever against abuse, rarely pulled. Nothing ran and nothing was charged. | Honor Retry-After, then send the same request again. |
| 500 / 503 / 504 | Temporary server, edge, or upstream failure | Retry only when the request is replay-safe. |
When the monthly allowance runs out
A 402 quota_exhausted is the one refusal you cannot retry your way out of, so its message carries everything needed to act on it rather than a label: how many of the allowance are used, the exact instant it resets, and the three things that keep working. Retry-After is the seconds remaining until reset.
The refused request is never metered. A 402 means nothing ran and nothing was charged — there is no partial state to reconcile.
Three things still work while you are over the allowance:
- A replay is free. A retry carrying an
Idempotency-Keythis account has already been answered under returns the stored response and is not metered — before the allowance check, so it works even at zero remaining. This is the same mechanism described under Idempotency, and it is the reason to derive keys from the question (screening:9169067:2026-09-11) rather than from the attempt. - Reads keep serving.
GET /api/v1/screenings,GET /api/v1/screenings/{id},GET /api/v1/voyage-assessmentsandGET /api/v1/voyage-assessments/{id}return every record already produced, for the full retention window. No read is metered. - Test keys are separate. An
arc_test_key draws on its own allowance, so development is never blocked by live consumption.
If the rest of the month needs a larger allowance, ask at Get a key and say what volume you expect.
One-time allowances do not reset
On two kinds of account the live screening allowance is a one-time total rather than a monthly one. Every account opened on or after 1 October 2026 gets its free 2,000 live screenings once (one opened before this changed on 7 October 2026 keeps the 5,000 it was given). An account running a pilot has a total set as part of the pilot. Nothing else about the API changes: test keys and voyage assessments stay monthly on those accounts too. Accounts opened before 1 October 2026 keep 5,000 live screenings a month, the allowance they signed up under.
A free allowance is paused if it is spent at a crawl’s pace. A free account that runs more than 250 screenings in any ten minutes, across live and test, is paused: the request gets a 402 quota_exhausted whose message says why, and nothing is charged for it. Real evaluations run far below that (the heaviest we have measured peaked at about 100 in ten minutes). If your integration genuinely needs a faster first run, write to [email protected] and we will lift it.
A free allowance is also paused if it is spent copying the register. When most of a free account’s last 40 screenings step through IMO numbers one after another (each within 200 of the one before), the account is paused the same way: 402 quota_exhausted, a message that says why, nothing charged. Screening a real fleet list, sorted or not, never looks like this. If yours genuinely does (a yard’s series of sister ships, say), write to [email protected] and we will turn it back on and exempt the account.
- It counts from the start.
GET /api/v1/usagereports the live screenings used since the account opened (or the pilot started), and itsresetisnull.X-ArcNautical-Quota-Remainingcounts down the same total. - Nothing states a reset.
X-ArcNautical-Quota-Resetis absent, thequota_warningnotice carriesresets_at: null, and a402at the end of the total carries noRetry-After, because waiting will not bring units back. Itsmessagesays so and says where to ask for more.
The free replay, the reads and the separate test allowance described above all still apply.
Live monitors and webhooks
Live keys with the required scopes can create retained corridor monitors. ArcNautical recomputes each corridor on the entitlement cadence and emits material-change, data-degradation, recovery, batch-completion, and quota events. Two events belong to a single screening rather than a monitor: screening.resolved, when an INCOMPLETE you submitted has been answered by a later record (data.screening_id is the answer, data.supersedes_screening_id your question), and screening.unresolved, when its schedule ran out still INCOMPLETE and the remedy is yours — see submit once. The full list is the WebhookEventType enum in the OpenAPI document; leave event_types empty to receive everything.
Webhook endpoints must be public HTTPS URLs. The creation response returns the HMAC secret once.
X-ArcNautical-Signature: t=1721149200,v1=<hex digest>
signed_payload = timestamp + "." + raw_request_body
expected = HMAC_SHA256(webhook_secret, signed_payload)
Compare signatures in constant time, reject stale timestamps, and deduplicate using the event id. Return any 2xx response promptly; delivery retries use increasing delays before becoming dead.
Data-use boundaries
- Responses contain derived scores, drivers, route context, confidence, and source-status summaries.
- Raw third-party feed payloads and internal scoring weights are not redistributed.
- The API is decision-support intelligence, not navigation, safety, legal, insurance, sanctions-compliance, or operational advice.
- Test keys are self-serve and bounded to the test environment; they do not authorize production or customer-facing use.
X-Request-Id. Never send the full API key.Guides
Worked integrations and the reasoning behind the contract, in the order most teams need them.
- A screening API where the first call needs no key
- Check a list of ships for sanctions in Python, with no key
- What the vetting grade is made of, factor by factor
- Is there an Equasis API? What to use instead
- What a screening API returns for 1234567
- Add screening to a chartering or procurement system
- OFAC ten-year recordkeeping: what a record must hold
- Add voyage risk to a supply-chain platform
- How to read a voyage-risk API response
How every verdict, score and grade is computed — sources, thresholds, weights — is on the methodology page (citable, DOI). Wire-level changes are dated on stability & changelog; every field is in the full reference.
Get an API key
Both test and live keys are self-serve and free — no card, no expiry; the allowances are the ones on the pricing page, and the full record is what the keyless check leaves out. Create an account, open Developer API, and mint one — it is shown once, so store it in a secret manager immediately.
- Create an account, or sign in.
- You land on Developer API.
- Confirm your email address. Signing in with an email link or Google already does this; after a password signup, the first Create key sends you the link. A key needs an address only you can read, so public throwaway inboxes (mailinator and similar) cannot hold one.
- Create a key with environment
testorlive.
Both environments run the same scoring engine. The environment is a partition, not a lesser service: it selects which quota bucket, idempotency namespace and webhook namespace your calls belong to. Neither is a trial and neither expires. A live key on a new account carries 2,000 screenings once: a free one-time allowance that does not reset (accounts opened before 1 October 2026 keep 5,000 a month). It also carries 10,000 voyage assessments a month. A test key carries 500 screenings and 1,000 voyage assessments a month. Webhooks work on either key. A live key also carries 10 corridor monitors, recomputed every 6 hours; each run counts against the live voyage-assessment allowance.
Or run an assessment in the browser first, with no account at all.
Need higher limits?
Tell us what you are building and we will provision it.