---
title: "Score one voyage"
method: POST
path: "/api/v1/voyage-assessments"
tags: ["Voyage assessments"]
---

# Score one voyage

`POST /api/v1/voyage-assessments`

## Request

- Base URL: `https://arcnautical.com`
- URL: `https://arcnautical.com/api/v1/voyage-assessments`
- Auth: one of:
  - HTTP bearer
  - API key in header `X-ArcNautical-Key`

Synchronously scores one voyage and retains the assessment for 30 days.

Required scope: voyage:score.

## Headers

- `Idempotency-Key` string, required

## Request body

- CreateVoyageAssessmentRequest
  - `customer_reference` string
  - `route` RouteInput, required
    - `origin` union, required
      - string
      - object
        - `locode` string, required
    - `destination` union, required
      - string
      - object
        - `locode` string, required
    - `via` GeoPoint[]
      - `latitude` number, required
      - `longitude` number, required
    - `vessel_type` 'container' | 'bulk' | 'tanker' | 'lng' | 'general'
    - `load_condition` 'laden' | 'ballast'
    - `speed_knots` number
    - `dwt_tonnes` number
    - `fuel_type` 'hfo' | 'vlsfo' | 'mgo' | 'lng'
    - `bunker_price_usd_per_tonne` number
  - `include_geometry` boolean: Set false to omit route.geometry from the response. The polyline is typically ~87% of an assessment body (measured: 65,053 of 74,265 bytes, 1,885 coordinate pairs on SGSIN-NLRTM), so a caller scoring a book of voyages who wants the score and drivers rather than the line on a map should turn it off. Default true so existing integrations are unchanged.

## Response `201`

Assessment created.

