ArcNautical Voyage Risk API

API v1 documentation

See real output before you sign up

Score maritime routes synchronously or in batches, retain results for audit, and add live corridor monitoring. Start by running something real — no key, no account.

Operational Base URL: https://arcnautical.com JSON over HTTPS Version: v1

Try it without a key

Screen any hull by IMO with one unauthenticated GET. No key, no account, 100 requests/hour per IP. This screens a vessel — sanctions, ownership opacity and vetting. Voyage scoring is a different endpoint and needs a key.

Screen a hull — no credentials
curl https://arcnautical.com/api/v1/vessels/9169067/check
Response (trimmed)
{
  "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"
}

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.

Resolve a port name — no credentials
curl 'https://arcnautical.com/api/v1/ports?query=rotterdam&limit=3'
Response
{
  "ports": [
    { "locode": "NLRTM", "name": "Rotterdam", "country": "Netherlands",
      "country_code": "NL", "lat": 51.9, "lon": 4.5 }
  ]
}

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

  1. Mint a key — test or live — and store it as ARCNAUTICAL_API_KEY.
  2. Score a voyage — every resource-creating POST needs a unique Idempotency-Key.
  3. Read GET /api/v1/usage for your environment, quota, and limits.
1. Score one voyage
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
    }
  }'
2. Confirm your quota and limits
curl --fail-with-body https://arcnautical.com/api/v1/usage \
  -H "Authorization: Bearer $ARCNAUTICAL_API_KEY" \
  -H "X-Request-Id: integration-check-001"

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.

Required header
Authorization: Bearer arc_test_...  # evaluation
Authorization: Bearer arc_live_...  # production
Do not put an API key in browser JavaScript, mobile bundles, source control, URLs, analytics events, or client-visible logs.
ScopeAllows
voyage:scoreSynchronous and batch assessments, plus retained-result retrieval. self-serve
usage:readEntitlement limits and current consumption. self-serve
voyage:monitorLive corridor monitors and monitor history. self-serve
webhooks:manageWebhook endpoint lifecycle and signed test delivery. 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.

Not every source screens the same way, and the difference matters. A hull identifier (IMO/MMSI) can only match a list that publishes vessel records: OFAC SDN, OpenSanctions, EU FSD and UK OFSI do. The UN Consolidated List does not — it publishes individuals and entities, and carries no vessel entries at all. It is screened, and it contributes real signal at the name and ownership-chain layer (it designates, for instance, the DPRK shipping companies), but an exact_imo match will never come from it. A source being reachable is not the same as a source being able to match the thing you asked about.

ValueMeaningHow to treat it
GREEN + coverageComplete: trueNo match, every source loaded.Clear.
GREEN + coverageComplete: falseNo 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.
AMBERPossible 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.
REDConfirmed match on a vessel identifier.Stop.
INCOMPLETENo 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. Never cached, so a retry gets a fresh attempt.
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 ownership and vetting values are fail-open defaults (grade C, score 50, opacity MEDIUM), not findings. Render them as “not assessed”, never as a confident grade.

Incident-context freshness

currentContext.coverage is a different axis: 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.

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.

Test and live environments

KeyDataQuotaMonitoring
arc_test_...Real route-risk computationSeparate test bucketCannot create active corridor monitors
arc_live_...Real route-risk computationSeparate live bucketAvailable 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.origin
  • route.destination

Optional

  • customer_reference
  • route.via, up to 20 coordinates
  • vessel type, load, speed, DWT, fuel, bunker price

Observed response shape

201 Created
{
  "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": null, "basis": "unknown",
      "message": "Forecast provider returns no model-run 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": "..."
}

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

  • imo9811000, "IMO9811000" or "IMO 9811000".

Optional

  • vessel_namethe fix for INCOMPLETE.
  • customer_reference — echoed back on the result.
  • include_vettingfalse skips the A–E grade.
If a hull comes back INCOMPLETE for identity, send vessel_name and screen it again. 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"}'
It is used only when our own sources hold no name, so it can never override what we already know, and it is recorded in the audit trail as caller-supplied. Retrying the identical request without it cannot produce a different answer — the identity gap is not transient.
Two checks run on the IMO itself, before any list is consulted. Both can produce INCOMPLETE on a number that looks perfectly well-formed, and neither is transient.
  • 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. 1111111 is 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. 1234567 is 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.
If you are testing the integration, this is the behaviour you want — a screening API that returns a confident vessel name and a clean verdict for 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".
Do not re-screen a hull that has not changed. A screen you already hold is still a screen you paid for, and re-asking the same IMO on a schedule is the most common way an integration burns its monthly allowance on answers it already had. Two purpose-built alternatives:
  • 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.
Both are metered far below repeated screening, and both give you the one thing a re-screen does not: the knowledge that nothing changed.

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 — 5,000 live screens a month, 500 test — 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. Budget a longer client timeout than an assessment needs: a screen reads several upstream lists and typically takes 5–15 seconds.

