You have a column of IMO numbers. Maybe it is a week of bunker nominations, the hulls named in a charter party, or an export of port calls. Someone wants to know which of them are sanctioned before anything is signed.

The script below answers that. It needs Python 3.9 or later and requests, and nothing else: no account, no key.

One ship

The check is a public GET with the IMO number in the path:

import requests

r = requests.get("https://arcnautical.com/api/v1/vessels/9034690/check", timeout=30)
r.raise_for_status()
v = r.json()
print(v["vesselName"], v["sanctions"]["status"], "-", v["sanctions"]["detail"])
LUMA RED - 2 confirmed matches on vessel identifier.

That one call screened IMO 9034690 against OFAC SDN, the UK list, the EU Financial Sanctions Database, the UN Security Council list and OpenSanctions' maritime dataset. A hull someone checked in the last hour answers from cache at once. Three fresh screens we timed on 11 October took between 2 and 5 seconds, so the 30-second timeout is generous rather than necessary.

Read the answer the way an auditor will

sanctions.status has four values, not two, and the difference matters more than the code:

So "clear" in code is status == "GREEN" and coverageComplete, and nothing else.

Catch typos before you send them

An IMO number carries its own checksum. Multiply the first six digits by 7, 6, 5, 4, 3 and 2, add them up, and the last digit of the sum must equal the seventh digit. For 9811000 (EVER GIVEN) that is 63 + 48 + 5 + 4 + 0 + 0 = 120, and the seventh digit is 0. A number that fails this belongs to no ship. It is almost always a typo, and screening it would tell you nothing, so the script checks first.

A whole list

Put the numbers in vessels.csv under a column called imo. "IMO 9811000" and "9811000" both work.

import csv
import time

import requests

API = "https://arcnautical.com/api/v1/vessels/{imo}/check"


def imo_is_valid(imo: str) -> bool:
    """The seventh digit of an IMO number is a check digit over the first six."""
    if len(imo) != 7 or not imo.isdigit():
        return False
    total = sum(int(d) * w for d, w in zip(imo[:6], range(7, 1, -1)))
    return total % 10 == int(imo[6])


def check(imo: str) -> dict:
    for _ in range(5):
        r = requests.get(API.format(imo=imo), timeout=30)
        if r.status_code in (429, 503):
            # Rate limited, or every screening slot busy: wait as told, then retry.
            time.sleep(int(r.headers.get("Retry-After", "30")))
            continue
        r.raise_for_status()
        return r.json()
    raise RuntimeError(f"IMO {imo}: still refused after 5 attempts")


with open("vessels.csv", newline="") as f_in, open("screened.csv", "w", newline="") as f_out:
    out = csv.writer(f_out)
    out.writerow(["imo", "vessel_name", "sanctions", "clear", "detail"])
    for row in csv.DictReader(f_in):
        imo = row["imo"].strip().upper().removeprefix("IMO").strip()
        if not imo_is_valid(imo):
            out.writerow([imo, "", "INVALID_IMO", "no", "fails the IMO check digit; fix it before screening"])
            continue
        v = check(imo)
        s = v["sanctions"]
        clear = s["status"] == "GREEN" and s["coverageComplete"]
        out.writerow([imo, v.get("vesselName") or "", s["status"], "yes" if clear else "no", s["detail"]])
        time.sleep(1)

Four rows in, four rows out (the GREEN detail is shortened here):

imo,vessel_name,sanctions,clear,detail
9034690,LUMA,RED,no,2 confirmed matches on vessel identifier.
9811000,EVER GIVEN,GREEN,yes,"No matches across OFAC SDN, OpenSanctions, EU FSD, UN Consolidated, UK OFSI ..."
9703318,MSC ZOE,GREEN,yes,"No matches across OFAC SDN, OpenSanctions, EU FSD, UN Consolidated, UK OFSI ..."
9295345,,INVALID_IMO,no,fails the IMO check digit; fix it before screening

Two things in check() are worth keeping if you rewrite it. First, it honours Retry-After. The free check allows 100 requests an hour per IP address, and the X-RateLimit-Remaining header on every response tells you how many you have left. Second, a 503 means every shared screening slot was busy. It does not count against your allowance, and waiting the number of seconds it gives is all it asks.

What the free check leaves out

The keyless answer is a verdict, not evidence. It does not say which list matched, under which programme, or with what confidence. It is also a sample for evaluation and occasional lookups, not a feed: past 100 hulls an hour, or for a product that runs on it, you want a key. A key is free and needs no card. The same question on a key returns the full record:

import datetime
import os

import requests

imo = "9034690"
r = requests.post(
    "https://arcnautical.com/api/v1/screenings",
    headers={
        "Authorization": f"Bearer {os.environ['ARCNAUTICAL_API_KEY']}",
        # The same hull asked again today replays the stored record instead of spending a unit.
        "Idempotency-Key": f"screening:{imo}:{datetime.date.today()}",
    },
    json={"imo": imo},
    timeout=30,
)
r.raise_for_status()
record = r.json()

That record lists every match with its list, programme and confidence class, reports each source separately, and is kept so you can cite it later. The same key screens batches of 50 and can watch hulls for you, so the script above does not have to run again next week. The API docs cover all of it.

What it is not

Run it now

Paste curl https://arcnautical.com/api/v1/vessels/9034690/check into a terminal. The response fields are documented in the API docs, and the vessel-check-api README has the same examples in JavaScript and Google Sheets.