- VoyageAssessment
  - `id` string, required
  - `customer_reference` string
  - `route` NormalizedRoute, required
    - `origin_locode` string, required
    - `dest_locode` string, required
    - `via_waypoints` NormalizedGeoPoint[], required
      - `lat` number, required
      - `lon` number, required
    - `distance_nm` number, required
    - `geometry` object: GeoJSON Feature containing the computed maritime route. Absent when the request set include_geometry:false.
  - `score` number, required
  - `risk_level` 'low' | 'moderate' | 'elevated' | 'high' | 'critical', required
  - `drivers` RiskDriver[], required
    - `source` string, required
    - `label` string, required
    - `summary` string, required
    - `event_count` integer, required
    - `signal_score` integer, required: This signal's own severity, independent of how much the model weights it.
    - `weight_pct` integer, required: This driver's share of the model. Sums to exactly 100 across all drivers.
    - `contribution_pct` integer, required: How much of this assessment this driver accounts for: signal_score x weight_pct, normalised across the driver list. Sums to exactly 100 across drivers, or is 0 on every driver when no signal reported anything.
    - `contribution_band` 'low' | 'medium' | 'high' | 'critical', required: Coarse band over contribution_pct: critical >= 30, high >= 20, medium >= 10, otherwise low. RELATIVE, not a severity scale — a driver can band critical on a low-scoring route because it is most of what little risk there is. Use score and risk_level for danger, signal_score for one signal's severity, and this for what the result is made of. Always critical when sets_floor is true.
    - `sets_floor` boolean, required: True for the driver whose categorical fact set score through score_floor. That driver is the reason for the published number rather than one weighted input to it, so it bands critical whatever its share. Exactly one driver carries it when score_floor is present; none do otherwise.
    - `relative_rank` integer, required
  - `jwc_intersected_areas` JwcIntersectedArea[], required: Joint War Committee listed areas the route crosses. Empty when none apply or the source was unavailable.
    - `id` string, required
    - `name` string, required
    - `risk_level` string, required: Dominant peril for this area — currently "war" or "piracy". This is ArcNautical's classification, not the Joint War Committee's: JWLA lists areas without grading them, so severity comes from the score and its drivers, not from this field.
    - `route_fraction` number, required: Fraction of total route distance crossing this listed area.
  - `eez_transit` EezTransit[], required: Exclusive Economic Zones the route transits, with sanctions status where the coastal state is designated.
    - `country_code` string, required
    - `country_name` string, required
    - `risk_category` string, required: Why this EEZ is notable: "sanctioned", "conflict", "piracy", "chokepoint" or "context". Only "sanctioned" is authoritative — it comes from the live OFAC/EU/UNSC lookup reflected in `sanctions`; the rest are advisory hints from the bundled EEZ dataset.
    - `route_fraction` number, required: Fraction of total route distance inside this EEZ.
    - `sanctions` EezSanctionsStatus
      - `is_sanctioned` boolean, required
      - `regimes` string[], required: Sanctions regimes designating this coastal state, e.g. EU, UNSC, OFAC.
  - `score_floor` ScoreFloor: Present only when a categorical fact raised `score` above the weighted signal average — so `score` is deliberately not reproducible from `drivers` alone. Absent when the weighted signals decided the score by themselves.
    - `basis` 'listed_area_transit' | 'listed_area_active_hostility' | 'listed_area_hostility_unknown' | 'named_country_port_call' | 'sanctioned_eez_transit', required: Which categorical fact set the floor. `listed_area_active_hostility` means the route crosses a JWC listed area where our live feeds place hostile activity (a hostile navigational warning, an attack, or AIS/GPS disruption) inside that area. `listed_area_hostility_unknown` means the route crosses a listed area, no hostile activity was found, and at least one of the three feeds that would have detected it was not reporting — so the score is held at the active-hostility floor rather than published as calm. Check `sources[]` to see which feed was degraded; `explanation` names it.
    - `value` number, required: The floor that was applied. `score` is at least this.
    - `explanation` string, required: One line naming the areas or states that produced the floor.
    - `weighted_score` number, required: The score before the floor. Use `score` for the verdict; use this to rank two lanes that both floored into the same band.
  - `confidence` number, required
  - `missing_sources` string[], required
  - `source_status` object, required: DEPRECATED — use `sources`, which carries the same status plus `checked_at`. Keeps working until 2026-10-31 under the 90-day policy at /developers/stability/; removal ships as v2, never in place.
  - `sources` SourceStatus[]: Per-source status and freshness. `checked_at` is null whenever the freshness is not genuinely known — it is never filled in with the request time. Read `basis` before comparing timestamps across sources: "when we last fetched" and "the newest record we hold" answer different questions, and only `refreshed_at` and `published_at` tell you whether a source is stale.
    - `source` string, required: Signal family, e.g. sanctions, navwarn, piracy. On a screening, `vessel_vetting` covers the port-state-control, registry and build-year inputs behind `vetting` and `ownership`: it reports `error` when that enrichment did not complete (a source failure — retry) and `ok` when it ran, whose `message` then distinguishes "inputs were read" from "the sources hold no record for this identifier" (retrying will not change that). Before 2026-09-06 this row did not exist, so a failed enrichment left every source reading `ok` with the grade simply absent.
    - `status` 'ok' | 'stale' | 'error' | 'not_requested', required: `ok` — read. `error` — we tried and could not read it; a retry may succeed. `not_requested` — you asked us not to read it (see `include_vetting`), so no retry will change it. These have opposite retry policies and must not be collapsed.
    - `checked_at` string, date-time, nullable, required: Null when basis is `unknown`, or when the source returned nothing to date. Never substituted with the request time.
    - `basis` 'refreshed_at' | 'latest_event_at' | 'published_at' | 'build_constant' | 'unknown', required: What checked_at means. `refreshed_at` — when we last fetched the source. `published_at` — the publication date the source itself states, which dates the DATA rather than our copy. `latest_event_at` — the newest record we hold, NOT a refresh time: a quiet week and a dead feed look identical. `build_constant` — bundled at deploy time. `unknown` — no timestamp exists in the fetch path, INCLUDING the `vessel_vetting` row when its status is `not_requested`: nothing was attempted, so there is nothing to date.
    - `message` string: Present when the basis needs explaining.
    - `label` string: Human-readable source name. Present on screening responses (e.g. "OFAC SDN").
  - `methodology_version` string, required
  - `assessed_at` string, date-time, required: When the SCORE was computed — not when this record was written. Scores are cached for four hours by scoring version, date and route hash, so an identical request inside that window returns the ORIGINAL computation time and `cached: true`. Use this as the "when did we know this" field in an audit trail.
  - `cached` boolean: True when this response came from the score cache rather than being computed for this request. `assessed_at` then predates your request.
  - `expires_at` string, date-time, required: When this RECORD is deleted (retention), not how long the verdict is valid.
  - `disclaimer` string, required

## Other responses

