<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by VesselAPI on Medium]]></title>
        <description><![CDATA[Stories by VesselAPI on Medium]]></description>
        <link>https://medium.com/@vessel-api?source=rss-00c2102cfd05------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*PAlOEnaEYLzewMogjQjEhw.png</url>
            <title>Stories by VesselAPI on Medium</title>
            <link>https://medium.com/@vessel-api?source=rss-00c2102cfd05------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 14 Nov 2026 04:18:00 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@vessel-api/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[What can an AI agent answer from an AIS API via an MCP server?]]></title>
            <link>https://vessel-api.medium.com/what-can-an-ai-agent-answer-from-an-ais-api-via-an-mcp-server-5982ac3b2d40?source=rss-00c2102cfd05------2</link>
            <guid isPermaLink="false">https://medium.com/p/5982ac3b2d40</guid>
            <category><![CDATA[mcps]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[data-engineering]]></category>
            <category><![CDATA[shipping]]></category>
            <category><![CDATA[claude-code]]></category>
            <dc:creator><![CDATA[VesselAPI]]></dc:creator>
            <pubDate>Sun, 01 Nov 2026 20:24:28 GMT</pubDate>
            <atom:updated>2026-11-02T09:40:36.415Z</atom:updated>
            <content:encoded><![CDATA[<p>Connected to live AIS data through an MCP server, Claude found 29 to 32 ships at anchor off Durban and dated the longest wait at about 18 days. A week after Storm Austen it counted 6 container ships still at anchor off Rotterdam, most of them there for one or two days. In the US Gulf it found no VLCC or Suezmax at Corpus Christi or Galveston. It read most of the five large tankers due there as arriving in ballast and left one undecided.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*5_aw5bWaBTPRtYQo.png" /></figure><p><em>Data from live runs on 1 November 2026. Series: Hub · </em><a href="https://vesselapi.com/blog/ai-agent-us-gulf-crude-tankers"><em>Part 1: Are US Gulf crude tankers arriving laden or in ballast?</em></a><em> · </em><a href="https://vesselapi.com/blog/ai-agent-durban-anchorage-wait"><em>Part 2: How long are ships waiting at Durban anchorage?</em></a><em> · </em><a href="https://vesselapi.com/blog/ai-agent-rotterdam-storm-backlog"><em>Part 3: Has the Rotterdam port backlog cleared after Storm Austen?</em></a></p><p>In all 17 runs Claude Code was the AI agent, and vesselapi-mcp, the MCP server for the VesselAPI AIS API, supplied its only tools. The runs took place between about 19:00 and 19:25 UTC on 1 November, from 7 prompts: 13 on Sonnet 5.5 and 4 on Haiku 5.5. No prompt named a tool.</p><h3>Three questions and what the agent found</h3><p><a href="https://vesselapi.com/blog/ai-agent-us-gulf-crude-tankers">Part 1: Are US Gulf crude tankers arriving laden or in ballast?</a> None of the seven runs found a VLCC or Suezmax at or due at Corpus Christi or Galveston. Every run found the same five Aframax-size tankers declaring Corpus Christi for the coming week. The three judgement runs read most of them as in ballast, an inference from crew-entered draughts and not trading advice.</p><p><a href="https://vesselapi.com/blog/ai-agent-durban-anchorage-wait">Part 2: How long are ships waiting at Durban anchorage?</a> Three runs found 29 to 32 ships at anchor; a fourth found 7 because its search box stopped short of the anchorage. The longest dated wait, about 18 days, came from the shortest prompt. The two Sonnet runs that tried to date every ship gave only rough bounds.</p><p><a href="https://vesselapi.com/blog/ai-agent-rotterdam-storm-backlog">Part 3: Has the Rotterdam port backlog cleared after Storm Austen?</a> <a href="https://mykn.kuehne-nagel.com/news/article/port-operational-updates-from-27-10-2026">Kuehne+Nagel reported</a> that the port closed from 23 to 25 October because of high winds. A week later, two Sonnet runs with overlapping search boxes found the same 6 container ships, one of them drifting in one run, and a third with a wider box found 8 waiting for Rotterdam.</p><h3>Connecting Claude to the AIS API</h3><p>The server runs through npx and needs one VesselAPI key, created in the <a href="https://dashboard.vesselapi.com">dashboard</a>. It needs Node 20 or later. The first line below reads the key without echoing it, which keeps it out of your shell history:</p><pre>read -rs VESSELAPI_API_KEY &amp;&amp; export VESSELAPI_API_KEY<br>claude mcp add vesselapi --scope user -e VESSELAPI_API_KEY=&quot;$VESSELAPI_API_KEY&quot; -- npx -y vesselapi-mcp</pre><p>With --scope user the server is available in every project; without it, only in the directory where you ran the command. Both store the key in plain text in ~/.claude.json.</p><p>For a project that shares its configuration through a committed .mcp.json, keep the key out of the file. Single quotes stop the shell from expanding the variable. Claude Code then stores a reference and fills it from each user&#39;s environment at start-up:</p><pre>claude mcp add vesselapi --scope project -e &#39;VESSELAPI_API_KEY=${VESSELAPI_API_KEY}&#39; -- npx -y vesselapi-mcp</pre><p>Each person approves a project server the first time they open the project in Claude Code. If the variable is unset, claude mcp list warns about it, and the server receives the literal ${VESSELAPI_API_KEY} text as its key, so every call fails to authenticate. Other clients take the configuration from the vesselapi-mcp README:</p><pre>{<br>  &quot;mcpServers&quot;: {<br>    &quot;vesselapi&quot;: {<br>      &quot;command&quot;: &quot;npx&quot;,<br>      &quot;args&quot;: [&quot;-y&quot;, &quot;vesselapi-mcp&quot;],<br>      &quot;env&quot;: {<br>        &quot;VESSELAPI_API_KEY&quot;: &quot;your-api-key&quot;<br>      }<br>    }<br>  }<br>}</pre><ul><li><strong>Claude Desktop</strong>. Where the configuration goes: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows). If Node was installed through a version manager such as nvm, give the absolute path to npx and add Node&#39;s directory to PATH in env</li><li><strong>Cursor</strong>. Where the configuration goes: ~/.cursor/mcp.json, or .cursor/mcp.json in the project. A project file can reference the key as ${env:VESSELAPI_API_KEY} instead of holding it</li><li><strong>Windsurf</strong>. Where the configuration goes: ~/.codeium/windsurf/mcp_config.json</li></ul><p>Files that hold the key in plain text belong outside version control.</p><h3>How we ran the tests</h3><p>Each run was one non-interactive Claude Code call (claude -p, version 2.1.316) in an empty directory outside any repository. User settings, hooks and instruction files were not loaded (--setting-sources project), and Claude Code&#39;s built-in tools were switched off (--tools &quot;&quot;). The agent had exactly the 19 VesselAPI tools, which each run&#39;s start-up record confirms. Claude Code&#39;s own system prompt, the date and the working-directory path were still in its context. Size bands and draught rules of thumb came from the model&#39;s own knowledge.</p><p>The server was vesselapi-mcp 2.2.1, the current release, run locally with a one-line patch to its bundled SDK that pointed it at a recording proxy in front of the live API. The proxy refused requests beyond 80 per run with HTTP 429. Only the two Rotterdam runs told to use as many calls as they needed reached that cap. Each prompt ran one to four times, and every Haiku figure comes from a single run, so the figures describe these runs and are not a benchmark.</p><h3>The 19 tools, in four groups</h3><ul><li><strong>Where ships are now</strong>. Tools: get_vessels_in_area (bounding box), get_vessels_in_radius (point and radius); What they return: Position reports with speed, heading and AIS navigational status</li><li><strong>What a ship is</strong>. Tools: get_vessel, get_vessel_position, search_vessels, get_vessel_eta, get_vessel_positions_batch; What they return: The vessel record: type, length, beam, deadweight, gross tonnage, average and deepest draught over the last 31 days. get_vessel_position adds the latest position, declared destination and ETA in one call; the batch tool returns many ships&#39; positions over a time range</li><li><strong>Ports and port calls</strong>. Tools: search_ports, get_port, get_port_inbound, and six port-event tools; What they return: Port records by name or UN/LOCODE; ships declaring the port as destination; arrivals and departures by port, by vessel, by name, across all ports, and a ship&#39;s last call</li><li><strong>Records</strong>. Tools: get_vessel_emissions, list_emissions, get_vessel_casualties; What they return: Emissions and marine casualty records</li></ul><p>Navigational status arrives as the AIS code (1 for “at anchor”, 5 for “moored”), and the agent decoded it without help.</p><h3>Query limits the agent works within</h3><p>List tools return 20 rows by default and up to 50 per page, with a nextToken for the next page. A row in a position search is one report, not one ship, so a page of 50 off Corpus Christi held 28 to 33 distinct ships. Position lists come newest first, so in a busy area a full page covers only a few minutes. Each page is about 24 KB of tool output. No run in the series read every page of every search.</p><p>Area and radius searches cover the last 2 hours by default and at most 4 hours per call. A radius is given in metres, up to 100 km. A bounding box’s height and width in degrees may total at most 4. To page through a search, pass an explicit timeFrom and timeTo and reuse them, with the same box, alongside the nextToken. A short window that one or two pages cover is easiest.</p><p>The port-event tools cover only the last 2 hours unless given timeFrom, and keep events for 31 days; get_vessel_last_port_event has no window. get_port_events_by_vessel takes a sortOrder, which position searches lack. get_port_inbound is ordered by ETA, starts at the current time unless given etaFrom, looks 72 hours ahead unless given etaTo, and by default counts only ships that reported in the last 24 hours.</p><h3>Five prompt details that changed the answers</h3><h4>Rotterdam went from 5 calls to 80 once the prompt allowed them</h4><p>Given the plain backlog question, both Sonnet runs made 5 or 6 calls. Each explained that ship type sits in the vessel record, one lookup per hull, and asked before going on. Haiku went ahead without asking. The prompt that added “look up the type of each ship … and use as many calls as you need” produced counts, and both of its runs reached the 80-request cap. One of them made 11 more lookups after the server’s first refusal told it to stop and tell the user, because the prompt said to keep going. A hard budget is better enforced in the system prompt or in the client.</p><h4>The 18-day Durban wait came from sampling earlier dates</h4><p>The prompt that asked how long each Durban ship had waited got rough bounds from both Sonnet runs. Neither sampled the whole anchorage on earlier dates, and one run’s newest-first history reached back only about an hour. The shorter prompt, which asked only for the longest wait, did sample 20-minute windows on earlier dates and dated a wait of about 18 days. In these runs, naming the method in the prompt (“sample each ship’s position in short windows on earlier days”) would have closed that gap.</p><h4>Durban’s anchorage lay 16 to 37.5 km from the port’s reference point</h4><p>A port record is a reference point in the harbour, so the agent sets its own search area around the anchorage. At Durban one run’s box stopped short and found 7 ships at anchor where the others found about 30. At Rotterdam a wider box held 55 ships at anchor against 31, which nearly doubled the type lookups. Naming the anchorage and its extent in the prompt would avoid both.</p><h4>Draught, ETA and status are crew-entered evidence</h4><p>Destinations, ETAs, draughts and navigational status are all entered by the crew. The deepest draught on a vessel record is the deepest a ship reported in the last 31 days. The agent treated these fields as evidence and gave a confidence for every laden or ballast reading. Declared ETAs also decide what the inbound list shows. Off Corpus Christi, two moored Aframax-size tankers were not on it, since the list starts at the current time and both ships’ declared ETAs had passed. A position search near the port found them where the inbound list could not.</p><h4>Give the agent the time</h4><p>Claude Code gives the agent the date but not the time of day. One Haiku run at Durban reasoned as if it were about 12:05 UTC, when its requests ran at about 19:10, so every wait it gave was about seven hours short. Both Haiku runs in the US Gulf had requests rejected, for a radius over 100 km and for a window over 4 hours. The radius was corrected on the next call and the window on the second retry. Putting the current UTC time and an explicit window in the prompt avoids both.</p><h3>Calls, time and cost per run for Sonnet 5.5 and Haiku 5.5</h3><ul><li><strong>Sonnet 5.5</strong>. Runs: 13; Tool calls per run: 5 to 92; API requests served per run: 5 to 80; Time per run: 26 s to 3 min 53 s; Cost per run: $0.10 to $0.67</li><li><strong>Haiku 5.5</strong>. Runs: 4; Tool calls per run: 31 to 44; API requests served per run: 31 to 44; Time per run: 53 s to 3 min 31 s; Cost per run: $0.016 to $0.22</li></ul><p>Cost is Claude Code’s own estimate per run (total_cost_usd) at API list prices. API requests count against the key&#39;s monthly quota separately. Tool calls exceed requests only in the two runs that reached the cap, where 2 and 12 calls were refused.</p><p>Each Haiku result is one run on one prompt, so the comparison is anecdotal. Haiku went about as deep as Sonnet. It dated 29 of its 32 ships at Durban by sampling small groups so that no page filled, and found the same five inbound Aframaxes in the US Gulf. At Rotterdam it went ahead where Sonnet asked, but its box missed most of the container ships and it counted only records labelled “Container Ship”, so it found one.</p><p><em>Series: Hub · </em><a href="https://vesselapi.com/blog/ai-agent-us-gulf-crude-tankers"><em>Part 1: Are US Gulf crude tankers arriving laden or in ballast?</em></a><em> · </em><a href="https://vesselapi.com/blog/ai-agent-durban-anchorage-wait"><em>Part 2: How long are ships waiting at Durban anchorage?</em></a><em> · </em><a href="https://vesselapi.com/blog/ai-agent-rotterdam-storm-backlog"><em>Part 3: Has the Rotterdam port backlog cleared after Storm Austen?</em></a></p><p>Claude, Claude Code, Sonnet, Haiku and Anthropic are trademarks of Anthropic PBC. Other names are trademarks of their respective owners.</p><p><em>Originally published at </em><a href="https://vesselapi.com/blog/ai-agent-live-shipping-questions"><em>https://vesselapi.com</em></a><em> on November 1, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5982ac3b2d40" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Has the Rotterdam port backlog cleared after Storm Austen?]]></title>
            <link>https://vessel-api.medium.com/has-the-rotterdam-port-backlog-cleared-after-storm-austen-740bf3c716a0?source=rss-00c2102cfd05------2</link>
            <guid isPermaLink="false">https://medium.com/p/740bf3c716a0</guid>
            <category><![CDATA[claude-code]]></category>
            <category><![CDATA[shipping]]></category>
            <category><![CDATA[logistics]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[mcp-server]]></category>
            <dc:creator><![CDATA[VesselAPI]]></dc:creator>
            <pubDate>Sun, 01 Nov 2026 20:23:34 GMT</pubDate>
            <atom:updated>2026-11-05T17:17:53.528Z</atom:updated>
            <content:encoded><![CDATA[<p>Six container ships were at anchor off the Maas entrance, the mouth of the port of Rotterdam, when Claude Sonnet counted them on the evening of 1 November. A second run with a wider search box found ten, and judged that eight of them were waiting for Rotterdam. Storm Austen had closed the port a week earlier, from 23 to 25 October. Asked whether the Rotterdam port backlog had cleared, the second of the two runs that found six wrote that its figures suggest “the storm queue has mostly cleared”. Most of the ships it dated had waited a day or two.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*eAgSw0rxPq8KXT-a.png" /></figure><p><em>Data from live runs on 1 November 2026. Series: </em><a href="https://vesselapi.com/blog/ai-agent-live-shipping-questions"><em>Hub: What can an AI agent answer from an AIS API via an MCP server?</em></a><em> · </em><a href="https://vesselapi.com/blog/ai-agent-us-gulf-crude-tankers"><em>Part 1: Are US Gulf crude tankers arriving laden or in ballast?</em></a><em> · </em><a href="https://vesselapi.com/blog/ai-agent-durban-anchorage-wait"><em>Part 2: How long are ships waiting at Durban anchorage?</em></a><em> · Part 3: Has the Rotterdam port backlog cleared after Storm Austen?</em></p><p><a href="https://mykn.kuehne-nagel.com/news/article/port-operational-updates-from-27-10-2026">Kuehne+Nagel’s port update for 21 to 27 October</a> reported that the port “experienced full closure from 23 to 25 October due to high winds and bad weather”. It also reported yard occupancy of 85% at ECT (Europe Container Terminals) and barge and feeder delays of 12 to 72 hours there. A feeder is a smaller container ship that shuttles boxes between hub ports and regional ones. The same update gave a 7-day average vessel waiting time of 1.36 days. A <a href="https://mykn.kuehne-nagel.com/news/article/storm-austen-halts-terminal-operations-on">separate Kuehne+Nagel article on Storm Austen</a> said operations at APM Terminals Rotterdam were “set to be temporarily suspended over the weekend due to forecast high winds”.</p><h3>Six runs, three prompts, one 80-request cap</h3><p>Each run was one non-interactive call to Claude Code in an empty directory. Its only tools were the 19 from vesselapi-mcp, the MCP server for the VesselAPI AIS API. They return live positions, vessel records, port calls and the ships declaring a port as their destination. User settings and Claude Code&#39;s built-in tools were switched off. The <a href="https://vesselapi.com/blog/ai-agent-live-shipping-questions">hub</a> has the setup and the full method. We capped every run at 80 API requests for the test, and each count below is what that one run found.</p><p>The plainest prompt asked for three numbers:</p><blockquote><em>Rotterdam’s container terminals were closed by a storm from 23 to 25 October. Has the backlog cleared? How many container ships have arrived since the terminals reopened, how many are still waiting at anchor, and how many are due in the next three days?</em></blockquote><p>The second prompt added one instruction:</p><blockquote><em>Look up the type of each ship at the anchorages off the Maas entrance so you can separate container ships from the rest, and use as many calls as you need.</em></blockquote><p>A third prompt asked for the ships themselves:</p><blockquote><em>Rotterdam’s container terminals were closed by a storm from 23 to 25 October. Which container ships are waiting at the anchorages off the Maas entrance right now, how long has each been waiting, and when are they due alongside?</em></blockquote><ul><li><strong>Queue</strong>. Model: Sonnet; Prompt: which ships, how long; Tool calls: 30; API requests served: 30; Time: 70 s; Container ships at anchor: 6</li><li><strong>Plain 1</strong>. Model: Sonnet; Prompt: three numbers; Tool calls: 6; API requests served: 6; Time: 34 s; Container ships at anchor: no count</li><li><strong>Plain 2</strong>. Model: Sonnet; Prompt: three numbers; Tool calls: 5; API requests served: 5; Time: 26 s; Container ships at anchor: no count</li><li><strong>Plain 3</strong>. Model: Haiku; Prompt: three numbers; Tool calls: 32; API requests served: 32; Time: 68 s; Container ships at anchor: 1 (called a floor)</li><li><strong>Typed A</strong>. Model: Sonnet; Prompt: look up each type; Tool calls: 82; API requests served: 80; Time: 149 s; Container ships at anchor: 10, of which 8 for Rotterdam</li><li><strong>Typed B</strong>. Model: Sonnet; Prompt: look up each type; Tool calls: 92; API requests served: 80; Time: 233 s; Container ships at anchor: 6</li></ul><p>Both typed runs reached the cap. Typed A had 2 calls refused and Typed B had 12. Each refused call returned the server’s message to stop and tell the user. Typed B made 11 more lookups after the first refusal, one after another, because the prompt’s “use as many calls as you need” outweighed it. For agent builders, a hard budget belongs in the system prompt or in the client.</p><h3>Two Sonnet runs declined to count</h3><p>Sonnet took the plain prompt twice and stopped after 6 and 5 calls. Both read one page of port events, the first page or two of the inbound list and one position search. A ship’s type sits in its vessel record by design, one get_vessel call per ship, and both runs worked out that a full count meant hundreds of such calls. The first wrote: &quot;That&#39;s a few hundred calls, mostly lookups. Do you want me to go ahead, and can you confirm the reopening time?&quot;</p><p>The first run went as far as “My provisional read is that the backlog looks cleared.” It rested that on 50 position rows within 30 km of the entrance. The second asked for 5 rows and wrote: “I haven’t counted the anchorage, so I can’t say it has cleared.” In claude -p that question ended the run.</p><h3>Why Haiku found one container ship</h3><p>Haiku took the same prompt further. It read one page of 50 position rows from a box over the anchorage, reported 23 ships at anchor and looked up 22 of their types. It reported one container ship and called that figure a floor.</p><p>Two things set its count apart from the Sonnet runs. Its box stopped short of the western anchorage, where four of the other runs’ six container ships lay. Inside its box was a second ship the Sonnet runs counted. It was over 350 m long with a generic “Cargo” label. Haiku put that one with the “other cargo vessels”. It counted only the record that said “Container Ship”.</p><h3>Telling a container ship from a “Cargo” record</h3><p>AIS ship-type codes, set out in ITU-R M.1371, have no value for container ships. Their cargo-ship group (codes 70 to 79) covers container ships, bulk carriers and general cargo ships alike, and many vessel records carry the matching “Cargo” labels. Of the six ships Typed B counted, one was labelled “Container Ship”. It placed the other five, in its words, by “name, length and tonnage”, and marked them “probable”.</p><p>Gross tonnage (GT) measures a ship’s enclosed volume. Deadweight tonnage (dwt) is the weight it can carry, cargo and fuel included. A container ship carries light, bulky cargo, so its dwt stays close to its GT. A bulk carrier’s dwt runs to nearly twice its GT. The ratios below come from the get_vessel records of the &quot;Cargo&quot; ships Typed B looked up at anchor.</p><ul><li><strong>Over 350 m</strong>. dwt ÷ GT: 0.9; Typed B: counted</li><li><strong>Over 350 m</strong>. dwt ÷ GT: 1.0; Typed B: counted</li><li><strong>330 to 340 m</strong>. dwt ÷ GT: 1.1; Typed B: counted</li><li><strong>330 to 340 m</strong>. dwt ÷ GT: 1.2; Typed B: counted</li><li><strong>About 250 m</strong>. dwt ÷ GT: 1.3; Typed B: counted</li><li><strong>About 150 m</strong>. dwt ÷ GT: 0.9; Typed B: not counted</li><li><strong>About 170 m</strong>. dwt ÷ GT: 1.2; Typed B: not counted</li><li><strong>200 to 230 m (two ships)</strong>. dwt ÷ GT: 1.8 and 1.9; Typed B: not counted</li></ul><p>The ratio separates the two 200 to 230 m ships, which Typed A’s emissions check below identified as bulk carriers. It does less at the small end. Two ships under 200 m had ratios of about 0.9 and 1.2, inside or below the counted range, and were left out on size and name. Low ratios are not unique to container ships either. Two inbound ships Typed B took for car carriers had dwt near 0.4 times GT, and it left them out as car carriers.</p><p>Typed A added a second check. Ships of 5,000 GT and over calling at EU and EEA ports report their annual emissions under the EU MRV (monitoring, reporting and verification) rules, and get_vessel_emissions returns that report with a ship type. Typed A called it once for each of the 11 anchored ships with a &quot;Cargo&quot; label. Seven of the 11 returned a report: five said container ship and two bulk carrier. One of the five was a ship of about 150 m that the size test alone would have left in doubt. The 11 calls came out of the same 80-request budget, and the run reached the cap before it had looked up any inbound ship.</p><h3>Search box size and the Rotterdam port backlog count</h3><p>The queue run and Typed B searched overlapping boxes off the entrance and found the same six container ships, though in the queue run one of them was drifting at about 1 knot and not formally at anchor. The queue run got the types from get_vessel_position, which returns the vessel record with the position. Typed A&#39;s box reached further south and north, and it found 55 ships at anchor against Typed B&#39;s 31. All four of its extra container ships lay outside Typed B&#39;s box. Typed A then removed two of them from the backlog, on the evidence of their recent port events. It wrote: &quot;They are probably waiting for their next port, so I&#39;d leave them out of the Rotterdam backlog.&quot;</p><p>That leaves eight for Typed A and six for Typed B. The difference is two ships in the southern part of Typed A’s box, a container ship of 170 to 190 m and a smaller one.</p><h3>How long the container ships had waited</h3><p>AIS gives a ship’s status at each report, and the time it dropped anchor has to be worked out from earlier data. Both typed runs dated waits from port events instead of position history. They took each ship’s last departure from get_port_events_by_vessel and added a likely passage time. Typed B called the results &quot;rough estimates from each ship&#39;s last port departure plus sailing time&quot;.</p><p>By that method most of the six had waited one to two days. Two ships over 350 m had upper bounds of about five and six days, which would put them at anchor since soon after the closure. Typed B wrote: “These two may be the only holdovers from it.”</p><p>Typed A agreed on the larger of those two and named a second holdover, the mid-size container ship from its wider box. It could not date two ships, because their port-event calls came after the cap. It said the rest “looks like a normal queue”.</p><p>Both runs that pulled position history ran into the same ordering. Positions come newest first. The queue run asked for 50 rows for four ships since 25 October and got the last 41 minutes. Typed B asked for 5 rows for one ship and got the last 24 minutes. To see whether a ship was at anchor days earlier, a call needs a timeTo inside that earlier window. <a href="https://vesselapi.com/blog/ai-agent-durban-anchorage-wait">Part 2</a> uses that sampling method in full at Durban. The queue run gave no waits, and proposed the same fix: &quot;poll this area on a schedule and record when each ship first shows zero speed and anchored status.&quot;</p><h3>The inbound list to 4 November</h3><p>get_port_inbound for NLRTM looks 72 hours ahead by default and is ordered by ETA. Typed B read all of it: three pages and 148 rows through the afternoon of 4 November. In the agent&#39;s reading they were mostly barges and small tankers. It looked up 35 ships. Seven records said &quot;Container Ship&quot;. It rated five more probable by size or name and left two unconfirmed. Its answer was &quot;12 to 14&quot; container ships due, about nine of them by the end of 2 November. Twelve lookups were refused by the cap, and it listed nine inbound ships it had not checked.</p><p>Typed A read one page, ETAs to early on 2 November, and gave no number. Haiku set etaFrom to midnight, so its two pages held ETAs from earlier that day, mostly before the run started. Typed B and Haiku both described the ETAs as the ships&#39; own declarations.</p><p>The anchorage and the inbound list compete for one budget. Typed A spent 54 lookups on its 55 anchored ships and 11 on emissions reports, and had none left for the list. Typed B’s smaller box left room for 35 inbound lookups, and it still ran out about a dozen short.</p><p><em>Series: </em><a href="https://vesselapi.com/blog/ai-agent-live-shipping-questions"><em>Hub: What can an AI agent answer from an AIS API via an MCP server?</em></a><em> · </em><a href="https://vesselapi.com/blog/ai-agent-us-gulf-crude-tankers"><em>Part 1: Are US Gulf crude tankers arriving laden or in ballast?</em></a><em> · </em><a href="https://vesselapi.com/blog/ai-agent-durban-anchorage-wait"><em>Part 2: How long are ships waiting at Durban anchorage?</em></a><em> · Part 3: Has the Rotterdam port backlog cleared after Storm Austen?</em></p><p>Claude, Claude Code, Sonnet, Haiku and Anthropic are trademarks of Anthropic PBC. Other names are trademarks of their respective owners.</p><p><em>Originally published at </em><a href="https://vesselapi.com/blog/ai-agent-rotterdam-storm-backlog"><em>https://vesselapi.com</em></a><em> on November 1, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=740bf3c716a0" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How long are ships waiting at Durban anchorage?]]></title>
            <link>https://vessel-api.medium.com/how-long-are-ships-waiting-at-durban-anchorage-83582b14f89c?source=rss-00c2102cfd05------2</link>
            <guid isPermaLink="false">https://medium.com/p/83582b14f89c</guid>
            <category><![CDATA[data-analysis]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[claude-code]]></category>
            <category><![CDATA[logistics]]></category>
            <category><![CDATA[shipping]]></category>
            <dc:creator><![CDATA[VesselAPI]]></dc:creator>
            <pubDate>Sun, 01 Nov 2026 20:22:45 GMT</pubDate>
            <atom:updated>2026-11-09T11:38:33.917Z</atom:updated>
            <content:encoded><![CDATA[<p>On the evening of 1 November, UTC, four Claude runs measured Durban port congestion by counting the ships at Durban anchorage. They found 29, 7, 31 and about 32. The longest wait any run dated was about 18 days, for one ship. That figure came from the run whose prompt asked only for the longest wait. The two Sonnet runs that tried to date every ship gave rough bounds, and one called the rest of its waits unknown. Haiku dated nearly every ship, but reasoned as if it were seven hours earlier than it was.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*ADJ4TkPsv8GSjGaG.png" /></figure><p><em>Data from live runs on 1 November 2026. Series: </em><a href="https://vesselapi.com/blog/ai-agent-live-shipping-questions"><em>Hub: What can an AI agent answer from an AIS API via an MCP server?</em></a><em> · </em><a href="https://vesselapi.com/blog/ai-agent-us-gulf-crude-tankers"><em>Part 1: Are US Gulf crude tankers arriving laden or in ballast?</em></a><em> · Part 2: How long are ships waiting at Durban anchorage? · </em><a href="https://vesselapi.com/blog/ai-agent-rotterdam-storm-backlog"><em>Part 3: Has the Rotterdam port backlog cleared after Storm Austen?</em></a></p><p><a href="https://mykn.kuehne-nagel.com/news/article/port-operational-updates-from-27-10-2026">Kuehne+Nagel&#39;s port update of 27 October</a> described Durban as severely congested. Its 7-day average vessel waiting time was about 7.3 days.</p><h3>Two Durban prompts on Sonnet and Haiku</h3><p>The short prompt ran once, on Sonnet 5.5:</p><blockquote><em>How many ships are waiting at Durban anchorage right now, and what is the longest current wait?</em></blockquote><p>The longer prompt ran twice on Sonnet 5.5 and once on Haiku 5.5:</p><blockquote><em>How many ships are waiting at Durban anchorage right now, and how long has each of them been waiting? Work out the waits as well as you can from the data, and tell me how you did it.</em></blockquote><p>Each run was one non-interactive call to Claude Code in an empty directory. Its only tools were the 19 from vesselapi-mcp, <a href="https://vesselapi.com/docs/developer-tools#mcp-server">the MCP server for the VesselAPI AIS API</a>. They return live positions, vessel records, port calls and the ships declaring a port as their destination. User settings and Claude Code&#39;s built-in tools were switched off. The <a href="https://vesselapi.com/blog/ai-agent-live-shipping-questions">hub</a> has the setup and the full method. A proxy refused requests beyond 80 per run. None of these four runs came near that cap, and each count below is what that one run found.</p><h3>Four counts of the Durban anchorage queue</h3><ul><li><strong>Short prompt, Sonnet</strong>. Area searched: After a 20 km circle, a box about 8 km south to 29 km north of the reference point; Pages read (minutes covered): 2 pages, about 10 minutes; At anchor: 29; Waits: Four traced, longest about 18 days; Requests: 22; Time: 85 s</li><li><strong>Long prompt, Sonnet run 1</strong>. Area searched: Box from 19 km south to 14 km north; Pages read (minutes covered): 3 pages, about 8 minutes; At anchor: 7; Waits: Bounds from port events, declared ETAs and passage times; Requests: 19; Time: 58 s</li><li><strong>Long prompt, Sonnet run 2</strong>. Area searched: After a 25 km circle, a box from 3 km to 36 km north; Pages read (minutes covered): 2 pages, about 10 minutes; At anchor: 31; Waits: Upper bounds for 7 ships; Requests: 23; Time: 65 s</li><li><strong>Long prompt, Haiku</strong>. Area searched: A 25 km circle, then an 8 km circle and a box offshore; Pages read (minutes covered): Around midday only; At anchor: about 32; Waits: 29 of 32 dated; Requests: 31; Time: 211 s</li></ul><p>&quot;At anchor&quot; means AIS navigational status 1, which the crew sets. Each count is what that run found. Every run ended with more pages left unread, and each said its count was a minimum. The short-prompt run put it as &quot;Treat 29 as a floor.&quot;</p><p>Across the four runs, 33 distinct ships reported status 1. The short-prompt run&#39;s 29 all appear in Sonnet run 2&#39;s 31. Haiku shared 27 of them, from positions seven hours older.</p><h3>Where Durban anchorage lies</h3><p>get_port with the code ZADUR returned Durban&#39;s record, a reference point in the harbour, and each run drew its own search area around it. The ships at anchor lay between about 16 and 37.5 km from that point, offshore to the north-east.</p><p>Sonnet run 1 drew a box whose northern edge sat about 14 km north of the reference point. Of the 29 ships the short-prompt run found, 22 lay north of that edge. Run 1 saw the problem itself. It noted that its northern edge cut through the anchorage and that ships anchored further north would not have shown up.</p><p>Two or three pages of 50 reports covered about ten minutes of traffic. In those minutes the three runs that covered the anchorage found 29 to 32 ships, out of 33 seen across all four. A ship that stayed silent in those minutes, or set another status, was missed.</p><h3>How a wait is dated from sampled positions and port events</h3><p>An AIS position report gives a ship&#39;s status and position at one moment. Dating a wait means finding when that status began, from earlier samples.</p><p>Position history comes back newest first. An open window such as &quot;from 25 October&quot; returns the most recent reports and stops at the page limit. In Sonnet run 2, one page for three ships covered a little over an hour. The runs that went further back asked for a short window on each earlier date. Each window gives one sample of where the ship was that day.</p><p>Samples become bounds. The earliest sample showing the ship at the same spot at anchor is a lower bound. The last sample before that, showing it under way or at a berth, gives an upper bound. Together they make a range. A ship missing from a sample proves nothing, since it may not have reported in those minutes. Haiku put it in one line: &quot;Absence from a 10-minute sample doesn&#39;t prove a ship wasn&#39;t there.&quot;</p><p>Port events add a second bound. They record arrivals and departures at a port. A ship that left a Durban berth three days ago has waited three days at most. Declared ETAs (estimated times of arrival) give a third, weaker bound, since the crew enters them.</p><h3>The short prompt found a wait of about 18 days</h3><p>The short-prompt run counted 29 ships in two pages. It gave the longest wait as &quot;[ship A] at roughly 18 days, though that rests on sampled positions, so it isn&#39;t exact.&quot;</p><p>The run then sent one get_vessel_positions_batch call per sample date for all 29 ships, each a 20-minute window around midday on 30, 26 and 22 October. It followed five candidates back through four more dates to 8 October, and single ships to 1 October. One earlier sample returned no report for [ship A], so its earliest position in the run is from mid-October.</p><ul><li><strong>[ship A]</strong>. Agent&#39;s estimate: about 18 d; Basis in the samples: Under way in mid-October, then at anchor at the same spot in every sample since. No port event in 31 days.</li><li><strong>[ship B]</strong>. Agent&#39;s estimate: about 15 d; Basis in the samples: At a berth earlier in October, then at anchor.</li><li><strong>[ship C]</strong>. Agent&#39;s estimate: at most about 13 d; Basis in the samples: In another port in mid-October, at anchor by late October.</li><li><strong>[ship D]</strong>. Agent&#39;s estimate: about 7 d; Basis in the samples: At anchor through most of October, then a short port call, then back at anchor.</li></ul><p>[ship D] shows a choice the agent made. It counted only the current stretch at anchor, so the ship&#39;s month off Durban came out at about 7 days. The agent called its dates &quot;accurate to a day or two&quot;.</p><p>It left most of the 29 undated. &quot;I only traced the vessels that looked longest-staying,&quot; it wrote. It treated ships missing from the 22 October sample as later arrivals. That sample was a full page of 50 reports and covered only 13 of the 29 ships. Some of the missing ships may have been cut off by the page limit.</p><h3>Two Sonnet runs that tried to date every ship</h3><p>Run 1 opened with &quot;Seven ships are at anchor off Durban right now, but I can&#39;t give exact wait times for any of them.&quot; Its box had cut off most of the anchorage. It read each ship&#39;s last port event and its declared ETA. For one ship it reasoned from passage time instead, about 15 days at 13 knots from its last port. Its estimates ran from at least 5 hours to at most about 12 days. Three rested on ETAs, and it flagged them: &quot;Each ship sets its own ETA, so those three are upper bounds at best.&quot;</p><p>Run 2 counted 31 ships and called get_vessel_last_port_event for 16 of them. Four returned no event. Among the others, six had left a Durban berth between 17 and 29 October. Those departures put their waits at about 3 to 15 days at most. Five had last left a port two to three weeks earlier, and it noted that passage time used up most of those bounds. It judged that paging back through 31 ships&#39; histories would take too many calls. Its summary said &quot;For the rest, the real waits are unknown.&quot;</p><p>Run 2 read position windows that return the newest reports first, so its history reached back about an hour. Run 1 asked for a few earlier windows for single ships but never sampled the whole group on earlier dates. The short prompt, with less asked of it, went further back.</p><h3>Haiku dated 29 of 32 ships, reasoning as if it were midday</h3><p>Haiku listed about 32 ships and gave 29 of them a wait or a range. &quot;Most have been waiting at least three days,&quot; it wrote. It split the ships into groups of 7, 13 and 12 and sampled each group in 6- to 10-minute windows. Those windows never filled a page, so no ship dropped out of a sample because of the page limit.</p><p>Its first sample for the larger groups was 27 October. Ten ships came out at &quot;≥ 5.0 d&quot;, the time from that first sample to midday on 1 November. [ship A], dated at about 18 days by the short-prompt run, is one of the ten.</p><p>Haiku also reasoned as if it were &quot;about 12:05 UTC&quot;. The requests log shows its calls ran a little after 19:10 UTC. Claude Code gives the agent the date but not the time, so the model guessed the hour. Its area searches all ended at midday, so its count describes the anchorage seven hours earlier. Each wait it gave is about seven hours short. For agent builders, the fix is to state the current UTC time in the prompt.</p><h3>The same ships across the runs</h3><ul><li><strong>[ship A]</strong>. Short prompt: about 18 d; Sonnet run 1: outside its box; Sonnet run 2: no port event; Haiku: at least 5 d</li><li><strong>[ship B]</strong>. Short prompt: about 15 d; Sonnet run 1: outside its box; Sonnet run 2: not checked; Haiku: at least 5 d</li><li><strong>[ship D]</strong>. Short prompt: about 7 d; Sonnet run 1: outside its box; Sonnet run 2: at most about 7.5 d; Haiku: at least 5 d</li><li><strong>[ship E]</strong>. Short prompt: not traced; Sonnet run 1: at most about 10 d, from ETA; Sonnet run 2: no port event; Haiku: at least 7 d</li><li><strong>[ship F]</strong>. Short prompt: not traced; Sonnet run 1: at least about 47 h; Sonnet run 2: not checked; Haiku: 5.0 to 5.3 d</li></ul><p>The runs disagree less than their headlines suggest. Most cells are bounds from different evidence, and they overlap. Put together, [ship E] has waited about 7 to 10 days, if its declared ETA was right. That range is our combination of two runs, and no single run gave it.</p><h3>Durban port congestion against Kuehne+Nagel&#39;s figures</h3><p>Kuehne+Nagel gave delays of 0 to 17 days depending on terminal. It also gave a 10.5-day average anchorage wait at one container terminal. The short-prompt run&#39;s two longest waits were about 18 and 15 days. The 15 sits inside that spread and the 18 slightly above it. Haiku wrote that most ships had waited at least three days, counted to midday.</p><p>The populations differ. Kuehne+Nagel&#39;s 10.5 days covers one container terminal. The agents counted every ship at anchor off the port, including ships waiting after a completed port call. AIS position reports do not say which terminal a ship is waiting for.</p><p><em>Series: </em><a href="https://vesselapi.com/blog/ai-agent-live-shipping-questions"><em>Hub: What can an AI agent answer from an AIS API via an MCP server?</em></a><em> · </em><a href="https://vesselapi.com/blog/ai-agent-us-gulf-crude-tankers"><em>Part 1: Are US Gulf crude tankers arriving laden or in ballast?</em></a><em> · Part 2: How long are ships waiting at Durban anchorage? · </em><a href="https://vesselapi.com/blog/ai-agent-rotterdam-storm-backlog"><em>Part 3: Has the Rotterdam port backlog cleared after Storm Austen?</em></a></p><p>Claude, Claude Code, Sonnet, Haiku and Anthropic are trademarks of Anthropic PBC. Other names are trademarks of their respective owners.</p><p><em>Originally published at </em><a href="https://vesselapi.com/blog/ai-agent-durban-anchorage-wait"><em>https://vesselapi.com</em></a><em> on November 1, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=83582b14f89c" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Beyond the CO₂ Number]]></title>
            <link>https://vessel-api.medium.com/beyond-the-co%E2%82%82-number-7b99780399bc?source=rss-00c2102cfd05------2</link>
            <guid isPermaLink="false">https://medium.com/p/7b99780399bc</guid>
            <category><![CDATA[decarbonization]]></category>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[sustainability]]></category>
            <category><![CDATA[shipping]]></category>
            <category><![CDATA[carbon-emissions]]></category>
            <dc:creator><![CDATA[VesselAPI]]></dc:creator>
            <pubDate>Wed, 23 Sep 2026 17:53:42 GMT</pubDate>
            <atom:updated>2026-09-23T23:15:14.892Z</atom:updated>
            <content:encoded><![CDATA[<p>Methodology Note 01 · Open Emission</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uaYGm5JSkMRnO_BGn4Elww.png" /></figure><p>A smarter way to understand ship emissions — how public emissions data becomes more useful through context, benchmarking, and trajectory.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*3rOilJMQXYGpSR9X.jpg" /></figure><blockquote><strong><em>Plain question:</em></strong><em> how does a ship perform compared with other ships that are genuinely comparable to it?</em></blockquote><blockquote><strong><em>The method, in short:</em></strong><em> cluster ships into real peer groups → score each ship’s carbon intensity within its group → roll ship scores up into an operator score against operators with similar fleets → track each ship’s year-on-year trend against its peers.</em></blockquote><blockquote><strong><em>The point:</em></strong><em> the value of the EU MRV dataset isn’t the raw number. It’s the context around it.</em></blockquote><h3>01 / Context — Why shipping emissions need more than a number</h3><p>The shipping industry has no shortage of emissions data. Since the introduction of the EU Monitoring, Reporting and Verification (EU MRV) framework, enormous amounts of information on ship fuel consumption and CO₂ emissions have become publicly available. The data is valuable — but raw data alone does not necessarily tell us which ships are genuinely performing well, which operators are falling behind, or where the greatest decarbonisation opportunities exist.</p><p>A simple ranking of ships by total CO₂ emissions is a good example. A large bulk carrier will almost certainly emit more CO₂ than a much smaller vessel. But does that mean it is less efficient? Not necessarily. The larger vessel is also carrying more cargo and travelling in a different operational context. Comparing the two purely on total emissions is therefore not really a comparison of efficiency — it is a comparison of scale.</p><blockquote><strong><em>The central question:</em></strong><em> how does a ship perform compared with other ships that are genuinely comparable to it? That question is at the heart of the Open Emission methodology.</em></blockquote><h3>02 / Peer benchmarking — start with the right peers, not the biggest numbers</h3><p>The first principle is simple: like should be compared with like. Open Emission uses machine-learning-based clustering to create peer groups within each ship type. The clustering considers characteristics such as vessel size and age, allowing ships to be compared against vessels operating within a similar population.</p><p>A 32,000-DWT bulk carrier should not be judged against a 180,000-DWT bulk carrier simply because both are classified as bulk carriers. Likewise, a five-year-old container ship should not automatically be compared with a twenty-year-old vessel.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*aNi8a86vj5ENS3qi.png" /></figure><p>Once these peer groups are established, the analysis looks at carbon intensity rather than simply total emissions. Each ship’s CO₂ performance is positioned against its peer group, producing a percentile-based performance ranking.</p><pre>instead of: &quot;How much CO2 does this ship emit?&quot;<br>        ask: &quot;How does this ship perform against vessels like itself?&quot;</pre><h3>03 / Operator scoring — from ship performance to operator performance</h3><p>Ships do not operate in isolation. A company’s environmental footprint is ultimately determined by the characteristics and performance of the fleet it operates. This creates another problem: comparing operators using only total fleet emissions can be just as misleading as comparing ships by total emissions.</p><p>A company operating a large fleet of older, heavy-emitting vessels will naturally have a larger absolute footprint than a company operating a small modern fleet.</p><blockquote><strong><em>The operator question:</em></strong><em> how does an operator perform relative to other operators with similar fleets?</em></blockquote><p>Open Emission addresses this in two stages.</p><ul><li><strong>Score the ships</strong> — individual ship performance is converted into comparable percentile scores. These ship-level scores are then aggregated into an operator-level score using a weighted approach.</li><li><strong>Find real peers</strong> — a fleet fingerprint captures the characteristics of the operator’s fleet, and cosine similarity identifies companies operating broadly similar fleets.</li></ul><p>This means an operator is not simply compared with an arbitrary list of shipping companies. Instead, the analysis asks a more meaningful benchmark question — <em>how are you performing compared with companies operating a fleet similar to yours?</em> — which is a far more useful basis for strategic decision-making.</p><h3>04 / Year-on-year trend — improvement matters as much as current performance</h3><p>A ship’s current position tells only part of the story. Imagine two vessels that currently have similar carbon intensity. One has been steadily improving year after year, while the other has remained essentially unchanged. Looking at one year’s data, they may appear similar. Looking at their trajectory, they are very different.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*GkU1zy5DmQKbQI9R.png" /></figure><p>Open Emission therefore examines year-on-year carbon-intensity performance and calculates the rate at which a ship is improving. This is expressed as Carbon Annual Intensity Reduction (CAIR). The important part is not only the ship’s improvement rate, but how that rate compares with its peers.</p><p>A ship operating within a sector where most vessels are improving slowly may become a leader by improving slightly faster than the group. Conversely, a ship in a rapidly improving peer group may actually be falling behind even if its own emissions are declining.</p><blockquote><strong><em>The principle:</em></strong><em> progress has to be measured in context.</em></blockquote><h3>05 / Analytical structure — the value is not the algorithm, it is the context</h3><p>Terms such as clustering, percentile scoring, cosine similarity and regression can make emissions analysis sound complicated. But the objective is actually straightforward: transform a very large public dataset into answers to practical maritime questions.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tWBsFcezZcSV3gneS-biBw.png" /></figure><p>That is the difference between data and intelligence. The EU MRV dataset provides the data. Statistical and machine-learning methods provide the structure. But the real value comes from putting the results into a maritime context that owners, operators, technical teams, charterers, investors and regulators can actually use.</p><h3>06 / Decision context — from reporting to insight</h3><p>For years, shipping has focused heavily on collecting environmental data. The next step is learning how to use it better. A database containing millions of records is not, by itself, a decarbonisation strategy. The real opportunity comes from asking better questions of the data:</p><ul><li>Who are my true peers?</li><li>Where do I stand against them?</li><li>Am I improving faster or slower than the market?</li></ul><p>It is an attempt to move maritime emissions analysis away from raw numbers and towards context, benchmarking, trajectory and action. Because in shipping, the most important number is rarely the number by itself.</p><blockquote><strong><em>Final thought:</em></strong><em> it is what that number means.</em></blockquote><p><em>Source methodology: </em><a href="https://openemission.com/methodolgy.html"><em>openemission.com/methodolgy.html</em></a></p><p><em>Originally published at </em><a href="https://vesselapi.com/blog/open-emission-beyond-the-co2-number"><em>https://vesselapi.com</em></a><em> on September 23, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7b99780399bc" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The slowest requests on our API were the ones that found nothing]]></title>
            <link>https://vessel-api.medium.com/the-slowest-requests-on-our-api-were-the-ones-that-found-nothing-ed0c32cba02e?source=rss-00c2102cfd05------2</link>
            <guid isPermaLink="false">https://medium.com/p/ed0c32cba02e</guid>
            <category><![CDATA[performance]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[timescaledb]]></category>
            <category><![CDATA[postgresql]]></category>
            <category><![CDATA[database]]></category>
            <dc:creator><![CDATA[VesselAPI]]></dc:creator>
            <pubDate>Thu, 17 Sep 2026 19:47:31 GMT</pubDate>
            <atom:updated>2026-09-17T20:58:59.180Z</atom:updated>
            <content:encoded><![CDATA[<p>On 9 September 2026, asking our API for the most recent port event of a vessel took 28 ms if the vessel had one and 2.0 to 2.5 s if it had none. Asking for the ETA of a vessel whose IMO we could not map to an MMSI took 12 s on a quiet database and 30 s or more on a busy one, until the statement timeout ended it. Over one night, requests that ended in “not found” used 71% of all the database time spent on the ETA endpoint.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*NKan-ta1u5F39ZnX.png" /></figure><p>All times below are UTC. Query timings are server-side, from EXPLAIN ANALYZE or the API request log, on TimescaleDB 2.25 and PostgreSQL 16 unless a line says otherwise. A vessel has two identifiers in this post: the MMSI, a radio identity that every AIS message carries, and the IMO number, a hull identity that many messages and records lack.</p><p>In March I wrote about <a href="https://vesselapi.com/blog/mongodb-to-timescaledb-migration">moving this platform to TimescaleDB</a>. That post describes vessel_positions: one-hour chunks, compressed after two hours, segmented by MMSI, ordered by time. Positions are mostly read by MMSI and time, which is what those settings are for. port_events and vessel_eta got the same settings. Both are queried by other columns. The settings were wrong for both.</p><h3>Which requests were slow</h3><p>Our latency dashboard averaged by raw path, so a single 40 s call to one port sat at the top of the “slowest paths” panel and told us nothing. Grouping by route template (/v1/vessel/{id}/eta, not /v1/vessel/9123456/eta) over six hours showed something else. Two port-event routes were slow in the median, and only when the answer was empty. A port with recent events answered in 10 to 100 ms. A small port with no events in the requested window took 3.6 to 4.3 s, every time. No external call sits on those paths and there was no cache to miss, so the difference was in how many chunks the query touched.</p><h3>Why a miss visited every chunk</h3><p>port_events had 764 one-hour chunks, and 760 of them were compressed (the columnstore, in current TimescaleDB terms) with these settings:</p><pre>ALTER TABLE port_events SET (<br>  timescaledb.compress,<br>  timescaledb.compress_segmentby = &#39;vessel_mmsi&#39;,<br>  timescaledb.compress_orderby   = &#39;timestamp DESC&#39;<br>);</pre><p>Our migrations use these older names throughout. Current TimescaleDB spells them timescaledb.enable_columnstore, timescaledb.segmentby and timescaledb.orderby, and elsewhere in this post convert_to_rowstore, remove_columnstore_policy and hypertable_columnstore_stats.</p><p>The query for the most recent port event filters on IMO, with a time bound wide enough to cover every chunk, so the bound prunes nothing:</p><pre>SELECT ... FROM port_events<br>WHERE vessel_imo = $1 AND timestamp &gt; now() - $2::interval<br>ORDER BY timestamp DESC LIMIT 1;</pre><p>The table has a btree index on (vessel_imo, timestamp). On a compressed chunk that index is still defined and holds nothing. The rows live in an internal table whose only index covers the segment column and the time metadata, so only the four or so uncompressed chunks at the head of the table had a usable IMO index. With ORDER BY timestamp DESC LIMIT 1 the executor visits chunks newest first and stops at the first row, so a vessel with a recent event returns quickly. A vessel with no event has no first row, and the plan for a miss had one sequential scan per compressed chunk. The same miss by MMSI, the segment column, took 0.12 s on the same chunks.</p><p>Compression packs up to a thousand rows of one segment into a batch and compresses each column across them. TimescaleDB 2.25 also keeps a bloom filter per batch for columns such as vessel_imo, which should let a miss skip most batches without decompressing them. The per-chunk node looks like this (from our local rehearsal of vessel_eta on 2.28, time conditions elided; production on 2.25 has the same shape with bloom1_contains):</p><pre>-&gt;  Custom Scan (ColumnarScan) on _hyper_3_1664_chunk (actual rows=1 loops=1)<br>      Vectorized Filter: ((imo = $1) AND ...)<br>      Rows Removed by Filter: 29<br>      ...<br>      -&gt;  Seq Scan on compress_hyper_4_1739_chunk (actual rows=29 loops=1)<br>            Filter: (_timescaledb_functions.bloom1_contains_any_hashes(_ts_meta_v2_bloomh_imo, ARRAY[_timescaledb_functions.bloom1_hash($1)]) AND ...)<br>            Rows Removed by Filter: 48<br>            ...</pre><p>The inner scan reads the chunk’s batches and tests the bloom filter and the time metadata, which rejected 48 of 77 batches here. The other 29 were decompressed and the outer filter kept one row (the rehearsal seed has about one row per batch). In production the filter skipped far less, for a reason in our data. A large minority of port_events rows carry no IMO. A batch whose IMO values are all NULL carries a NULL bloom filter, and a NULL filter passes every lookup.</p><p>The by-port query had a second problem, in our SQL. It compared the port code with COALESCE((SELECT ...), $1), a subquery that resolves an alternative code. TimescaleDB pushes many right-hand expressions down to the bloom filter, and in the versions we ran COALESCE is not among them. Ours was skipped, and every batch in every chunk of the requested window was decompressed: 6.05 s for a port that had two rows. The same query with a plain bound parameter took 0.84 s. We now resolve the code in Go first and pass it as a parameter, and a test asserts that the generated SQL contains no subquery.</p><h3>Compression saved 0.1% on port_events</h3><p>hypertable_compression_stats(&#39;port_events&#39;) reported 1,689 MB before compression and 1,687 MB after.</p><p>Segmenting by MMSI inside a one-hour chunk means a batch holds one vessel’s port events for one hour. A vessel rarely has more than one, and most of our segments held a single row, so there was nothing to compress across. On vessel_eta, where a vessel reports many times an hour, the same settings give 3.0x.</p><p>Compression cost us the use of every index except the one on the segment column, across 760 chunks, and returned 2 MB. The options we looked at:</p><ul><li>Add vessel_imo and port_unlo_code to segmentby. Lookups by those columns would get an index on the compressed chunk, and the segments would become smaller still. We did not test it. With one row per segment there was no compression to keep, and that index would have been a slower copy of the btree we already had.</li><li>A negative cache in the API for misses. We checked the request log first. Repeat requests for the same path arrived more than an hour apart, so a short TTL would never hit.</li><li>Daily chunks with no compression, so the btree indexes work on every chunk. We chose this one. It costs disk: the table grew by about a sixth.</li></ul><h3>Decompressing 762 chunks under live traffic</h3><p>By the evening 762 chunks were compressed. The first migration removes the compression policy and changes the interval. The order matters, because the policy’s job runs every 30 minutes and would recompress chunks behind the loop.</p><pre>BEGIN;<br>SET LOCAL lock_timeout = &#39;5s&#39;;<br>SET LOCAL statement_timeout = &#39;60s&#39;;<br>SELECT remove_compression_policy(&#39;port_events&#39;, if_exists =&gt; true);<br>SELECT set_chunk_time_interval(&#39;port_events&#39;, INTERVAL &#39;1 day&#39;);<br>COMMIT;</pre><p>set_chunk_time_interval applies to new chunks only. Existing hourly chunks keep their ranges until retention drops them.</p><p>A single SELECT decompress_chunk(c) FROM show_chunks(...) would hold locks on every chunk until the end. We ran a loop with one chunk per transaction, newest first. decompress_chunk holds an exclusive lock while it copies, which lets reads continue (nothing writes to chunks that old), and a brief access-exclusive lock at the end. Each call runs with lock_timeout = 5s and is retried on timeout, so the loop never queues behind API readers for long. It pauses between chunks, aborts if other sessions start waiting on locks, suspends the retention and continuous aggregate jobs (TimescaleDB&#39;s materialised rollups) while it runs, and can be rerun, since it selects only chunks that are still compressed.</p><p>We rehearsed it on a local container with 3.1M synthetic rows in 745 chunks, 742 of them compressed, and the loop decompressed those in 341 s. We predicted 15 to 20 minutes for production. It took 2,280 s, about 3 s a chunk, because rebuilding the indexes on each decompressed chunk cost more on real data than on the seed. There were no lock-timeout retries and no aborts, a watcher sampling the API once a minute saw no 5xx, and the row count before the cutoff was identical afterwards. The second migration sets timescaledb.compress = false. TimescaleDB returns an error if any compressed chunk remains, so the migration counts them first and stops with a message naming the loop to run.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*o2x1ClgFPtlZ3mL0RYhVnw.png" /></figure><p>Planning still costs 75 to 100 ms per query across roughly 750 chunks, and falls as the hourly chunks age out.</p><h3>vessel_eta needed a different fix</h3><p>vessel_eta is 78 GB across 752 hourly chunks, segmented by MMSI. Compression earns its 3.0x there and stays. The API asks it two questions. One is the newest ETA report for a vessel. The other is which vessels are inbound to a port. Both ask for the current state. The table holds weeks of history.</p><p>By MMSI the first question is cheap. By IMO it is cheap only when we can map the IMO to an MMSI. When we cannot, the query filters on imo, which is neither the segment column nor the order column, and walks every chunk: 12 s warm, and up to the statement timeout under load. The bloom filter on imo helps less than it sounds. The scan still reads every batch&#39;s metadata row in every chunk to test it, and batches with no IMO pass. The inbound query filters on destination_port and has the same problem. With a freshness window of several days it covers well over a hundred chunks, and took 12.5 s on a good day.</p><p>For a day we blamed the wrong thing for part of this. Metrics showed a 10 to 25 s transaction at the top of every hour, and a continuous aggregate refresh is scheduled hourly. The next morning, with ten hours of traffic to read, the refresh turned out to run at three minutes past the hour and take 10 to 16 s. The longer transactions were these IMO lookups, in requests arriving on the hour.</p><p>As a stopgap we capped concurrent history scans and added a short-lived negative cache for IMOs with no ETA. The cache was switched off once the table below existed.</p><p>The fix is a plain PostgreSQL table next to the hypertable that holds the newest report per key:</p><pre>CREATE TABLE vessel_eta_latest (<br>    mmsi             BIGINT      NOT NULL,<br>    imo              BIGINT,<br>    destination_port TEXT,<br>    eta              TIMESTAMPTZ,<br>    timestamp        TIMESTAMPTZ NOT NULL,<br>    -- ... payload columns ...<br>    dest_key TEXT        GENERATED ALWAYS AS (COALESCE(destination_port, &#39;&#39;)) STORED,<br>    eta_key  TIMESTAMPTZ GENERATED ALWAYS AS (COALESCE(eta, &#39;-infinity&#39;::timestamptz)) STORED,<br>    imo_key  BIGINT      GENERATED ALWAYS AS (COALESCE(imo, 0)) STORED,<br>    PRIMARY KEY (mmsi, dest_key, eta_key, imo_key)<br>) WITH (fillfactor = 80, autovacuum_vacuum_scale_factor = 0.02);</pre><p>One row per vessel is the obvious design, and it returns different answers from the hypertable. At two large ports it dropped 8 of 58 and 8 of 166 inbound vessels, because a vessel had since reported another destination. One row per vessel and destination still dropped 3 and 2, where an ETA had been revised out of the requested window. One row per vessel, destination and ETA value dropped none. IMO is in the key because about a third of reports carry no IMO. Vessels rarely revise an ETA (97.7% of vessel and destination pairs carry a single ETA value in any hour), so the table stays small: 592,672 rows and 248 MB with indexes.</p><p>The ingestion service upserts into it after every batch it writes to the hypertable, with ON CONFLICT (...) DO UPDATE ... WHERE EXCLUDED.timestamp &gt;= vessel_eta_latest.timestamp. Each batch is reduced to one row per key first, since one statement cannot update the same row twice. A daily job deletes rows past the retention window.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*IKLlROub41MOsd57.png" /></figure><p>We checked that the two read paths agree before switching. Locally, EXCEPT in both directions between the hypertable queries and the table queries returned 0 rows. A byte diff of 4,105 API responses between the two read modes also showed no differences, and we counted it as a pass. In production, 500 random MMSIs and 20 random IMOs matched, and so did 37 of 38 port windows. The 38th differed by 4 rows and matched on the next run: the writer commits the hypertable insert and the upsert as two transactions, and the comparison had landed between them. A report now appears on the ETA endpoints a moment later than it used to. A request for inbound vessels as of a past time still goes to the hypertable.</p><p>The backfill copied the history in six-hour slices of 20 to 45 s each, and partway through PostgreSQL reported deadlock detected (SQLSTATE 40P01). A slice inserted in key order and held its row locks for the whole statement, and ON CONFLICT DO UPDATE locks the conflicting row even when the WHERE clause then rejects the update. The live writer upserted in arrival order, so the two statements locked the same rows in different orders. 19 live batches lost their upsert (the hypertable rows were intact, and nothing read the table yet). The writer and the backfill now sort by the key in plain byte order ( COLLATE &quot;C&quot; on the text column, which matches Go&#39;s string ordering), and the statement uses unnest(...) WITH ORDINALITY ... ORDER BY ord. PostgreSQL does not document the order in which one statement takes its row locks. In this shape it follows the input order, and both sides retry on 40P01 in case it does not. The ordering was needed regardless of the backfill. Two hours later, with no bulk job running, the live writer deadlocked against itself.</p><p>Once reads had moved, we changed the vessel_eta chunk interval from one hour to six. (Tiger Data&#39;s guidance sizes the indexes of the chunk being written against a quarter of memory. We were more conservative and looked at the whole uncompressed chunk with its indexes: about 300 MB for an hour, so about 1.8 GB for six hours and 7 GB for a day. A day would not fit the write working set.) The first full six-hour chunk compressed from 1,734 MB to 443 MB in about 31 s. That is 3.9x against 3.0x for the hourly chunks, presumably because a segment now holds six hours of a vessel&#39;s reports.</p><h3>The release that returned 500 on two endpoints</h3><p>The API release that switched reads to the new table failed in production. For about half an hour the ETA and inbound endpoints returned 500 to every request. Other endpoints and ingestion were unaffected. Post-deploy probes caught it within minutes and we rolled back to the previous build. The fixed build shipped the same evening.</p><p>The new query selected NULL::uuid AS id, because the table has no id column and the row scanner expected one. The scanner read id into a Go string. pgx v5 returns can&#39;t scan into dest[0] (col: id): cannot scan NULL into *string. The fix is *string. The value is never used.</p><p>Two checks had passed before this release. The SQL equivalence check was sound, and by design it cannot see an error in Go’s row scanning. The API diff could have seen it, and it never ran against the new code. It starts two local servers, one per read mode, sends the same requests to both and compares the bodies. A server from a test earlier that day was still running and held the port. Both new servers failed to bind, with “address already in use” in a log we did not read at the time, and every request of both passes went to the old process. The 4,105 responses were byte-identical because one server had produced all of them.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*9IKAYwHiWOqDBDmj.png" /></figure><p>Re-running the diff on the fixed code exposed a second gap. Every inbound request in every earlier local run had returned 400 on both sides, because the script asked for a page of 200 and the limit is 50. We had been comparing two identical error bodies.</p><p>The re-validation compared about 11,600 responses on each side. 13 differed on a window boundary (a vessel due at exactly 20:00:00, with the two passes either side of 20:00) and matched with a fixed window.</p><p>What changed in how we release:</p><ul><li>The diff script exits at start if its port is taken, and checks for its own server’s startup line before sending anything.</li><li>A test behind an environment variable scans a real row from the new table. On the broken commit it fails with the production error.</li><li>The built binary is probed against a real row after the final edit.</li><li>The probe set runs as soon as a new instance is serving.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AMdbhC1NtLvr-pZFwEGzNg.png" /></figure><h3>A background job filled the request pool</h3><p>That table still has a 567 ms p99, and one inbound request took 13.6 s. All 12 ETA and inbound requests over 3 s in those 14.5 hours fell in six one-minute slots. In each, the API’s request pool was fully occupied, with hundreds of seconds of acquire wait per minute summed across waiting requests. Three ETA calls finished together at an identical 13.0 s, which fits requests queued for a connection.</p><p>A request for vessel details can trigger a background refresh of that vessel’s aggregates, and a burst of detail requests across many vessels started many of them at once on the pool that serves requests. The refresh now has its own small pool with a concurrency cap, and a refresh that waits too long is dropped. None was dropped in the next 18 hours, the request pool stayed well below its limit, and ETA p99 was 32 ms.</p><p>The true top-of-hour spike, found on the evening of 10 September, was ours. An hourly job in our ingestion service ran SELECT DISTINCT imo over two days of positions and took 58 s. It now reads vessel_eta_latest, a close enough list of active vessels for that job, and takes 0.3 s.</p><h3>Three quarters of the ETA rows were copies</h3><p>Some reports reach us again after we have already stored them, identical in vessel, report time and content. The copies were byte-identical repeats of reports we already held. The ETA and inbound endpoints return one report per vessel, the newest that matches, so a copy never appeared in a response. An earlier fix had removed such repeats from positions. vessel_eta still stored them.</p><p>The latest-row table made the check cheap. Before inserting, the ingestion service compares each incoming report with the row for its key and drops it when the two are identical. A new report is stored as before. A report that arrives out of order and cannot be shown to be a copy by a second exact lookup is also kept. Reports of that kind were 0.7% of what the check examined. The check adds about 0.6 s to each batch.</p><p>Rows written fell by about three quarters against the same quarter hour of the same weekday a week earlier, and by a little more on a second weekday. The check can only drop a report that matches a stored one exactly, so it cannot remove a distinct report. Distinct reports per vessel moved by a few per cent, up on one day and down on the other. A six-hour chunk went from 1.8 GB to about 440 MB uncompressed, and from 450 MB to 125 MB compressed. Every row of vessel_eta_latest touched in a six-hour window had an identical row in the hypertable.</p><p>Had we done this first, the 78 GB table would probably have been under 20 GB, going by that reduction. We did it last because the check depends on the latest-row table, which did not exist until the second day.</p><h3>What is left</h3><p>Requests for inbound vessels as of a past time are still served from the hypertable, a path this work did not speed up. port_events planning stays at 75 to 100 ms until its hourly chunks have aged out, and vessel_eta waits the same way for its six-hour chunks.</p><p>Bounding-box queries over positions are untouched by this work and are next.</p><p><em>Originally published at </em><a href="https://vesselapi.com/blog/timescaledb-lookup-miss-hourly-compressed-chunks"><em>https://vesselapi.com</em></a><em> on September 17, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ed0c32cba02e" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[What ‘Real-Time’ Actually Means for a Vessel Tracking API]]></title>
            <link>https://vessel-api.medium.com/what-real-time-actually-means-for-a-vessel-tracking-api-c6928070c744?source=rss-00c2102cfd05------2</link>
            <guid isPermaLink="false">https://medium.com/p/c6928070c744</guid>
            <category><![CDATA[vessel-tracking]]></category>
            <category><![CDATA[api]]></category>
            <category><![CDATA[maritime]]></category>
            <category><![CDATA[ai]]></category>
            <dc:creator><![CDATA[VesselAPI]]></dc:creator>
            <pubDate>Sat, 13 Jun 2026 13:02:47 GMT</pubDate>
            <atom:updated>2026-10-08T00:14:45.275Z</atom:updated>
            <content:encoded><![CDATA[<p>Open any consumer ship-tracking website, zoom in on the Strait of Dover, and you’ll see hundreds of little arrowheads gliding across the channel as if you were watching a live camera feed. It feels continuous. It feels like <em>now</em>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*X2rsjRd9xuqpd4Jx.png" /></figure><p>It isn’t. Not really. And the gap between what “real-time” feels like and what it actually is turns out to be the most important thing to understand before you start building anything on top of a vessel tracking API.</p><p>So let’s look at it properly.</p><h3>The thing you’re actually receiving</h3><p>A ship doesn’t broadcast its position to a satellite that beams it instantly into your laptop. It broadcasts an AIS message — a short burst on one of two VHF channels, about 256 bits long, containing its identity, position, course, speed, and heading. AIS stands for Automatic Identification System, and it was designed in the 1990s primarily so that ships could avoid hitting each other, not so that you could draw nice dots on a Mapbox tile.</p><p>This origin matters. AIS is a <em>collision-avoidance</em> protocol that the rest of the world has retrofitted into a global tracking network. The clue is in how often a ship transmits: a vessel moving fast and turning sharply sends a position every 2 seconds. A vessel anchored or moored might send one every 3 minutes. The system spends its bandwidth where danger lives, not where dashboards want it.</p><p>Those VHF bursts then have to be <em>heard</em> by something. Two somethings, really:</p><ul><li><strong>Terrestrial receivers</strong> — antennas on coastlines, harbours, and ships, with a range of roughly 40 nautical miles. Coverage is excellent near land, nonexistent in the middle of the Pacific.</li><li><strong>Satellites</strong> — low-Earth-orbit constellations that pick up AIS signals from open ocean. Coverage is global, but a given patch of ocean might only be overflown every 10 to 90 minutes depending on the constellation. And satellites struggle in busy areas, because hundreds of ships transmitting on the same frequency at the same time produce a sort of radio shouting match.</li></ul><p>By the time a position reaches an API, it has been: broadcast by the vessel, received by a terrestrial antenna or a satellite, relayed to a ground station, decoded, deduplicated against other receivers that heard the same burst, and written to a database. The latency budget for that pipeline ranges from a couple of seconds (a tug in Rotterdam harbour) to tens of minutes (a bulk carrier two-thirds of the way across the Indian Ocean).</p><p>So when an API says “real-time vessel tracking,” what it can honestly promise is: <em>the most recent position we have, with the timestamp of when the vessel actually transmitted it</em>. The honesty lives in that second clause.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*DK33PcTgpM1U--hO.png" /></figure><h3>The latency budget, written down</h3><p>Let’s make this concrete. Here’s a rough decomposition of the lag between a ship being somewhere and your code knowing it:</p><ol><li><strong>Reporting interval</strong>: 2 seconds to 3 minutes, depending on the vessel’s state.</li><li><strong>Reception delay</strong>: near-zero for terrestrial, anywhere from seconds to an hour for satellite, depending on overflight schedule and message collisions.</li><li><strong>Aggregation and ingestion</strong>: usually short, though a feed outage can stop updates for longer.</li><li><strong>Your polling interval</strong>: entirely up to you, and usually the biggest contributor to your perceived latency.</li></ol><p>Numbers 1 and 2 are physics and orbital mechanics. You cannot beat them. Number 3 is engineering, and a good provider keeps it small. Number 4 is yours to design.</p><p>This is why every position you fetch from a vessel API comes with a timestamp — and why that timestamp matters more than the moment you received the response. The data isn’t lying when it’s 12 minutes old. It’s telling you the truth: <em>this is where the ship was 12 minutes ago, and we haven’t heard a peep since</em>.</p><h3>Pulling positions, properly</h3><p>Let’s actually do it. The VesselAPI Python SDK is installed with pip install vessel-api-python, and the client looks like this:</p><pre>from vessel_api_python import VesselClient<br><br>client = VesselClient(api_key=&quot;your_key_here&quot;)</pre><p>There are two distinct concepts in the API, and confusing them is the first mistake everyone makes. A <strong>vessel</strong> is identity: name, IMO, MMSI, type, flag, tonnage. It doesn’t move. A <strong>position</strong> is a snapshot: latitude, longitude, speed, course, and the timestamp of the AIS burst that produced it. These live behind different endpoints.</p><pre># Identity — who is this ship?<br>vessel = client.vessels.get(&quot;9811000&quot;).vessel<br>print(vessel.name, vessel.vessel_type, vessel.imo, vessel.mmsi)<br><br># Position — where was it last seen?<br>pos = client.vessels.position(&quot;9811000&quot;).vessel_position<br>print(pos.latitude, pos.longitude, pos.sog, pos.heading, pos.timestamp)</pre><p>A few things to notice. The ID is positional and defaults to IMO; pass filter_id_type=&quot;mmsi&quot; if you want to look up by MMSI instead. The speed field is sog - speed over ground, in knots - not speed. And timestamp is an ISO-8601 <em>string</em>, which means if you want to compute &quot;how stale is this?&quot;, you have to parse it first:</p><pre>from datetime import datetime, timezone<br><br>reported = datetime.fromisoformat(pos.timestamp.replace(&quot;Z&quot;, &quot;+00:00&quot;))<br>age = datetime.now(timezone.utc) - reported<br>print(f&quot;Position is {age.total_seconds():.0f} seconds old&quot;)</pre><p>This little calculation is the most honest line of code you’ll write all day. Use it. Surface that age in your UI. A dashboard that displays a 47-minute-old position as if it were live is lying to its users; a dashboard that says “last seen 47 minutes ago” is telling them something true and useful.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*w1UdAsTO9-bGVNMZ.png" /></figure><h3>Tracking many ships at once</h3><p>Single-vessel polling is fine for, say, tracking your one chartered tanker. But most real applications care about an area — a port approach, a strait, a fishing ground. For that, the API exposes location queries:</p><pre># Everything currently in a rectangle around Rotterdam<br>box = client.location.vessels_bounding_box(<br>    lat_min=51.85, lat_max=52.05,<br>    lon_min=3.80,  lon_max=4.55,<br>)<br><br>for v in box.vessels or []:<br>    print(v.vessel_name, v.mmsi, v.latitude, v.longitude, v.timestamp)<br><br># Or everything within 10 km of a point<br>near = client.location.vessels_radius(<br>    latitude=51.92, longitude=4.48, radius=10000,<br>)</pre><p>These return the latest known position for each vessel in the area. Each row has its own timestamp, and those timestamps will not be the same. The tug in the inner harbour might be 4 seconds old; the cargo ship anchored offshore might be 6 minutes old. That&#39;s not a bug - it&#39;s the protocol working as designed.</p><h3>How often should you actually poll?</h3><p>People reach for WebSockets and “live” streams because polling feels primitive. But for AIS data, the rhythm of your polling should match the rhythm of the underlying transmissions, not exceed it. There’s no benefit to polling a moored ship every second when it only transmits every three minutes — you’ll just see the same row, over and over, while burning your rate limit.</p><p>A reasonable heuristic:</p><ul><li><strong>Harbour and approach traffic</strong> (lots of movement, terrestrial coverage): every 10 to 30 seconds.</li><li><strong>Coastal traffic at cruising speed</strong>: every minute or two.</li><li><strong>Open-ocean voyages</strong>: our terrestrial data goes quiet offshore, so polling harder won’t help. The paid satellite fallback can return the latest fix for a single vessel.</li></ul><p>If a position’s timestamp hasn&#39;t advanced since your last poll, nothing new has happened. The vessel hasn&#39;t moved into a new reporting state, or no receiver has heard from it since. Either way, polling harder won&#39;t help.</p><h3>The thing under the thing</h3><p>So: real-time vessel tracking is not a continuous video stream of the oceans. It is a sparse, opportunistic, physics-bounded sampling of millions of ships, glued together by a global network of antennas and satellites, and exposed to you as the freshest snapshot the system was able to assemble. The word “real-time” is doing some work it can’t quite hold up.</p><p>But once you see the seams — once you treat that timestamp field as the first-class citizen it is - the whole thing stops feeling like a leaky abstraction and starts feeling like what it is: a remarkable feat of distributed coordination, where a 1990s collision-avoidance radio protocol has been quietly turned into a planetary sensor network.</p><p>The ships have been broadcasting all along. Someone just had to listen.</p><p><em>Originally published at </em><a href="https://vesselapi.com/blog/realtime-vessel-tracking-websocket"><em>https://vesselapi.com</em></a><em> on June 13, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c6928070c744" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Email That Arrived Six Hours Late]]></title>
            <link>https://vessel-api.medium.com/the-email-that-arrived-six-hours-late-5d7f23c5def5?source=rss-00c2102cfd05------2</link>
            <guid isPermaLink="false">https://medium.com/p/5d7f23c5def5</guid>
            <category><![CDATA[api]]></category>
            <category><![CDATA[maritime]]></category>
            <category><![CDATA[business-intelligence]]></category>
            <dc:creator><![CDATA[VesselAPI]]></dc:creator>
            <pubDate>Thu, 04 Jun 2026 14:11:37 GMT</pubDate>
            <atom:updated>2026-06-05T11:06:06.984Z</atom:updated>
            <content:encoded><![CDATA[<p>You set up the email alert weeks ago. A small, satisfying piece of automation: when the <em>MV Aristos</em> enters the port approach zone, you get a notification. Your team prepares the berth. Customs is warned. The agent is on the way.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*cLhB-Y3USri-49Dm.png" /></figure><p>The email arrives at 06:14. You read it on the train. You feel briefly organised.</p><p>Then you get to the office and find out the <em>Aristos</em> tied up at 04:30.</p><p>What happened isn’t quite a bug, and it isn’t quite a lie. It’s the gap between two things that sound almost identical when you read the marketing copy: <strong>maritime email alerts</strong> and <strong>API-based vessel monitoring</strong>. They both promise to tell you where your ships are. Only one of them is actually built to.</p><p>Six hours is the bad day, to be clear. Most of the time the gap is ten or twenty minutes — long enough to miss the pilot boarding ground, short enough that nobody writes it down. Six hours is what happens when ten or twenty minutes meets a quarantine folder.</p><h3>The thing about email</h3><p>Email is, structurally, a notification protocol whose architecture dates to the early 1970s, designed for messages that absolutely do not need to be timely. SMTP itself was formalised in 1982, and it was built to survive a node going down for a week. The entire postal metaphor — inboxes, deliveries, retries — is the wrong shape for “this ship is about to reach the pilot boarding ground.”</p><p>Here is roughly what happens between an AIS transmission and your inbox. A provider ingests AIS positions from terrestrial receivers and, for open ocean, from satellite constellations. The raw feed itself is fast in some places and slow in others: a Class A transponder on a SOLAS-mandated ship over 300 GT will report every two to ten seconds when underway and every three minutes at anchor; a Class B unit on a smaller coastal trader reports every thirty seconds or so. So already, “real-time AIS” is a range, not a number.</p><p>That feed then gets batched, deduplicated, and run through a rule engine on some interior schedule. When a rule fires, a message is queued to SMTP. SMTP hands off to your mail provider, which runs the message through greylisting, spam filters, rate limits, and DMARC checks. Eventually your mail client polls, and the email appears.</p><p>It’s the stacking that gets you. Each step is harmless on its own. Together they produce a system whose typical latency lives in the minutes and whose worst case lives in the hours, because somewhere in that chain something will occasionally get stuck. Greylisting alone — the spam-prevention trick where unknown senders are politely told to try again later — routinely adds fifteen minutes. A DMARC misalignment can route the alert into a quarantine folder you’ll find on Thursday. None of that is the alert provider’s fault. It’s just what email <em>is</em>: a protocol hardened against spam, not optimised for signalling.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*vqi2kHs54QMCFpwc.png" /></figure><h3>What an API is actually doing</h3><p>A vessel-tracking API — the kind you’d query from your TMS, your berth-planning tool, or a small internal dashboard — is doing something architecturally different. Instead of pushing a human-readable message through a chain of best-effort hops, it lets your software ask the provider’s database a direct question and get a structured answer back.</p><p>The most important consequence is that the data is structured. An email says “MV Aristos entered Zone Piraeus-Approach at 04:27.” An API response says:</p><pre>{<br>  &quot;mmsi&quot;: 240958000,<br>  &quot;imo&quot;: 9776418,<br>  &quot;name&quot;: &quot;ARISTOS&quot;,<br>  &quot;lat&quot;: 37.9421,<br>  &quot;lon&quot;: 23.6398,<br>  &quot;sog&quot;: 8.4,<br>  &quot;cog&quot;: 287,<br>  &quot;nav_status&quot;: 0,<br>  &quot;destination&quot;: &quot;PIRAEUS&quot;,<br>  &quot;eta&quot;: &quot;2024-11-12T04:30:00Z&quot;,<br>  &quot;timestamp&quot;: &quot;2024-11-12T04:27:13Z&quot;<br>}</pre><p>(Illustrative example — nav_status: 0 is the raw AIS code for &quot;under way using engine&quot;; most APIs normalise it into a string. The MMSI and IMO are fictional.)</p><p>You cannot automate against a sentence. You can do almost anything against that JSON: correlate it with your cargo manifest, trigger a berth-allocation algorithm, update a customer-facing ETA, or fire your own email, on your own infrastructure, with your own latency budget.</p><p>Latency, the other thing people care about, mostly collapses — with caveats. A REST call typically returns in a few hundred milliseconds. A WebSocket or streaming endpoint can push position updates within a second or two of the provider receiving them. But that’s for vessels within range of terrestrial AIS receivers. For open-ocean ships visible only to satellites, the position you fetch in 200 milliseconds may itself be five to twenty minutes old, depending on the constellation’s revisit rate. A fast API does not abolish physics; it just stops adding insults on top.</p><p>And then there’s the part that sounds like a disadvantage and isn’t: an API is pull, not push. You decide when to ask. Forty vessels every thirty seconds, if you want — though at that rate you should probably be on a streaming endpoint instead of hammering REST, both for your own sanity and your provider’s rate limits. Webhooks sit in the middle: structured push notifications that give you timing control with the data quality of an API call.</p><p>The honest version is that polling has its own pains. Your loop has to actually keep running. Authentication tokens have to keep refreshing. You have to detect gaps when the network blips, because unlike email, nothing screams when a request silently never happens. Whoever told you APIs were free of operational debt was selling something.</p><h3>The honest case for email</h3><p>If you are a single operator tracking three or four ships, and you don’t have a developer, and your workflow is “look at email, walk down the hall, tell someone” — email alerts are <em>fine</em>. They are, in fact, better than fine. No integration, no API keys, no JSON parsing, no rate-limit management. The five-minute delay doesn’t matter, because the human in the loop is also operating on minutes-to-hours timescales.</p><p>The problem is what happens when that workflow grows. The moment you have more than a handful of vessels, or more than one person needs the data, or the data needs to flow into <em>another</em> system — email becomes a bottleneck disguised as a feature. You end up with people forwarding alerts to each other. You end up with Outlook rules trying to parse vessel names out of subject lines. You end up, eventually, building a brittle script that reads emails and extracts data from them, which is the saddest possible reinvention of an API. If you find yourself writing an email scraper, the universe is trying to tell you something.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*bA82B1x96HeXYs1u.png" /></figure><h3>The expensive failure mode</h3><p>The really costly version of this isn’t the dramatic six-hour delay. It’s the demurrage dispute.</p><p>A vessel arrives at 04:30. Your alert lands at 06:14. Weeks later, in a laytime calculation, your records say the ship arrived at 06:14 — that’s the timestamp in your system, because that’s when the email came in — and the port’s records say 04:30. The difference is real money, and the arbitrator has to pick which timestamp to believe. Notice of Readiness disputes turn on exactly this sort of evidentiary gap. The cheap-looking email alert just became the most expensive line item in the quarter.</p><p>Around that sit the quieter costs. Berth windows missed by twenty minutes because the arrival alert was queued behind a spam scan. Customer ETAs that were correct in the provider’s database but stale in yours, because nobody received the update. An ops team that has slowly developed a learned distrust of its own alerting system, which is the worst failure mode in any monitoring tool. Once people stop trusting the alerts, they start checking manually, and the automation might as well not exist.</p><p>None of these look like the alerting <em>broke</em>. They look like ordinary operational friction. That’s what makes them hard to attribute and easy to live with.</p><p>Email alerts are a notification layer. An API is a data layer. You can build the first on top of the second — many operations teams do exactly that, sending themselves Slack messages or emails generated from API data they control. You cannot build the second on top of the first without writing an email scraper.</p><p>The ship doesn’t care when the email arrives. It’s already alongside.</p><p><em>Originally published at </em><a href="https://vesselapi.com/blog/choosing-vessel-monitoring-approach"><em>https://vesselapi.com</em></a><em> on June 4, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5d7f23c5def5" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[What a Port Event Actually Is (And Why Your Timestamps Are Lying to You)]]></title>
            <link>https://vessel-api.medium.com/what-a-port-event-actually-is-and-why-your-timestamps-are-lying-to-you-b1508a749905?source=rss-00c2102cfd05------2</link>
            <guid isPermaLink="false">https://medium.com/p/b1508a749905</guid>
            <category><![CDATA[container-tracking]]></category>
            <category><![CDATA[maritime-data-api]]></category>
            <category><![CDATA[shipping]]></category>
            <category><![CDATA[rest-api]]></category>
            <dc:creator><![CDATA[VesselAPI]]></dc:creator>
            <pubDate>Mon, 01 Jun 2026 18:54:20 GMT</pubDate>
            <atom:updated>2026-10-07T23:44:11.488Z</atom:updated>
            <content:encoded><![CDATA[<p>Ask someone when a ship “arrives” at a port and you’ll get an answer that sounds obvious. It arrives when it gets there. When it docks. When the cargo comes off. When the captain stops driving.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*9NkU_-QPdT0v1zUc.png" /></figure><p>All of these are different events. And if you’re building anything that depends on knowing where ships are and what they’re doing — a logistics dashboard, an emissions tracker, a demurrage calculator, a trading signal — the difference between them is the difference between software that works and software that quietly lies to you.</p><p>This is a piece about what a <em>port event</em> actually is, why the timestamp on it is more philosophically loaded than it has any right to be, and how to pull clean data out of an API without tripping over the assumptions baked into the question.</p><h3>The fridge magnet version</h3><p>Here’s the mental model most people start with: ships sail around, and every so often one of them enters a port. The port notices. A row gets written somewhere. That row is a “port event.”</p><p>That model is wrong. Not approximately wrong — wrong in a way that will burn you the first time you trust it.</p><p>In reality, no port “notices” anything. There is no welcome desk at the edge of Singapore harbor. What actually happens is this: commercial vessels of any size that matters — the rule is nominally 300 gross tons on international voyages, 500 GT on domestic ones, plus all passenger ships, but the operational answer is “anything commercially significant” — are required by SOLAS to broadcast their position, speed, heading, and identity. Fishing fleets and pleasure craft are mostly invisible. The protocol is called AIS (Automatic Identification System), broadcasting on two dedicated VHF channels (161.975 and 162.025 MHz) in the maritime band — pure digital TDMA, not voice, distinct from the channels humans actually talk on. Smaller vessels often carry the lighter Class B variant voluntarily.</p><p>Coastal stations receive these broadcasts within roughly 40–60 nautical miles of shore; satellites fill in the open ocean, with gaps depending on constellation coverage and how badly signals collide in busy lanes. The aggregate raw stream runs into the hundreds of millions to over a billion messages per day globally — scattered across the planet with no semantic meaning attached. Each message carries an MMSI, a nine-digit identifier that is the actual join key if you ever want to correlate a port event back to the raw position stream.</p><p>A “port event” is something a computer infers from that stream. It’s a <em>derived</em> fact, not a recorded one. (The port events in our own API come from port-call records rather than from our own AIS geofencing. The same questions apply to them.)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*_T_tA4GQ-vAHAMIp.png" /></figure><h3>Inferring a thing that didn’t actually happen</h3><p>To turn position fixes into port events, you need two things: a definition of what a port is in geographic terms, and a rule for when a ship has interacted with it.</p><p>The first one sounds easy and isn’t. Ports aren’t points; they’re sprawling, irregular regions that include anchorages outside the breakwater, terminal berths inside it, sometimes a river pilot boarding area many kilometers offshore, and frequently a chunk of open sea where ships wait — sometimes for days, sometimes for weeks — for a berth to free up. Singapore’s anchorage zones extend well into the strait. Houston’s ship channel runs roughly fifty nautical miles from open water to the inner turning basin, serving a string of independently operated terminals that are colloquially “the port” but legally several different things.</p><p>So the first decision is: where does the port end? Serious port-event systems use polygons — hand-drawn or algorithmically refined boundaries, layered: an outer “port limits” polygon, inner anchorage polygons, and individual berth polygons. A vessel crossing one of these boundaries generates a candidate event. The providers who skip the layering and use a single outer polygon are selling you noise — every anchorage stay becomes “arrived,” every drift across the line becomes a fresh port call.</p><p>The second decision is what counts as a crossing. A naive “is the ship inside the polygon?” check produces nonsense: GPS noise causes vessels at the edge to flicker in and out, generating dozens of fake arrival/departure pairs per hour. Real systems apply hysteresis (you have to be inside for N minutes before it counts) and speed thresholds — a moored ship reports near-zero speed-over-ground, though you have to filter the AIS sentinel codes near 102 knots. The standard reserves 1023 (102.3 knots) for &quot;SOG unavailable&quot; and 1022 (≥102.2 knots) as an overflow marker; both will otherwise inject impossible speeds into your data.</p><p>What comes out the other end is an event stream: <em>arrival</em> and <em>departure</em>, plus <em>berth</em> events in systems that track berth polygons. Each one with a timestamp.</p><p>And the timestamps are where it gets interesting.</p><h3>Why sub-minute matters</h3><p>Query a port events endpoint for Singapore, say /portevents/port/SGSIN using the UN/LOCODE, and look at how finely the timestamps are resolved. The hour, the nearest five minutes and the second are very different answers.</p><p>The demurrage case is the one that matters most, and it gets the least attention. Emissions models get the press; trading signals get the venture money. But the demurrage clock — the meter that charges a charterer for keeping a ship waiting — starts ticking at a contractually defined moment, often tied to Notice of Readiness tendering at the port limits. NOR is a document the master serves, not an AIS event; but AIS-derived port event timestamps are increasingly used as corroborating evidence, and a charterer’s lawyer disputing a $40,000-per-day demurrage claim on a Capesize bulker (an illustrative figure — real rates swing widely with the market) will absolutely cross-reference your data against the NOR timing. If your provider rounded to the nearest hour, you have just introduced uncertainty into someone’s evidence.</p><p>Emissions estimates drift quietly across a fleet when timestamps are coarse, and the trading case is the most fragile of all: watching how fast ships cycle through berths at Port Hedland is, in effect, watching iron ore loading throughput in something close to real time. If your berth timestamp is fuzzy by twenty minutes, your derivative is noise.</p><p>The reason the data <em>can</em> be sub-minute is that AIS Class A position reports arrive every 2 to 10 seconds for a vessel underway — strictly, every 10 seconds at port-approach speeds (0–14 knots), every 6 seconds in the 14–23 knot range, every 2 seconds above 23 knots, with course changes collapsing the interval to 2–3 seconds across the board. Class B transponders report every 30 seconds when moving, dropping to 3 minutes when slow; newer Class B+ devices match Class A rates. Stationary vessels at anchor with reported SOG ≤ 3 knots drop to once every 3 minutes — though GPS noise will sometimes keep an anchored ship in the faster-reporting mode anyway, which is one reason boundary flicker is so persistent.</p><p>In practice? A lot of providers downsample for storage and the precision dies upstream. If all your timestamps end in :00:00, that&#39;s your answer.</p><h3>Querying it without tripping</h3><p>The endpoint is GET /portevents/port/{unlocode}. UN/LOCODE is the five-character code maintained by UN/CEFACT - two letters of country code, three characters of location, occasionally including digits ( GB2WB is a real one). SGSIN is Singapore. NLRTM is Rotterdam. USNYC is New York; though if you&#39;re querying the New York/New Jersey container complex specifically, your provider may use USNWK (Newark) for the New Jersey terminals - the boundary varies by data source, and &quot;the port of New York&quot; turns out not to be one thing. UN/CEFACT publishes updates twice a year, so if you&#39;re building something that runs unattended for years, track the changes.</p><p>The first thing that’ll burn you is reading more into an event than it says. An <em>arrival</em> can be logged while the vessel is still miles from any berth, anchored, waiting. A <em>departure</em> means it left. Our port events record arrivals and departures only; time alongside is not reported separately, so arrival-to-departure includes any wait at anchor. That is not the same as “time spent loading”, and the difference can be days. Events are kept for 31 days.</p><p>Coverage is uneven. Fishing fleets, very small craft, and naval vessels — which are SOLAS-exempt and may suppress AIS in operational contexts, though plenty of warships and auxiliaries transmit routinely in commercial ports — are inconsistent at best. Query a fishing port and the silence is the answer.</p><p>And events arrive out of order. Satellite AIS latency means transmissions from quieter ports can be buffered until the next overhead pass; for busy ports the more common cause of late arrivals is provider ingestion batching. Either way: your code should not assume chronological order, and providers that stamp ingestion time instead of transmission time will quietly destroy your analysis.</p><h3>The thing under the thing</h3><p>“A ship arrived at a port” is not an observed fact. It’s a model. Somewhere between the radio signal and the JSON, a decision was made about where the port ends and what counts as being inside it. That decision is invisible in the response, and it shapes everything downstream.</p><p>The nice thing about port events, compared to most derived data, is that the questions are easy to write down: where does the port end, what counts as inside, and which clock produced the timestamp. Ask them of any source you use, ours included.</p><p>You just have to remember that the ship didn’t arrive. Something decided it had.</p><p><em>Originally published at </em><a href="https://vesselapi.com/blog/port-events-vessel-tracking"><em>https://vesselapi.com</em></a><em> on June 1, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b1508a749905" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Drawing Lines on the Ocean]]></title>
            <link>https://vessel-api.medium.com/drawing-lines-on-the-ocean-5d951df7a10c?source=rss-00c2102cfd05------2</link>
            <guid isPermaLink="false">https://medium.com/p/5d951df7a10c</guid>
            <category><![CDATA[maritime-intelligence]]></category>
            <category><![CDATA[ship-tracking]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[notifications]]></category>
            <dc:creator><![CDATA[VesselAPI]]></dc:creator>
            <pubDate>Mon, 25 May 2026 14:21:51 GMT</pubDate>
            <atom:updated>2026-10-07T23:53:23.981Z</atom:updated>
            <content:encoded><![CDATA[<p>Imagine you’ve drawn a circle on a map. It surrounds the Port of Rotterdam, with a bit of slack so you catch ships in the approach. You want to know, in something close to real time, when a particular tanker crosses that line. Not when someone checks a dashboard. Not in a daily report. The moment it happens.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*MCGUEukeC0GRnrUx.png" /></figure><p>On the face of it, trivial. You have a position. You have a polygon. Check if one is inside the other. Done.</p><p>Except almost nothing about that sentence is true in practice.</p><h3>The thing you think you have</h3><p>The first lie is “you have a position.” You don’t, really. You have a position that a vessel’s AIS transponder broadcast some number of seconds or minutes ago, that was picked up by a terrestrial receiver (if the vessel is near shore) or a satellite (if it isn’t), that was relayed through one or more data networks, that was deduplicated against other reports of the same broadcast, that was timestamped — possibly multiple times, by different systems with slightly different clocks — and that finally arrived in a database.</p><p>The position is real. The “now” is negotiable.</p><p>Class A transponders — required under SOLAS for commercial vessels of 300 GT and up on international voyages — report as often as every 2 seconds for a vessel above 23 knots or one changing course aggressively, every 6 seconds at 14–23 knots, and every 10 seconds for anything underway below 14 knots regardless of course changes. Anchored or moored, it drops to 3 minutes. (The full ITU-R M.1371 table has more buckets than that; the point is the rate scales with how interesting the vessel currently is.) Class B SO units on smaller craft report every 30 seconds above 2 knots and every 3 minutes at or below — and Class B CS units, increasingly common, just report every 30 seconds and yield the channel to Class A. In a place as busy as Rotterdam, a small vessel’s updates can get elbowed out and arrive minutes apart.</p><p>Satellite AIS is a separate latency story, and honestly the part of this stack I find most quietly enraging — half the vendors in this space sell “real-time satellite AIS” and quietly mean “ten minutes old, on a good day.” The protocol’s anti-collision mechanism — Self-Organizing TDMA — assumes each vessel can passively listen to nearby traffic and pick an unoccupied time slot from the local frame map. That assumption holds beautifully at ship-to-ship VHF ranges of roughly 20 nautical miles (the design figure; in practice it varies a lot with antenna height). From orbit, it falls apart: thousands of vessels beyond each other’s range have independently claimed the same time slots, and a satellite hears all of them at once. Modern LEO constellations claw a lot of those messages back with wider-bandwidth receivers and probabilistic decoding, but gaps remain. By the time you see the vessel cross the line, the next position might place it 200 meters past it. Or 2 kilometers. The crossing itself is something you have to infer.</p><p>Which brings us to the second lie: “check if one is inside the other.”</p><h3>Inside, outside, and the hysteresis problem</h3><p>Here is what naive geofencing looks like in production: a vessel anchors precisely on the boundary of your polygon. Wind and current shift, and the vessel swings through an arc — a ship on 60 meters of chain in 15 meters of water can sweep through a circle over 100 meters wide, and that’s <em>real</em> movement, not noise. On top of that, modern receivers add a few meters of GPS jitter under open sky; multipath in a busy harbor, obstructions, or loss of differential correction can push that to tens of meters regardless of how new the equipment is. Every few minutes, a new position arrives — sometimes just inside the fence, sometimes just outside.</p><p>Your webhook fires. And fires. And fires. By morning your downstream system has received 847 “vessel entered port” notifications for the same ship that hasn’t actually moved.</p><p>This is the same problem thermostats have. The solution is the same too: <strong>hysteresis</strong>. You don’t fire on a single crossing. You fire on a <em>state change</em> that has settled.</p><p>Concretely, your handler tracks, for each (vessel, geofence) pair, the last known state — inside, outside, or unknown - and only emits a notification when a new observation contradicts the stored state and you have some confidence the contradiction is real.</p><p>The simplest version of “real” is a debounce window. A single window applied to every fence will not work: a value that suits a 5nm port-approach fence is far too long for a harbor entrance fence a pilot boat can cross in twenty seconds. It needs to be per-fence, scaled to the smallest dimension you care about and the fastest legitimate vessel.</p><p>A buffer zone — enter when the vessel is 100m <em>inside</em> the boundary, exit when it’s 100m <em>outside</em> — works beautifully for circles. For arbitrary polygons it’s harder; computing an inward offset of a concave shape is a real piece of geometry, not a one-liner, so most implementations apply the buffer at evaluation time rather than try to redraw the polygon.</p><p>Your point-in-polygon test also needs to work in projected coordinates or use a proper spherical distance formula. Raw lat/lon arithmetic produces shapes that are noticeably non-circular at high latitudes — at Rotterdam’s 51.9°N, a degree of longitude is about 69 km on the ground, not 111. That’s a 38% squash. A “5 nautical mile radius” drawn with naive Euclidean distance is an ellipse, and a visibly wrong one.</p><p>There’s a subtler trap waiting too. If two position updates for the same vessel arrive within milliseconds — entirely possible with high-frequency feeds and multiple workers — a naive “read state, decide, write state” sequence races against itself. Both workers read outside, both see inside, both fire. In Postgres, the cleanest fix is a conditional update with a timestamp guard: UPDATE ... WHERE state = &#39;outside&#39; AND last_observed_at &lt; $position_timestamp, then check the affected row count. The timestamp clause matters more than it looks - without it, a satellite position from twenty minutes ago, arriving late after a fresh terrestrial position, will cheerfully roll the state back and re-fire the crossing. (Pessimistic locking with SELECT FOR UPDATE is simpler to reason about; at geofence throughputs the difference rarely matters.)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*sFdbKgefBjtUmzpe.png" /></figure><h3>What the webhook should actually contain</h3><p>When the notification finally does fire, what goes in the payload matters more than people expect. The receiver may need to argue with it — dismiss it as a stale position, route it to the right downstream system, decide whether to wake someone up. Give them the materials to make that call.</p><p>The payload below is an illustration of the fields worth carrying if you build this yourself. It is not the VesselAPI event schema: our geofence events are position.geofence_enter and position.geofence_exit, and they carry no confidence field.</p><pre>{<br>  &quot;event&quot;: &quot;geofence.entered&quot;,<br>  &quot;event_id&quot;: &quot;evt_01HXYZ...&quot;,<br>  &quot;occurred_at&quot;: &quot;2024-11-14T08:42:17Z&quot;,<br>  &quot;vessel&quot;: {<br>    &quot;mmsi&quot;: 338234567,<br>    &quot;imo&quot;: 9074729,<br>    &quot;name&quot;: &quot;EXAMPLE VESSEL&quot;<br>  },<br>  &quot;geofence&quot;: {<br>    &quot;id&quot;: &quot;gf_rotterdam_approach&quot;,<br>    &quot;name&quot;: &quot;Rotterdam Approach&quot;<br>  },<br>  &quot;position&quot;: {<br>    &quot;lat&quot;: 51.9461,<br>    &quot;lon&quot;: 4.1234,<br>    &quot;sog&quot;: 8.2,<br>    &quot;cog&quot;: 87,<br>    &quot;reported_at&quot;: &quot;2024-11-14T08:41:55Z&quot;,<br>    &quot;nav_status&quot;: &quot;under_way_using_engine&quot;<br>  },<br>  &quot;confidence&quot;: &quot;high&quot;,<br>  &quot;previous_state&quot;: &quot;outside&quot;<br>}</pre><p>Ship MMSIs are 9 digits with a Maritime Identification Digit (MID) prefix roughly in the 201–775 range; prefixes starting with 0, 8, or 9 indicate coast stations, group calls, or special devices like AtoN buoys and MOB beacons, not vessels. IMO numbers are 7 digits, last digit a check digit (the 9074729 above validates, and engineers do paste examples straight into validators). Use fictional-but-structurally-valid identifiers in your docs.</p><p>event_id is the receiver&#39;s lifeline. Webhooks get retried. Networks fail. Your handler crashes between sending the HTTP request and committing the &quot;sent&quot; flag. The receiver will see the same event twice, and the only thing that lets them deduplicate cleanly is a stable, unique identifier per <em>logical</em> event - not per delivery attempt. (Their dedup store should be bounded too; storing every event_id forever is an unbounded leak nobody notices until it&#39;s six months in.)</p><p>occurred_at versus position.reported_at is the gap between when the vessel was where it says, and when your system was confident enough to call the crossing. The vessel was at that lat/lon at 08:41:55. You decided 22 seconds later. That delta is the truth about how much to trust the event.</p><p>A confidence field, if you add one, needs a written rule. One workable rule: high when the position is under 5 minutes old <em>and</em> the AIS position-accuracy bit is 1 (indicating &lt;10m, usually DGPS-augmented); low when the last known position is older than 15 minutes or follows a long gap; nothing in between. A medium tier is tempting and tends to become decoration that nobody filters or alerts on. Five minutes is generous for a tight fence, since a vessel at 20 knots covers over 3 km in that time, so tighten it for harbor-scale fences. Write the thresholds down. Argue about them in code review, not in incident retros.</p><p>nav_status is worth including, but treat it as advisory. It&#39;s a 4-bit field that&#39;s <em>supposed</em> to reflect operational status, but in practice it&#39;s set once at commissioning, or updated via an integrated bridge system that may or may not be wired to anything useful, or just left at the default. Vessels routinely transmit under_way_using_engine while sitting at anchor because nobody changed the setting. Useful for suppression heuristics; dangerous as a hard filter, and frankly this is the AIS field I&#39;d most like to set on fire.</p><h3>Delivery: the part everyone underestimates</h3><p>The webhook itself is the easy bit. POST a JSON body to a URL. Twenty lines of code.</p><p>What separates a hobby project from production is what happens when the POST fails. And here’s the thing nobody tells you up front: your retry schedule is going to be wrong, and you’ll only find out which way when something breaks.</p><p>A schedule of retries every 10 seconds for 30 minutes sounds reasonable until a receiver does a 35-minute deploy and every retry lands on a 502. If you run your own delivery and need to ride out outages like that, a longer schedule such as 30s, 2m, 10m, 1h, then hourly for 24h works, with jitter on every interval. Without the jitter, when a shared hosting provider recovers from an outage, every retry timer in your system fires at the same millisecond and you DDoS the receiver on its way back up. Every retry reuses the same event_id.</p><p>VesselAPI’s own webhook delivery is short by comparison: after a failed attempt (a connection error or any non-2xx response) it retries up to three times, at 1, 2 and 4 seconds, with the same X-Delivery-ID each time. If the last attempt fails, the event is not redelivered. If your endpoint can be down for longer than that, put something durable in front of it so the POST still succeeds while your server is away.</p><p>Sign the payloads. HMAC-SHA256 of the request body with a per-customer secret, sent in a header — Stripe calls theirs Stripe-Signature, GitHub uses X-Hub-Signature-256. The header name doesn&#39;t matter. What matters is that the timestamp goes <em>inside</em> the signed material, and you reject anything more than a few minutes old. Without that, anyone who captures one valid request can replay it forever. Stripe&#39;s scheme is the widely-copied reference; copy it shamelessly.</p><p>Then there’s the delivery log. Every attempt (success, failure, response code, response body, latency) written somewhere queryable. When someone says “we never got the notification for vessel X”, the log ends the argument quickly: yes you did, here’s the 200; or no, here are the attempts, and your server returned 503 to every one.</p><p>And two layers of failure handling, which get conflated more often than they should. A consecutive-failure threshold (say, ten failures inside a minute) stops you immediately hammering an endpoint that’s clearly down. A separate endpoint-health policy decides when to give up entirely, mark the endpoint dead, and page someone. Choose that window deliberately: set it too long and the receiver discovers on Monday that nothing has worked since Friday.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*wsbmmi4LK44ZRtEf.png" /></figure><h3>The geofence itself is a choice</h3><p>One last subtlety. A geofence isn’t just a shape. It’s a <em>modeling decision</em> about what you actually care about.</p><p>A circular fence around a port’s coordinates is easy to draw and almost always wrong. Ports aren’t circles. They have channels, anchorages outside the breakwater, restricted zones inside. A 5-mile radius around Rotterdam’s city center will include vessels transiting the North Sea that have nothing to do with the port — and will simultaneously miss the Maas pilot boarding ground, which sits well offshore from the outer terminals. Rotterdam’s port complex stretches more than 40 km from the sea inland, so “Rotterdam” as a single coordinate is already a fiction.</p><p>A polygon traced along the actual port boundary is better, but vessels at the outer anchorage are arguably already arriving, and a freight forwarder cares about a different boundary than a bunker fuel supplier or a port authority. In practice one vessel crosses several geofences on a single voyage — different fences for different customers asking different questions about the same ship.</p><p>Get the infrastructure right and you still have to decide where to put the line. The circle on the map is the easy part.</p><p><em>Originally published at </em><a href="https://vesselapi.com/blog/advanced-notifications-geofence-polygon"><em>https://vesselapi.com</em></a><em> on May 25, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5d951df7a10c" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Self-Hosted Vessel Email Alerts with AWS Lambda and SES]]></title>
            <link>https://vessel-api.medium.com/self-hosted-vessel-email-alerts-with-aws-lambda-and-ses-dc70bb21fb7e?source=rss-00c2102cfd05------2</link>
            <guid isPermaLink="false">https://medium.com/p/dc70bb21fb7e</guid>
            <category><![CDATA[aws-lambda]]></category>
            <category><![CDATA[maritime]]></category>
            <category><![CDATA[webhooks]]></category>
            <category><![CDATA[serverless]]></category>
            <category><![CDATA[shipping]]></category>
            <dc:creator><![CDATA[VesselAPI]]></dc:creator>
            <pubDate>Tue, 28 Apr 2026 00:00:45 GMT</pubDate>
            <atom:updated>2026-05-06T11:14:00.786Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1000/0*bdXKSY7suE79JWOt.png" /></figure><p>The thing you actually want is an email.</p><p>Not a dashboard you have to remember to open. Not a webhook you have to write a server for. An email — the kind your bank sends when a charge clears, the kind your airline sends when the gate changes — that quietly arrives in your inbox and tells you the <em>Ever Given</em> just entered Suez, or that one of your chartered tankers’ ETA has shifted by six hours.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/614/1*kzuLTan4rICb9uMwa9pJ3g.png" /><figcaption>vesselapi.com</figcaption></figure><p>VesselAPI sends notifications as webhooks. The full post walks through a small AWS Lambda + SES + DynamoDB pipeline that turns those webhooks into vessel arrival, departure, and ETA-change emails — about a hundred lines of Python and fifty lines of SAM YAML, with idempotency around webhook retries and a timing-safe HMAC verify. Idle cost is zero; running cost is a few cents to a dollar a month.</p><p>Read the full post on the VesselAPI blog: <a href="https://vesselapi.com/blog/email-alerts-aws-lambda-ses">https://vesselapi.com/blog/email-alerts-aws-lambda-ses</a></p><p>Code on GitHub: <a href="https://github.com/vessel-api/vessel-email-alerts-aws-sam">https://github.com/vessel-api/vessel-email-alerts-aws-sam</a></p><p><em>Originally published at </em><a href="https://vesselapi.com/blog/email-alerts-aws-lambda-ses"><em>https://vesselapi.com</em></a><em> on April 28, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=dc70bb21fb7e" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>