Three fields to read before you act on a result

  • sanctions.status has 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. Read sanctions.detail before retrying: if it says the identity could not be resolved, retrying the identical request cannot change the answer — send vessel_name instead, because the gap is not transient. If it names unreachable sources, a later retry genuinely can help.
  • 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-check sanctions.coverage_gaps.
  • vetting.assessed: false means the A–E grade is a fail-open default on a hull we could not identify. Do not show a confident letter when it is false.

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 five codes, each with the fact in message and the change to your next request in remedy:

  • repeat_screening — your account screened this hull inside the last 24 hours and this request carried a different Idempotency-Key, so it ran again and spent a unit. previous_screening_id names the earlier record; the remedy is the derived key that would have replayed it free.
  • identity_unresolved — no source we hold names this hull and the request carried no vessel_name, so nothing was screened and the identical request again cannot change the answer. The remedy is the request with the name you hold.
  • 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 — 1234567 is 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 of identity_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_warning — at least 80% of the month’s live screening allowance is used. The same threshold fires the usage.quota_warning webhook event; this is the copy for accounts with no webhook endpoint, which is most of them.
  • 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 on sanctions.status alone, include_vetting: false gives 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. Only an input with no 7-digit core is rejected, and there are exactly three ways to have none: 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: an eight-digit blob 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" does not contain a 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.

Where two readings are equally valid we say nothing specific, deliberately — naming one of them would be a guess about a hull identity, which is the one thing this endpoint must never do.

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/screenings is your screening history, newest first. Filter by date to answer “everything we screened in Q3”, or by customer_reference to 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-log is every authenticated request made with a key on your account: method, path, status, the machine error_code on a refusal, source address, which key, and the request_id you were given at the time. Refusals included — usually the half worth reading. 365 days, logs:read scope.
  • GET /api/v1/webhook-deliveries is every event we tried to send you and what happened to it. ?status=dead finds events you never received after all five attempts, and POST /api/v1/webhook-deliveries/{id}/redeliver queues 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/exports turns 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_large naming 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_before is pinned. Leave it out and it is set to the moment of your request and returned in filters. 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 imo with resource: "access_log" and you get a 422 naming 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.degraded fires 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

  1. POST /api/v1/voyage-assessment-batches with a unique idempotency key.
  2. Poll GET /api/v1/voyage-assessment-batches/{id} until completed, partial, or failed.
  3. 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

MethodPathScopeSuccess
POST/api/v1/voyage-assessmentsvoyage:score201
GET/api/v1/voyage-assessments/{id}voyage:score200
POST/api/v1/voyage-assessment-batchesvoyage:score202
GET/api/v1/voyage-assessment-batches/{id}voyage:score200
GET/api/v1/voyage-assessment-batches/{id}/itemsvoyage:score200
POST/api/v1/screeningsvessel:screen201
GET/api/v1/screenings/{id}vessel:screen200
POST/api/v1/screening-batchesvessel:screen202
GET/api/v1/screening-batches/{id}vessel:screen200
GET/api/v1/screening-batches/{id}/itemsvessel:screen200
POST/api/v1/vessel-monitorsvessel:monitor201
GET/api/v1/vessel-monitorsvessel:monitor200
POST/api/v1/corridor-monitorsvoyage:monitor201
GET/api/v1/corridor-monitorsvoyage:monitor200
GET/api/v1/screeningsvessel:screen200
POST/api/v1/cargo-screeningscargo:screen201
GET/api/v1/cargo-screeningscargo:screen200
GET/api/v1/cargo-screenings/{id}cargo:screen200
POST/api/v1/fleetsvessel:screen201
GET/api/v1/fleetsvessel:screen200
GET/api/v1/fleets/{id}/exposurevessel:screen200
GET/api/v1/fleets/{id}/changesvessel:screen200
POST/api/v1/webhook-endpointswebhooks:manage201
GET/api/v1/webhook-deliverieswebhooks:manage200
GET/api/v1/webhook-deliveries/{id}webhooks:manage200
POST/api/v1/webhook-deliveries/{id}/redeliverwebhooks:manage202
GET/api/v1/access-loglogs:read200
POST/api/v1/exportsthe resource's own scope202
GET/api/v1/exportsthe resource's own scope200
GET/api/v1/exports/{id}the resource's own scope200
GET/api/v1/exports/{id}/downloadthe resource's own scope200
DELETE/api/v1/exports/{id}the resource's own scope200
GET/api/v1/usageusage:read200
GET/api/v1/vessels/{imo}/checknone — no key200
GET/api/v1/ports?query=none — no key200

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 3–6 seconds with enrichment and the slowest few percent run past 8 — 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, and X-RateLimit-Reset.
  • Assessment responses include X-ArcNautical-Quota-Limit, X-ArcNautical-Quota-Remaining, and X-ArcNautical-Quota-Reset.
  • Send a unique X-Request-Id and 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.

422 Unprocessable Entity
{
  "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.

StatusMeaningAction
400 / 422Invalid JSON, headers, or route fieldsCorrect the request; do not retry unchanged.
401 / 403Invalid key, environment, entitlement, or scopeCheck the issued key and entitlement.
402Monthly assessment quota exhaustedWait for reset or request a limit change.
409Idempotency or resource-state conflictInspect 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.
429Rate or concurrency limitHonor Retry-After.
500 / 503 / 504Temporary server, edge, or upstream failureRetry only when the request is replay-safe.

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.

Webhook endpoints must be public HTTPS URLs. The creation response returns the HMAC secret once.

Verify each delivery
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.
For integration support, include the endpoint, UTC timestamp, response status, error code, and X-Request-Id. Never send the full API key.

Get an API key

Both test and live keys are self-serve. Create an account, open Developer API, and mint one — it is shown once, so store it in a secret manager immediately.

  1. Create an account, or sign in.
  2. You land on Developer API.
  3. Create a key with environment test or live.

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 — live carries 10,000 assessments a month, test 1,000. Webhooks work on either key. A live key also carries 10 corridor monitors, recomputed every 6 hours; each run counts against the same live 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.