- `400`: Validation error. `code` is one of: - `missing_idempotency_key` — A POST arrived with no Idempotency-Key header. - `invalid_idempotency_key` — The Idempotency-Key was present but unusable (empty or over-long). - `invalid_json` — The body did not parse as JSON.
- `401`: Missing or invalid API key. `code` is one of: - `unauthorized` — No credential was supplied. - `invalid_api_key` — The key is unknown, revoked, expired, or its prefix does not match the environment.
- `402`: The allowance for this resource is used up: the month's, or a one-time total (live screenings on an account opened from 2026-10-01, or a pilot), which does not reset (then there is no reset instant and no Retry-After). The refused request is NOT metered. message states used/limit and the reset instant when there is one; a replay under an Idempotency-Key already answered is still free and is not metered, reads are never metered, and a test-environment key has its own allowance. `code` is one of: - `quota_exhausted` — The allowance for this resource is spent: the month's (GET /api/v1/usage carries the reset date), or a one-time total (live screenings on an account opened from 2026-10-01, or a pilot), which does not reset.
- `403`: The key is not entitled to this environment or scope. `code` is one of: - `insufficient_scope` — The key does not hold the scope this endpoint requires. - `entitlement_inactive` — The account has no active entitlement. - `ip_not_allowed` — The key is IP-restricted and this source address is not on its list. - `monitoring_unavailable_in_test` — Vessel and corridor monitors are not available on a test key. - `simulation_unavailable_in_live` — Simulated monitor events are available only on a test key. A synthetic risk change on a live monitor would enter your compliance record as a real finding about a real vessel.
- `404`: Resource not found. `code` is one of: - `not_found` — No such resource, or it is not yours. A malformed id answers this way too, deliberately. - `unsupported_api_version` — The path names an /api/vN this service does not publish. Answered by the edge, so it is the one code that carries no rate-limit headers. NEVER retry: our own guidance says an HTML body means a network layer answered and should be retried, and this exists so that advice is not applied to a permanent 404.
- `405`: The path exists but not with this method. The Allow header names the methods that do. `code` is one of: - `method_not_allowed` — The path exists but not with this verb. The Allow header names the verbs that do work.
- `409`: Idempotency conflict. `code` is one of: - `idempotency_conflict` — This Idempotency-Key was already used with a different body. Never resolves — use a new key. - `idempotency_in_progress` (retryable) — The original request with this key is still running, and this retry waited for it (up to 30s) without it finishing. Retry with the same key: the next retry waits again and returns the original result the moment it lands, flagged X-ArcNautical-Idempotent-Joined: true. Carries Retry-After. - `export_not_ready` (retryable) — The export job has not finished. Poll it; carries Retry-After. - `delivery_in_flight` (retryable) — That webhook delivery is being attempted right now. - `endpoint_disabled` — The webhook endpoint is disabled. Re-enable it first. - `endpoint_deleted` — The webhook endpoint no longer exists. - `monitor_limit_exceeded` — Corridor monitor ceiling reached. Delete one first. - `vessel_monitor_limit_exceeded` — Vessel monitor ceiling reached. Delete one first. - `vessel_monitor_exists` — A monitor already watches this hull.
- `415`: Content-Type describes a body that is not JSON (form-encoded, multipart, XML). application/json, text/plain and an absent header are all accepted. `code` is one of: - `unsupported_media_type` — Content-Type declared something other than application/json. Omitting the header is accepted; declaring the wrong one is not.
- `422`: Request or route validation failed. Also returned for a body field this endpoint does not read — field_violations names it and lists the accepted keys. `code` is one of: - `validation_failed` — One or more fields are invalid; field_violations names each one. - `route_not_found` — Both ports are known but no sea route connects them. - `export_too_large` — The requested range exceeds the per-export row cap. Narrow the filters and export in parts. - `webhook_url_blocked` — The URL resolves to a private, loopback or link-local address. - `webhook_url_unresolvable` — The URL host does not resolve.
- `429`: Rate limit exceeded. `code` is one of: - `rate_limit_exceeded` (retryable) — Over the per-key request rate. Shed RATE; back off to X-RateLimit-Reset. - `concurrency_limit` (retryable) — Over your own in-flight ceiling. Shed CONCURRENCY, or use the batch endpoint. Retry-After carries a live estimate, not a fixed number. - `service_concurrency_limit` (retryable) — The shared pool is full and your account is within its allowance. A graded screen QUEUES for that pool first and is served round-robin between accounts, so this means the queue itself ran out of time — you were not stuck behind one customer's batch. Reducing your fan-out will NOT help; retry unchanged after Retry-After. include_vetting: false skips this queue entirely, and /api/v1/screening-batches is not subject to it at all. - `export_concurrency_limit` (retryable) — Too many exports building at once. Wait for one to finish.
- `500`: Internal or upstream error. `code` is one of: - `internal_error` (retryable) — An unhandled failure. Retry with backoff; quote request_id if it persists. - `assessment_failed` (retryable) — Voyage scoring failed to produce a result. - `screening_failed` (retryable) — The screening engine failed to produce a verdict. No unit is charged. - `export_failed` (retryable) — The export job failed while building. - `export_row_count_grew` — The matching rows grew past the cap while the file was being written, so it was abandoned rather than shipped short. Narrow the filters — an identical retry fails identically.
- `503`: Service temporarily unavailable. `code` is one of: - `api_paused` (retryable) — The API is paused for a short while (an operator lever against abuse, rarely pulled). Nothing ran and nothing was charged. Retry the same request after Retry-After.
- `504`: The request exceeded its server-side time budget and was abandoned. Raised by us, below the socket timeout at the edge, so it arrives as JSON with a request_id. `code` is one of: - `assessment_timeout` (retryable) — Scoring exceeded its deadline. Retry; a warm route usually succeeds. - `screening_timeout` (retryable) — The screening exceeded its time budget (screeningComputeBudgetMs) and was abandoned before it could be stored. No unit is charged, and the Idempotency-Key is RELEASED — so retry with the SAME key; it is a clean attempt, not a replay. Deliberately raised below the socket timeout at the edge, so this arrives as JSON with a request_id rather than as a gateway 504 we never see.

---

[API](https://skmtc.dev/arcnautical/apis/arcnautical-api.md) · [All operations](https://skmtc.dev/arcnautical/apis/arcnautical-api/llms.txt) · [OpenAPI document](https://skmtc.dev/arcnautical/apis/arcnautical-api/revisions/40cd2659cdd7?raw)
