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-$(uuidgen)" \
  -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.

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.Review before acting.
REDConfirmed match on a vessel identifier.Stop.
INCOMPLETEA core source (OFAC SDN, OpenSanctions, EU FSD) could not be loaded, so no clear verdict is possible.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: $(uuidgen)" \
  -d '{"imo":"9811000","customer_reference":"fleet-7"}'

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

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.

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.

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
POST/api/v1/webhook-endpointswebhooks:manage201
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. Keep the key stable only when retrying the identical method, path, and JSON body. Results are replayable for 24 hours. Reusing a key with a different body returns 409 idempotency_conflict.

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; retry in-progress keys with backoff.
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.