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.
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.
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"
}
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.
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 }
]
}
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. - Score a voyage — every resource-creating
POSTneeds a uniqueIdempotency-Key. - Read
GET /api/v1/usagefor your environment, quota, and limits.
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
}
}'
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"
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
| Scope | Allows |
|---|---|
voyage:score | Synchronous and batch assessments, plus retained-result retrieval. self-serve |
usage:read | Entitlement limits and current consumption. self-serve |
voyage:monitor | Live corridor monitors and monitor history. self-serve |
webhooks:manage | Webhook 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.
| 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. | Review before acting. |
RED | Confirmed match on a vessel identifier. | Stop. |
INCOMPLETE | A 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
| 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": 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.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.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 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.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 |
| POST | /api/v1/webhook-endpoints | webhooks:manage | 201 |
| 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. 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, andX-RateLimit-Reset. - Assessment responses include
X-ArcNautical-Quota-Limit,X-ArcNautical-Quota-Remaining, andX-ArcNautical-Quota-Reset. - 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 / 403 | Invalid key, environment, entitlement, or scope | Check the issued key and entitlement. |
| 402 | Monthly assessment quota exhausted | Wait for reset or request a limit change. |
| 409 | Idempotency or resource-state conflict | Inspect code; retry in-progress keys with backoff. |
| 429 | Rate or concurrency limit | Honor Retry-After. |
| 500 / 503 / 504 | Temporary server, edge, or upstream failure | Retry 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.
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.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.
- Create an account, or sign in.
- You land on Developer API.
- 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 — 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.