<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: VesselAPI</title>
    <description>The latest articles on Forem by VesselAPI (@vessel_api).</description>
    <link>https://forem.com/vessel_api</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3838965%2Fd6a827f7-26de-4683-a155-c41378675ee7.jpg</url>
      <title>Forem: VesselAPI</title>
      <link>https://forem.com/vessel_api</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/vessel_api"/>
    <language>en</language>
    <item>
      <title>What an MMSI Lookup Actually Looks Up</title>
      <dc:creator>VesselAPI</dc:creator>
      <pubDate>Fri, 22 May 2026 13:51:09 +0000</pubDate>
      <link>https://forem.com/vessel_api/what-an-mmsi-lookup-actually-looks-up-g8f</link>
      <guid>https://forem.com/vessel_api/what-an-mmsi-lookup-actually-looks-up-g8f</guid>
      <description>&lt;p&gt;A ship has a name, and a name is a fragile thing. &lt;em&gt;Maersk Detroit&lt;/em&gt; could be sold tomorrow and become &lt;em&gt;Ocean Star&lt;/em&gt;. &lt;em&gt;Ocean Star&lt;/em&gt; could be re-flagged, repainted, renamed again, and resurface six months later moving sanctioned crude in the South China Sea. Names lie. Flags lie. Even the hull, given a coat of paint and a torch to the bow numbers, can be made to lie.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fvesselapi.com%2Fblog%2Fposts%2Fvessel-lookup-by-name-imo-mmsi%2Fimages%2Fhero.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fvesselapi.com%2Fblog%2Fposts%2Fvessel-lookup-by-name-imo-mmsi%2Fimages%2Fhero.png" alt="A ship's bridge and antennas silhouetted at golden hour" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What doesn't lie — or at least, lies less often — is a nine-digit number broadcast by the ship's radio at intervals ranging from every couple of seconds when underway to every few minutes at anchor. That number is the MMSI. And when you build an "MMSI lookup," what you are really doing is asking a deceptively hard question: &lt;em&gt;given this number, who is this, right now, today?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first time I queried an MMSI on a vessel I was actively tracking and got back a tanker that had been scrapped in 2019, I assumed the API was broken. It wasn't. The MMSI had been reassigned. That was a fun afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number itself
&lt;/h2&gt;

&lt;p&gt;MMSI stands for Maritime Mobile Service Identity. It is, on the surface, exactly what it sounds like: a unique radio identifier assigned to a vessel (or a coast station, or a navigational aid, or a life raft's distress beacon) so that other radios can address it. Nine digits. Broadcast in every AIS message the ship transmits. If you've ever seen a live map of ocean traffic — those flickering arrows crawling across the Mediterranean — every one of those arrows is, at the wire level, an MMSI.&lt;/p&gt;

&lt;p&gt;The first three digits are called the &lt;strong&gt;MID&lt;/strong&gt;, the Maritime Identification Digits, and they encode the country whose flag the vessel sails under. 232 through 235 are the United Kingdom. 338 and 366 through 369 are the United States. 477 is Hong Kong. The remaining six digits are assigned by that country's licensing authority, more or less however they like.&lt;/p&gt;

&lt;p&gt;So already, before you've looked anything up, an MMSI tells you something. &lt;code&gt;538&lt;/code&gt; at the front? That's the Marshall Islands — one of the world's largest open registries and a dominant flag of convenience for tanker operators, for reasons that have very little to do with the Marshall Islands. &lt;code&gt;412&lt;/code&gt; at the front? Mainland China. The number is a passport, sort of.&lt;/p&gt;

&lt;p&gt;But here is the first uncomfortable truth: &lt;strong&gt;MMSIs are not permanent.&lt;/strong&gt; When a vessel changes flag, it gets a new MMSI. When it's sold to an operator in a different jurisdiction, new MMSI. When a transponder is replaced and the new one is misconfigured — which happens more often than the industry likes to admit — the MMSI can change with no paperwork at all. Sometimes two vessels broadcast the same MMSI by accident. Sometimes, less innocently, on purpose.&lt;/p&gt;

&lt;p&gt;So a lookup isn't a dictionary read. It's a forensic question.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp2bc10i2dnxtg4d5jk0g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp2bc10i2dnxtg4d5jk0g.png" alt="Close-up of a ship hull with painted-over lettering" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What a lookup is actually doing
&lt;/h2&gt;

&lt;p&gt;When you call something like &lt;code&gt;GET /vessel/{mmsi}&lt;/code&gt; on a vessel data API, here's what's happening behind that single line of HTTP.&lt;/p&gt;

&lt;p&gt;First, the system has to find every record that has ever been associated with that MMSI. That sounds trivial; it isn't. The same nine digits, over the course of a decade, might point to two or three completely different ships. Good lookup services maintain a temporal index — in practice, an append-only event log keyed on &lt;code&gt;(mmsi, valid_from, valid_to, vessel_id)&lt;/code&gt; that you can query at a point in time. Not just &lt;em&gt;what is MMSI 477123456?&lt;/em&gt; but &lt;em&gt;what was MMSI 477123456 on the afternoon of 14 March 2021?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Second, it has to resolve to the ship's more durable identifiers. The MMSI is the radio identity; the &lt;strong&gt;IMO number&lt;/strong&gt; is the hull identity. The IMO ship identification scheme is mandated by the International Maritime Organization and administered by its authorized registrar (currently S&amp;amp;P Global, formerly IHS Markit, formerly Lloyd's Register Fairplay — the genealogy itself tells you something about how this industry works). The IMO number is intended to follow the steel of the ship from cradle to scrapyard, regardless of how many times it changes name or flag. In practice, vessels not engaged on international voyages — and most fishing boats and inland barges — don't have IMO numbers at all, and some operators of larger vessels have been known to broadcast incorrect ones. But when an MMSI and an IMO agree, and have agreed for years, you have something close to ground truth.&lt;/p&gt;

&lt;p&gt;Third, it has to enrich. This is the layer most people don't think about until they need it. Beyond identity: dimensions, deadweight tonnage, year and place of build, class society, last known position, last known port call, whether the vessel appears on a sanctions list under any of its previous names. And ownership — which is its own swamp. &lt;em&gt;Current operator&lt;/em&gt;, &lt;em&gt;registered owner&lt;/em&gt;, and &lt;em&gt;beneficial owner&lt;/em&gt; are three different entities, often deliberately so. AIS doesn't carry any of them. Knowing who actually owns a ship requires an entirely different lookup chain, and the answer is frequently a shell company in a jurisdiction that does not enjoy being asked.&lt;/p&gt;

&lt;p&gt;A "lookup" that returns only the name and flag is barely a lookup. It's the cover of a book.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dark inputs
&lt;/h2&gt;

&lt;p&gt;Here's the part that makes this genuinely difficult. AIS — the Automatic Identification System that carries the MMSI — was developed through the 1990s as a vessel traffic management and collision-avoidance system, ratified under SOLAS in the early 2000s. It assumed everyone was honest, because the original users were port authorities and bridge officers, and in a collision scenario, dishonesty kills you first. The lack of any authentication layer was a deliberate architectural choice for low-cost receiver compatibility — but the upshot is the same: AIS is a network of unsigned messages from strangers, and that assumption no longer holds.&lt;/p&gt;

&lt;p&gt;Vessels engaged in sanctions evasion routinely &lt;strong&gt;spoof&lt;/strong&gt; their MMSI, broadcasting a number that belongs to a different ship — or to no ship at all. They go &lt;strong&gt;dark&lt;/strong&gt;, switching off their transponder entirely for days or weeks. And in cases documented in OFAC advisories and UN Panel of Experts reports on Iran and North Korea sanctions, they meet other vessels in unmonitored waters and conduct ship-to-ship transfers: the dirty cargo physically moves to a clean ship, sometimes while one or both vessels broadcast another ship's identity entirely. The MMSI doesn't literally swap. The cargo does, and the electronic identity is manipulated to obscure who carried what.&lt;/p&gt;

&lt;p&gt;A naive MMSI lookup — the kind that just returns whatever the latest AIS broadcast claims — will happily report back the spoofed identity as fact. A serious lookup cross-references the broadcast against fleet registries, historical position tracks, hull dimensions detected by satellite synthetic aperture radar, and port arrival records. When the AIS says "I am a 180-meter bulk carrier" and the satellite radar says "you are a 250-meter tanker," the lookup is supposed to notice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F277npp9jv9gdthvepo6y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F277npp9jv9gdthvepo6y.png" alt="A crowded anchorage seen from above at twilight" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Building one that doesn't lie to you
&lt;/h2&gt;

&lt;p&gt;There are a lot of "vessel lookup" APIs in the world. Most of them are a thin wrapper around the most recent AIS broadcast, which is to say, most of them will cheerfully repeat whatever a transponder told them six minutes ago, with no provenance and no sense of history. That's fine for drawing dots on a map. It is not fine for any decision with consequences.&lt;/p&gt;

&lt;p&gt;The two things I actually care about, when evaluating one of these services, are &lt;em&gt;time&lt;/em&gt; and &lt;em&gt;provenance&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Time, because an MMSI is a moving target. If the API can't tell you what an MMSI meant on a specific date, it cannot help you with a sanctions investigation, an insurance claim, or any question about the past. The phrase "this MMSI belonged to a different vessel last year" should be a normal sentence in its response, not a footnote.&lt;/p&gt;

&lt;p&gt;Provenance, because "vessel name: ATLANTIC PIONEER" means very different things depending on where that string came from. A name pulled from the IHS/S&amp;amp;P registry against a confirmed IMO number is near-truth. The same name pulled from a single AIS broadcast forty minutes ago, with no IMO cross-check and no historical confirmation, is a rumour. A lookup that doesn't distinguish between these two cases is, frankly, lying to you politely.&lt;/p&gt;

&lt;p&gt;And then there's the question of whether the API has actually thought about the long tail. Most fail there — because the small fishing boats with no IMO, the inland river barges with regional MMSI quirks, the AIS aids-to-navigation with the &lt;code&gt;111&lt;/code&gt; prefix, the AIS-SART distress beacons in the &lt;code&gt;970&lt;/code&gt; range — none of these things show up in the sales demo. They show up at three in the morning when your pipeline throws a null pointer and a container ship somewhere is unaccounted for.&lt;/p&gt;

&lt;p&gt;The nine digits look like a phone number. They are not a phone number. They are a thread — and pulling on a thread is how you find out what's actually on the other end of the line. In this case: forty years of flag-state politics, satellite coverage gaps, and ships that, very specifically, do not want to be found.&lt;/p&gt;

</description>
      <category>mmsi</category>
      <category>vessellookup</category>
      <category>ais</category>
      <category>apitutorial</category>
    </item>
    <item>
      <title>How to Measure a Ship's CO Emissions From Land</title>
      <dc:creator>VesselAPI</dc:creator>
      <pubDate>Wed, 20 May 2026 11:42:57 +0000</pubDate>
      <link>https://forem.com/vessel_api/how-to-measure-a-ships-co2-emissions-from-land-2c2</link>
      <guid>https://forem.com/vessel_api/how-to-measure-a-ships-co2-emissions-from-land-2c2</guid>
      <description>&lt;h1&gt;
  
  
  How to Measure a Ship's CO₂ Emissions From Land
&lt;/h1&gt;

&lt;p&gt;Here's a question that sounds simple until you try to answer it: how much CO₂ did that container ship just emit on its way from Shanghai to Rotterdam?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fvesselapi.com%2Fblog%2Fposts%2Fco2-emissions-eu-mrv-walkthrough%2Fimages%2Fog.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fvesselapi.com%2Fblog%2Fposts%2Fco2-emissions-eu-mrv-walkthrough%2Fimages%2Fog.png" alt="A practical walkthrough of measuring vessel CO₂ emissions via API — what EEXI actually means, why fuel type changes everything, and how to verify a number before you trust it." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can see the ship. You know roughly where it went, and roughly how fast. You can look up its size. And yet the honest answer — the one a regulator or a carbon accountant would actually accept — requires you to know things about that vessel that aren't printed on the hull. What fuel was in its tanks. What its engines were designed to do at three-quarters load. Whether its propeller has been polished this year. Whether the paint on its hull is the slick anti-fouling kind or the kind that's currently hosting a small ecosystem of barnacles.&lt;/p&gt;

&lt;p&gt;This tutorial is about closing that gap. We're going to walk through how to ask our &lt;code&gt;/emissions&lt;/code&gt; endpoint for the CO₂ output of a single vessel, and — more interestingly — how to understand what the API is actually doing under the hood. Because if you're going to make a decision based on a number, you should know where the number came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing everyone gets wrong
&lt;/h2&gt;

&lt;p&gt;Most people assume ship emissions are calculated the way car emissions are: you burn X litres of fuel, each litre contains Y grams of carbon, multiply, done.&lt;/p&gt;

&lt;p&gt;That's roughly right for a car. It falls apart for ships almost immediately.&lt;/p&gt;

&lt;p&gt;The IMO does publish carbon factors for each marine fuel type, and they're as clean as you could want. For heavy fuel oil (HFO), the number is &lt;strong&gt;3.114 grams of CO₂ per gram of fuel burned&lt;/strong&gt;. For VLSFO — the blended low-sulphur product most large vessels burn today — it's &lt;strong&gt;3.151&lt;/strong&gt;. For marine gas oil (MGO), 3.206. For LNG, the direct combustion factor is 2.750. These aren't interchangeable. Submitting an MRV report with the HFO factor against a tank of VLSFO will fail verification, which is exactly the kind of thing I got wrong the first time I tried to reconcile a vessel's annual fuel mix against its reported emissions. The numbers look close. They aren't.&lt;/p&gt;

&lt;p&gt;If you knew exactly how much of each fuel a ship had burned, you'd be done. The problem is that almost nobody outside the ship does. Aggregate fuel consumption is reported annually under the IMO Data Collection System; per-voyage data is collected by the EU MRV regime for vessels calling at European ports, but it isn't publicly accessible. If you want per-voyage emissions from the outside, you have to estimate them — working backwards from what the ship was &lt;em&gt;designed&lt;/em&gt; to burn, and adjusting for what it probably actually did.&lt;/p&gt;

&lt;p&gt;The starting point for that estimate is a three-letter acronym you'll see everywhere in this domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  What EEXI actually is
&lt;/h2&gt;

&lt;p&gt;EEXI stands for Energy Efficiency Existing Ship Index. The name sounds like it was designed to deter questions, so let's ignore it.&lt;/p&gt;

&lt;p&gt;What EEXI actually is: a fuel economy rating. It's the mpg sticker on the window, except the window is a 300-metre container ship and the sticker is buried in a classification society database in Hamburg.&lt;/p&gt;

&lt;p&gt;For most existing vessels of &lt;strong&gt;5,000 gross tonnes and above&lt;/strong&gt; — broadly, the ships big enough to do international trade and already in service when the regulation took effect on 1 January 2023 — the IMO requires a calculation that answers a single question: &lt;em&gt;if this ship sailed at the speed corresponding to 75% of its maximum continuous engine rating, in calm weather, in a defined reference state, how many grams of CO₂ would it emit per tonne of cargo per nautical mile?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's it. Standardised conditions, one number, comparable across vessels. A modern, efficient large bulk carrier might come in around 3 g CO₂/tonne-nm. An older, smaller one might be roughly double that. Container ships sit higher again because their capacity is measured differently. Lower is better, the way fewer litres per 100km is better on a car.&lt;/p&gt;

&lt;p&gt;EEXI on its own doesn't tell you what a ship emitted yesterday. But combined with a known route, a speed profile, and a load factor, it lets you build a credible estimate without ever needing the captain's fuel logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The request
&lt;/h2&gt;

&lt;p&gt;Let's measure something. Here's a minimal call for a vessel by IMO number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://api.vesselapi.com/v1/emissions?imo=9395044"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_API_KEY"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here's the relevant chunk of the response, trimmed for clarity. The values are &lt;strong&gt;illustrative&lt;/strong&gt; — they show the shape of the response, not certified figures for this vessel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"emissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"eexi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"attained"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;6.82&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;7.84&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"unit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"g_co2_per_tonne_nm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"compliant"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fuel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"primary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"VLSFO"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"eca"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MGO"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"carbon_factor_primary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;3.151&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"carbon_factor_eca"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;3.206&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"verifier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DNV"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"verified_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2023-04-12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"approved_calculation"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The most important field here is probably the one you'd skip past on first reading: &lt;code&gt;verifier&lt;/code&gt;. We'll come back to it. First, the things this response is telling you about the ship itself.&lt;/p&gt;

&lt;p&gt;EEXI arrives as a pair — &lt;code&gt;attained&lt;/code&gt; and &lt;code&gt;required&lt;/code&gt;. EEXI is an efficiency index, so lower is better, and &lt;code&gt;required&lt;/code&gt; is a ceiling: the maximum value the IMO will accept for this ship type and size. The vessel is compliant if attained sits at or below that ceiling. If it doesn't, the primary remedy is an Engine Power Limitation: a technical and administrative measure that caps engine output and brings the attained index down. Switching fuels operationally doesn't change EEXI itself — EEXI is a design-condition index, fixed at certification. Fuel choices affect the &lt;em&gt;operational&lt;/em&gt; metric (CII) instead. A ship that can't demonstrate EEXI compliance can't get its certificate endorsed, which means in practice it can't legally trade. It isn't really a menu.&lt;/p&gt;

&lt;p&gt;The fuel block reports two carbon factors because most large vessels switch fuels at ECA boundaries — the North Sea, the Baltic, the US and Canadian coasts, and (from 1 May 2025) the Mediterranean. VLSFO on the open ocean, MGO or another compliant blend inside the zone. For a voyage that crosses an ECA boundary, you need to know which fuel was burning where.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the verifier matters more than the number
&lt;/h2&gt;

&lt;p&gt;An emissions number without a verifier is a rumour.&lt;/p&gt;

&lt;p&gt;I mean this practically. EEXI calculations are prepared by the ship's owner or technical manager and submitted to a classification society — DNV, ABS, Lloyd's Register, ClassNK, Bureau Veritas. These bodies do the actual checking: engine specs, hull form, propulsion train, sea margin assumptions, the lot. They either approve the calculation or send it back. The number that survives this process is the only one that means anything in a regulatory context.&lt;/p&gt;

&lt;p&gt;When the API returns a &lt;code&gt;verifier&lt;/code&gt; block, it's saying: this isn't our estimate. This is the number a recognised classification society signed off on, with their reputation attached. If the verifier field is missing, or shows &lt;code&gt;"method": "estimated"&lt;/code&gt;, you're looking at a calculated approximation. Most APIs don't surface this distinction at all, which is how an estimated figure ends up in someone's audited disclosure.&lt;/p&gt;

&lt;p&gt;For the EU ETS, which began applying to shipping in 2024 (40% of verified emissions surrendered that year, 70% in 2025, 100% from 2026), or for CSRD disclosures, or for any scope 3 reporting that has to survive contact with an auditor — the verified number is the one that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  From EEXI to an actual voyage
&lt;/h2&gt;

&lt;p&gt;EEXI gives you grams of CO₂ per tonne-nautical-mile under reference conditions. To get the emissions of an actual voyage, the back-of-envelope multiplication is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;voyage_emissions = EEXI × cargo_tonnes × distance_nm × correction_factor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This formula works, with friction. EEXI is calibrated to a defined reference state, which for bulk carriers and tankers is 70% of deadweight but for container ships is a TEU-based capacity figure — not a deadweight fraction at all. Check the IMO guidelines for the vessel class before applying a load correction. The &lt;code&gt;cargo_tonnes&lt;/code&gt; you plug in should reflect actual cargo on board, because a container ship sailing 60% laden carries 60% of the cargo but burns considerably more than 60% of the fuel. And the formula degenerates on a ballast leg: zero cargo gives zero emissions, which is physically impossible. Ballast voyages need a displacement-based substitution or allocation back to the laden leg.&lt;/p&gt;

&lt;p&gt;The correction factor is where the rest of the honesty lives. Real voyages aren't reference conditions. Heavy weather adds fuel. Slow steaming subtracts it — dramatically. For full-form displacement vessels, fuel consumption scales with speed at an exponent typically between 2.7 and 3.5, with the cube as the standard admiralty anchor. That non-linearity is why dropping container ship service speeds from around 21 knots toward 14 was, by most accounts, the single biggest operational lever the industry pulled in the last decade. Not technology. Just patience, and the willingness to leave port a day earlier.&lt;/p&gt;

&lt;p&gt;For a rough estimate, a correction factor between 1.1 and 1.3 covers most container and bulk voyages in fair-to-moderate conditions. For anything precise, you want AIS-derived speed profiles for the actual voyage — which is the bridge between the &lt;code&gt;/emissions&lt;/code&gt; endpoint and the &lt;code&gt;/positions&lt;/code&gt; endpoint. Combine the two and the error band tightens, though the residual error depends heavily on voyage type and the quality of the load assumption.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this still doesn't tell you
&lt;/h2&gt;

&lt;p&gt;EEXI is a design-condition metric. It doesn't capture hull fouling — industry studies suggest six months of moderate fouling can add roughly 10% to fuel burn, and severely fouled hulls have been measured at 30% or worse. It doesn't capture weather routing decisions. It doesn't capture auxiliary engines running in port, which on a large container ship is not a rounding error. And it doesn't capture methane slip from LNG carriers, which deserves a paragraph of its own.&lt;/p&gt;

&lt;p&gt;Methane slip is uncombusted natural gas escaping through the engine. It's a separate problem from the LNG carbon factor, and it doesn't show up in EEXI at all. Fossil methane carries a GWP₁₀₀ of roughly 30 per the most recent IPCC figures (the exact value depends on the assessment report and whether you include climate-carbon feedbacks), so a few percent of slip can erase the apparent climate advantage of switching from oil to gas. Two-stroke high-pressure LNG engines slip very little; some four-stroke low-pressure designs slip enough to matter. The API returns a CO₂-only figure and flags LNG vessels separately, because rolling slip into a single number requires picking a GWP horizon, and that choice is a political one as much as a scientific one.&lt;/p&gt;

&lt;p&gt;So: where does that leave you? If you're filing EU ETS reports, the verified EEXI number is where you start and your auditor decides whether you need to go further. If you're building a product that surfaces emissions to end users, a well-bounded estimate is almost certainly fine and most users won't know the difference. If you're trying to prove in a contract dispute that a specific voyage exceeded a specific threshold — call a naval architect, not an API.&lt;/p&gt;

&lt;p&gt;At sea, with no fuel pump to read and no tailpipe to sniff, the gap between what was burned and what we can prove was burned is wider — and stranger — than most people realise.&lt;/p&gt;

</description>
      <category>emissions</category>
      <category>tutorial</category>
      <category>eexi</category>
      <category>cii</category>
    </item>
    <item>
      <title>Why There's a Tanker in Central Madrid</title>
      <dc:creator>VesselAPI</dc:creator>
      <pubDate>Tue, 19 May 2026 22:27:50 +0000</pubDate>
      <link>https://forem.com/vessel_api/why-theres-a-tanker-in-central-madrid-46jk</link>
      <guid>https://forem.com/vessel_api/why-theres-a-tanker-in-central-madrid-46jk</guid>
      <description>&lt;p&gt;We ingest about a million raw AIS messages every hour. Roughly four out of ten never make it to our database.&lt;/p&gt;

&lt;p&gt;That is not because they are all wrong. Most of those rejected messages aren't position reports at all — they are vessel name broadcasts, safety messages, channel management commands, or interrogation requests that arrive on the same feed. Once you strip those out, you are left with the actual position data. A 300-metre oil tanker reporting its position as central Madrid. A bulk carrier allegedly doing 90 knots — which would make it faster than most warships. A cargo vessel that teleports from the North Sea to the Sahara Desert between two consecutive reports, three seconds apart.&lt;/p&gt;

&lt;p&gt;The genuinely bad position data — invalid coordinates, impossible jumps, sentinel values from transponders that lost GPS — is a smaller fraction, probably in the single-digit percentages based on what we see and what academic literature reports. But even a few percent, at the volumes AIS produces, means tens of thousands of phantom ships drifting across continents every day.&lt;/p&gt;

&lt;p&gt;This is AIS — the Automatic Identification System — the backbone of global vessel tracking. Every ship over 300 gross tonnes on an international voyage is required by SOLAS to broadcast its identity and location over VHF radio, every few seconds, around the clock. Around 400,000 vessels do this simultaneously, generating over 300 million messages per day. It is one of the largest real-time geospatial data streams on the planet.&lt;/p&gt;

&lt;p&gt;Nobody warns you about this part.&lt;/p&gt;

&lt;p&gt;We are VesselAPI, a two-person company in Málaga, Spain. We built a REST API that takes this raw radio data and turns it into something you can actually use. What follows is the story of how we learned, the hard way, that maritime data wants to lie to you — and the filtering we built to catch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What “Not Available” Looks Like at 161.975 MHz
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpljmyuotavipyq3jwaw7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpljmyuotavipyq3jwaw7.png" alt="Ship VHF antenna mast with glitching digital coordinate overlays showing 91.000000°N" width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The AIS specification — ITU-R M.1371-5, if you want to look it up — was designed by people who understood that transponders would sometimes have no idea where they are. So they built in sentinel values: specific numbers that mean “I don’t know.”&lt;/p&gt;

&lt;p&gt;Latitude 91° North. Longitude 181° East. Speed 102.3 knots. Heading 511°.&lt;/p&gt;

&lt;p&gt;These are not real coordinates. They are the AIS equivalent of a shrug. A transponder that has lost its GPS fix, or has just been powered on and hasn’t acquired satellites yet, is supposed to transmit these values. The problem is that plenty of systems downstream — tracking platforms, analytics tools, map renderers — don’t check for them. They plot the point. And suddenly you have a vessel at 91° latitude, which is one degree past the North Pole, in mathematical space that doesn’t physically exist.&lt;/p&gt;

&lt;p&gt;We filter these out. Latitude over 90, longitude over 180, SOG at 102.3, heading at 511 — gone before they touch the database. The same goes for (0, 0) — Null Island, a fictional place in the Gulf of Guinea that is the most popular port on Earth if you believe unfiltered GPS data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pipeline
&lt;/h2&gt;

&lt;p&gt;When a raw AIS message arrives, it passes through four stages before it becomes an API response. We did not plan four stages. We started with coordinate bounds checking and kept adding layers as new classes of garbage revealed themselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  Message Type Filtering
&lt;/h3&gt;

&lt;p&gt;AIS has 27 message types. Types 1, 2, and 3 are Class A position reports — the bread and butter, broadcast every 2 seconds to 3 minutes depending on speed and navigational status. A container ship doing 20 knots and changing course reports every 2 seconds. The same ship at anchor drops to every 3 minutes. Types 18 and 19 are Class B position reports from smaller vessels. The other 22 message types — binary data, safety broadcasts, channel management, interrogation requests — are not positions.&lt;/p&gt;

&lt;p&gt;We were surprised how often non-position messages leaked into position processing. A Type 5 message (static and voyage data — ship name, dimensions, destination) has no coordinates but arrives on the same feed. Our first week in production, we had phantom entries with zeroed-out positions because we weren’t filtering on message type.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MessageType&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aisstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;POSITION_REPORT&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aisstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;STANDARD_CLASS_B_POSITION_REPORT&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aisstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EXTENDED_CLASS_B_POSITION_REPORT&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;valid&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;
&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines. They fixed a category of bad data that had cost us two days of debugging.&lt;/p&gt;

&lt;h3&gt;
  
  
  MMSI Validation
&lt;/h3&gt;

&lt;p&gt;Every AIS transponder has a Maritime Mobile Service Identity — a 9-digit number that encodes what kind of entity is broadcasting. Ship stations use MMSIs in the range 100,000,000 to 799,999,999, where the first three digits (the MID — Maritime Identification Digits) roughly indicate the flag state’s region: 2xx for Europe, 3xx for the Americas, 4xx for Asia, and so on.&lt;/p&gt;

&lt;p&gt;Outside that range, you get coast stations (prefixed 00), SAR aircraft (prefixed 111), man-overboard devices (972), and EPIRBs (974). All of these broadcast AIS, and none of them are ships. Then there are MMSIs that shouldn’t exist at all — misconfigured transponders with default factory values, test transmissions. We reject anything outside the vessel range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MMSI&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;100000000&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MMSI&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;799999999&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is also a subtler problem: MMSI sharing. When multiple vessels use the same MMSI — whether through misconfiguration or deliberate sanctions evasion — a single identity appears to teleport across oceans. Your tracking system shows one ship doing 4,000 knots because it is actually two ships on opposite sides of the Indian Ocean, alternating transmissions. This is a documented tactic used by the dark fleet. Kpler identified 261 vessels that spoofed AIS before being sanctioned. An estimated 600 to 1,000 vessels — roughly 10% of the global large oil tanker fleet — operate this way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Coordinate Validation
&lt;/h3&gt;

&lt;p&gt;After message type and MMSI filtering, we validate the coordinates themselves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Latitude&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Longitude&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Latitude&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Latitude&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Longitude&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Longitude&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The (0, 0) check catches Null Island — what happens when a GPS chipset defaults to zero. The bounds check catches both corrupted data and the sentinel values from the spec (91° and 181° both fall outside the valid range). Simple, fast, eliminates a remarkable amount of junk.&lt;/p&gt;

&lt;p&gt;But it has a fundamental limitation: it cannot tell you whether a position is &lt;em&gt;plausible&lt;/em&gt;, only whether it is &lt;em&gt;possible&lt;/em&gt;. A ship reporting its position as downtown Lagos passes every check — valid latitude, valid longitude, valid MMSI. It is also on land. We could add coastline polygon checks, but at ~235 messages per second on a single t3.medium instance, the spatial computation cost does not justify the catch rate. Instead, we handle plausibility in the next layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Jump Detection
&lt;/h3&gt;

&lt;p&gt;The first three stages examine each message in isolation. This one looks at the sequence.&lt;/p&gt;

&lt;p&gt;For every MMSI, we keep the last known good position in a &lt;code&gt;sync.Map&lt;/code&gt; — Go’s concurrent map, which fits here because reads vastly outnumber writes and the key set (active vessel MMSIs) is relatively stable. When a new position arrives, we compute the implied speed: how fast would this ship have to be moving to get from the last known position to the new one?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;const&lt;/span&gt; &lt;span class="n"&gt;maxSpeedKnots&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;

&lt;span class="n"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;approxDistanceKm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lon1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lat2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lon2&lt;/span&gt; &lt;span class="n"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;float64&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;const&lt;/span&gt; &lt;span class="n"&gt;kmPerDeg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;111.0&lt;/span&gt;
    &lt;span class="n"&gt;dLat&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lat1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;kmPerDeg&lt;/span&gt;
    &lt;span class="n"&gt;midLat&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;lat2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pi&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;180.0&lt;/span&gt;
    &lt;span class="n"&gt;dLon&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lon2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lon1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;kmPerDeg&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;midLat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dLat&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;dLat&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;dLon&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;dLon&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;In&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;detection&lt;/span&gt; &lt;span class="n"&gt;logic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="n"&gt;distKm&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;approxDistanceKm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Latitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Longitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lon&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;speedKnots&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distKm&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;elapsedSeconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;3600.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1.852&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;speedKnots&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;maxSpeedKnots&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;suspectedGlitch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The threshold is 500 knots — about 926 km/h, roughly 10 times the speed of the fastest commercial vessel on Earth. If the implied speed exceeds that, the position is flagged as a suspected glitch.&lt;/p&gt;

&lt;p&gt;Why 500 and not something tighter, like 30 or 50? Because AIS messages arrive out of order, with gaps, from multiple sources with different latencies. A container ship at 20 knots that misses a few reports and then sends a batch can look like a jump. Setting the threshold at physical impossibility means we catch genuine GPS failures — the Madrid tanker, the Sahara cargo ship — without flagging normal transmission delays. We had an earlier version with the threshold at 50 knots, and it was flagging container ships rounding headlands in the English Channel. That lasted about a day.&lt;/p&gt;

&lt;p&gt;The equirectangular distance approximation is deliberate. For consecutive AIS reports — typically seconds to minutes apart, so sub-10 km distances — the error is well under 1% at normal shipping latitudes. Even at 70°N, above most commercial routes, it stays under 5%. And against a 500-knot threshold, none of that matters. Haversine would be wasted precision.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Cache Trick
&lt;/h3&gt;

&lt;p&gt;The position cache has one design decision that makes the whole thing work: &lt;strong&gt;glitch positions do not update the cache.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;suspectedGlitch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;positionCache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mmsi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cachedPosition&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Latitude&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Longitude&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Timestamp&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a ship sends a glitched position — say, it briefly appears in the Sahara — and we update the cache with that position, then the next &lt;em&gt;real&lt;/em&gt; report from the English Channel would look like an impossible jump from the Sahara. One bad message poisons all future comparisons for that vessel.&lt;/p&gt;

&lt;p&gt;By only caching clean positions, the system self-heals. A single GPS spike gets flagged. The next legitimate report compares against the last &lt;em&gt;good&lt;/em&gt; position and passes normally. The glitch never propagates.&lt;/p&gt;

&lt;p&gt;The cache grows unboundedly right now — one entry per active MMSI, so around 60-70K entries in steady state. At ~100 bytes per entry, that is under 10 MB. We should probably add a TTL to evict vessels that stop reporting, but in practice it has not been a problem yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production
&lt;/h2&gt;

&lt;p&gt;Here is our monitoring map from February 5, 2026 — the day we deployed the jump detection layer:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fswabeupvxk7j4pukzonp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fswabeupvxk7j4pukzonp.png" alt="AIS monitoring map showing green valid vessel positions along coastlines and red glitch triangles scattered across Africa and inland areas" width="800" height="596"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Green dots: valid vessel positions. Red triangles: suspected GPS glitches. Production data, February 5, 2026.&lt;/p&gt;

&lt;p&gt;Green dots: valid vessel positions, tracing coastlines and shipping lanes. Red triangles: suspected glitches, scattered across sub-Saharan Africa, the Brazilian interior, the South Atlantic.&lt;/p&gt;

&lt;p&gt;Cintia pulled up the map the morning after we deployed it and called me over — “come look at Africa.” We’d been dismissing those positions as weird data for weeks. They were not weird. They were systematic GPS failures that had been flowing through to our API consumers the entire time.&lt;/p&gt;

&lt;p&gt;In a 24-hour window: 20.3 million positions, 64,412 unique vessels. 966 H3 cells — a hexagonal spatial index, each cell about 250 km² at resolution 5 — contain only glitch positions. The Sahara, the Congo, landlocked South America. We should have caught it sooner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AIS Data Is This Bad
&lt;/h2&gt;

&lt;p&gt;How does a system used by 400,000 vessels, mandated by international convention, produce this much junk?&lt;/p&gt;

&lt;p&gt;Start with the radio layer. Each AIS VHF channel gets exactly 2,250 time slots per minute. Class A transponders use SOTDMA — self-organizing time division multiple access — which lets them reserve a slot through a negotiation protocol. Class B CS transponders (used on smaller vessels) use carrier-sense TDMA, which is less deterministic: they listen for an opening and try to grab one. Newer Class B+ units also use SOTDMA, but the older CS units are still everywhere. In congested waters like the Singapore Strait, there are so many ships that Class B units cannot find empty slots. They fail to transmit, or their messages collide.&lt;/p&gt;

&lt;p&gt;Then there is GPS itself. Multipath reflection — signals bouncing off containers, crane structures, bridge superstructure — introduces positioning errors. And there is a configuration problem that nobody talks about enough: if a navigator sets a GPS offset correction on the bridge, the AIS transponder broadcasts the wrong position by exactly that offset for the entire voyage. Not spoofing. Just a button nobody remembered to reset.&lt;/p&gt;

&lt;p&gt;And then there is actual spoofing. In June 2017, around 20 vessels in the Black Sea simultaneously reported positions miles inland on Russian territory — the first widely documented case of mass maritime GPS spoofing. In 2019, hundreds of ships near Shanghai saw their positions form strange rotating circles — up to 200 metres in radius — near oil terminals and government buildings. C4ADS documented the pattern. MIT Technology Review described it as a form of GPS spoofing that had never been seen before. And in March 2026, per Windward’s GPS monitoring data, over 1,100 vessels in the Persian Gulf were disrupted in a single day following military strikes on Iran. Ships appeared inside airports, near nuclear facilities, deep in the Iranian interior.&lt;/p&gt;

&lt;p&gt;AIS was designed for collision avoidance, not adversarial security. There is no authentication in the protocol. SOLAS can require you to carry a transponder. It cannot require the transponder to be honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flag, Don’t Discard
&lt;/h2&gt;

&lt;p&gt;Our first instinct was to delete glitch positions. Don’t store them, don’t serve them, pretend they never happened. We had it built that way for about three weeks before a customer asked whether we could expose the glitch data — they were building a monitoring tool that specifically needed to detect GPS manipulation patterns.&lt;/p&gt;

&lt;p&gt;So we changed it. We store every glitch-flagged position with &lt;code&gt;suspected_glitch: true&lt;/code&gt; and return it in API responses. If you are building a map, you filter them out. If you are doing sanctions compliance work, those anomalies are exactly what you are looking for.&lt;/p&gt;

&lt;p&gt;Our filter pipeline is maybe 250 lines of Go. It runs on a single EC2 t3.medium — 2 vCPUs, 8 GB RAM, the kind of instance you forget exists until the bill shows up. About a million raw messages come in per hour; roughly 600,000 clean positions come out the other end. Nothing about this is impressive infrastructure. We built it in a week and have not thought about it much since, except when it catches something bizarre and you go “huh, a ship in Chad.”&lt;/p&gt;

&lt;p&gt;The ITU got the spec right. The problem is everything between the specification and the antenna — the part where the real world gets involved. If you are thinking about building on AIS data, budget more time for filtering than you think you need. We did not, and we spent a month serving phantom ships to paying customers before we noticed.&lt;/p&gt;

</description>
      <category>ais</category>
      <category>dataquality</category>
      <category>maritime</category>
      <category>gps</category>
    </item>
    <item>
      <title>The MMSI Lookup Is Lying to You (And That's Not Its Fault)</title>
      <dc:creator>VesselAPI</dc:creator>
      <pubDate>Mon, 18 May 2026 19:11:42 +0000</pubDate>
      <link>https://forem.com/vessel_api/the-mmsi-lookup-is-lying-to-you-and-thats-not-its-fault-2d11</link>
      <guid>https://forem.com/vessel_api/the-mmsi-lookup-is-lying-to-you-and-thats-not-its-fault-2d11</guid>
      <description>&lt;p&gt;You type a nine-digit number into a search box. Out comes a ship: a name, a flag, a length, a cargo type, maybe a fuzzy photo taken from a pilot boat in Rotterdam three years ago. The number is the MMSI — Maritime Mobile Service Identity — and it feels like the most boring possible piece of maritime data. It's an ID. You look it up. You get a ship.&lt;/p&gt;

&lt;p&gt;Except the MMSI is not, in any strict sense, the ship's identity. It's the identity of the &lt;em&gt;radio on the ship&lt;/em&gt;. And once you understand the difference, a lot of strange things about vessel tracking start to make sense — including why our &lt;code&gt;/vessel/{id}&lt;/code&gt; endpoint returns an array called &lt;code&gt;former_names[]&lt;/code&gt;, and why that array is sometimes longer than you'd believe.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an MMSI actually is
&lt;/h2&gt;

&lt;p&gt;The MMSI was never designed to be a permanent vessel identifier. It was designed by the ITU — the International Telecommunication Union, the same body that allocates radio spectrum and country calling codes — to route maritime radio traffic. For ship-station MMSIs, the first three digits, the Maritime Identification Digits, encode the country that issued the number. The rest is administrative.&lt;/p&gt;

&lt;p&gt;When SOLAS mandated Class A AIS for international trading vessels, the rollout was phased over several years beginning in 2002, with smaller tiers and existing fleets not fully captured until the late 2000s. The MMSI was the natural identifier to broadcast — every SOLAS vessel equipped with a DSC-capable VHF radio under the GMDSS rules of the 1990s already had one — and reusing it required no new numbering authority. It was a pragmatic choice, not a design decision.&lt;/p&gt;

&lt;p&gt;Here's what an MMSI actually is: a number associated with a transmitter, registered to a vessel, flagged to a country, at a particular moment in time. Change any of those and the relationship shifts. Sell a ship to an owner in another country and the MMSI changes — new flag, new MID, new number. Scrap a vessel and the MMSI may eventually be eligible for reissuance, though practices vary by flag state and most administrations have grown reluctant to reuse numbers because of the AIS contamination it causes. Far more common, in practice, is the ghost record problem: the hull is gone, but the MMSI keeps matching in aggregator databases because nobody told the aggregator.&lt;/p&gt;

&lt;p&gt;And then there's the misconfigured transponder. A significant number of vessels — overwhelmingly small craft running cheap Class B units, not SOLAS-grade Class A installations — are out there broadcasting &lt;code&gt;123456789&lt;/code&gt; or &lt;code&gt;000000000&lt;/code&gt; because someone shipped a unit with factory defaults and nobody changed them. This should have been solved at the hardware certification level a decade ago. It wasn't. The result is the same: two ships, sometimes on opposite sides of the planet, broadcasting the same MMSI in the same minute.&lt;/p&gt;

&lt;p&gt;The MMSI is more like a license plate than a VIN. The plate identifies the car &lt;em&gt;on this road, under this jurisdiction, right now&lt;/em&gt;. The VIN identifies the car itself. AIS gives you the plate. You have to do real work to get to the VIN.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing that actually identifies a ship
&lt;/h2&gt;

&lt;p&gt;The closest thing the maritime world has to a VIN is the &lt;strong&gt;IMO number&lt;/strong&gt; — seven digits, issued by the International Maritime Organization, and (in principle) attached to a hull for life. Scrap the ship and the IMO retires with it. Rename it, reflag it, repaint it, sell it to a shell company: the IMO stays.&lt;/p&gt;

&lt;p&gt;The analogy nearly works. The catch is the scope. Under IMO Resolution A.1078(28), IMO numbers are required for commercial cargo vessels of 300 GT and above on international voyages, passenger ships of 100 GT and above, and — progressively — fishing vessels of 100 GT and above. The exemptions matter more than the thresholds: pleasure yachts not in trade, domestic-only vessels, government vessels, and, critically, most of the global fishing fleet, which sits below the 100 GT cutoff and is therefore legitimately exempt. Among the fishing vessels that &lt;em&gt;are&lt;/em&gt; eligible, flag-state compliance is inconsistent at best — a fact anyone who has tried to identify a vessel involved in IUU fishing will recognize immediately.&lt;/p&gt;

&lt;p&gt;So a serious vessel lookup has to reason about identity using &lt;em&gt;several&lt;/em&gt; keys at once — MMSI, IMO, call sign, name — and know which one to trust when they disagree. They disagree often.&lt;/p&gt;

&lt;p&gt;My personal opinion, for what it's worth: the IMO number should be mandatory for every motorized seagoing vessel, full stop. The fact that it isn't costs the industry — and sanctions enforcement, and fisheries management, and casualty investigations — real money every year. But the politics of flag state sovereignty being what they are, we work with what we have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;former_names[]&lt;/code&gt; is an array
&lt;/h2&gt;

&lt;p&gt;When you query &lt;code&gt;/vessel/{id}&lt;/code&gt; and get back a vessel record, one of the fields is &lt;code&gt;former_names&lt;/code&gt;. We didn't add it for novelty. We added it after a customer ran a sanctions screen against a current vessel name, got a clean result, and chartered a ship that — under a previous name two owners back — had been hauling sanctioned crude out of a port nobody wants to be associated with. The hull was sanctioned. The current name was not. Same ship. Different mask. The customer was, understandably, not delighted.&lt;/p&gt;

&lt;p&gt;A bulk carrier built in 2008 might have been called &lt;em&gt;Atlantic Pioneer&lt;/em&gt; under one owner, &lt;em&gt;Star Pioneer&lt;/em&gt; under the next, &lt;em&gt;MV Helena&lt;/em&gt; after a sale to a Greek operator, and &lt;em&gt;Ocean Star III&lt;/em&gt; after a charter restructuring last spring. (Illustrative names; the pattern is real.) Same hull. Same IMO. Four names. Possibly three different MMSIs along the way, because each sale brought a reflag.&lt;/p&gt;

&lt;p&gt;If your application caches "MMSI &lt;code&gt;538001234&lt;/code&gt; → &lt;em&gt;Ocean Star III&lt;/em&gt;" and a customer asks about a port call from 2019, you'll tell them no such ship visited, when in fact &lt;em&gt;Atlantic Pioneer&lt;/em&gt; visited twice that month. The vessel didn't lie. Your model of vessel identity did.&lt;/p&gt;

&lt;p&gt;This is why a good vessel lookup API doesn't just resolve the &lt;em&gt;current&lt;/em&gt; state. It resolves the &lt;em&gt;history&lt;/em&gt; — every alias the hull has worn, with date ranges where we can establish them. The same principle drives &lt;code&gt;former_flags[]&lt;/code&gt;, &lt;code&gt;former_owners[]&lt;/code&gt;, and &lt;code&gt;former_mmsis[]&lt;/code&gt;, populated where registry data and AIS static records let us reconstruct the chain. Coverage is uneven: &lt;code&gt;former_names&lt;/code&gt; and &lt;code&gt;former_mmsis&lt;/code&gt; are usually dense; &lt;code&gt;former_owners&lt;/code&gt; is sparse for vessels flagged in states with limited public registry disclosure, and we'd rather return an empty array than a confident lie. The alternative — presenting a clean record where the data doesn't support one — is exactly how customers end up chartering the wrong ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disambiguating the transmitter from the hull
&lt;/h2&gt;

&lt;p&gt;Given all of this, "MMSI lookup" turns out to be a small phrase covering a fairly large pile of work. When an MMSI comes in, the system has to decide which hull it currently maps to, and flag uncertainty when two hulls are broadcasting the same number. Then the registries have to be reconciled: flag state databases, classification societies, port state control records, and AIS feeds all carry partial and often contradictory views of the same ship. Somewhere in the pipeline they get merged with a defensible source priority — IMO GISIS, flag state registry, AIS static, last-seen timestamp as tiebreaker — rather than a coin flip.&lt;/p&gt;

&lt;p&gt;And then there's the matter of vessels that are &lt;em&gt;actively&lt;/em&gt; lying about where they are. AIS messages are unauthenticated; there is no cryptographic integrity on the protocol and there never has been, despite twenty years of industry conversation about it. A vessel can broadcast a false MMSI, a false position, or both. The most documented case is the Black Sea, where mass GNSS interference around the Crimean peninsula has placed dozens of vessels at false locations simultaneously — the signature of deliberate infrastructure-level spoofing rather than individual misconfiguration. The Persian Gulf and Chinese coastal waters show different patterns, more consistent with individual vessels concealing dark activity than fleet-wide displacement. The Eastern Mediterranean and the Baltic have joined the list since 2022.&lt;/p&gt;

&lt;p&gt;The defense against this is unglamorous. A serious lookup flags physically impossible position transitions: a ship logged in Singapore on Tuesday cannot appear in the Black Sea on Wednesday, because the implied speed would be impossible for any displacement hull. Detection isn't a single threshold — it's a combination of implied speed (commonly set somewhere in the 30–60 knot range, depending on vessel class and operator), acceleration envelopes, and cross-checks against legitimate fast traffic like ferries and patrol craft. It's probabilistic. It catches most cases, occasionally flags a perfectly innocent fast ferry, and never gives you the satisfying yes-or-no answer you'd like.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest framing
&lt;/h2&gt;

&lt;p&gt;The reason "MMSI lookup API" is a useful search query but a slightly misleading product description is that nobody actually wants an MMSI lookup. They want to know what ship they're looking at. They want to enrich a port call record. They want to check if the vessel showing up in their charter quote is the same one that ran aground off Suez last year under a different name.&lt;/p&gt;

&lt;p&gt;The MMSI is the way in. It's the doorknob, not the room.&lt;/p&gt;

&lt;p&gt;What sits behind it — if the API is doing its job — is a model of vessel identity that knows the difference between a transmitter and a hull, between a current name and a historical one, between an authoritative source and a noisy one. That model is the actual product. The nine-digit number is just the most convenient way for you to ask the question.&lt;/p&gt;

&lt;p&gt;The ships out there, slowly tracing wakes across the North Atlantic, don't care what we call them. They wear names the way people wear coats — putting one on for a season, hanging another in the back of the closet, occasionally forgetting which one they showed up in. The MMSI is the label sewn into the collar of the coat the ship is wearing today. If you want to know who's inside, you have to look further in.&lt;/p&gt;

</description>
      <category>mmsi</category>
      <category>vesselidentity</category>
      <category>ais</category>
      <category>maritimedata</category>
    </item>
    <item>
      <title>Hexagons, Hypertables, and 240 Dead Tags: Migrating a Maritime Data Platform to TimescaleDB</title>
      <dc:creator>VesselAPI</dc:creator>
      <pubDate>Thu, 14 May 2026 23:39:44 +0000</pubDate>
      <link>https://forem.com/vessel_api/hexagons-hypertables-and-240-dead-tags-migrating-a-maritime-data-platform-to-timescaledb-m72</link>
      <guid>https://forem.com/vessel_api/hexagons-hypertables-and-240-dead-tags-migrating-a-maritime-data-platform-to-timescaledb-m72</guid>
      <description>&lt;p&gt;Every ship in the world is constantly shouting its name into the void. Its position, its heading, its speed, its destination — broadcast every few seconds via radio, picked up by satellites and shore stations, and funneled into databases that try to make sense of it all. At VesselAPI, we run one of those databases. And for the first year of our existence, we ran it on MongoDB. This is the story of why we stopped.&lt;/p&gt;

&lt;p&gt;It's also a story about hexagons and a single mismatched struct tag that quietly broke an entire data pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Shape of the Problem
&lt;/h2&gt;

&lt;p&gt;AIS — the Automatic Identification System — is the backbone of maritime surveillance. Most commercial vessels are legally required to carry a transponder that broadcasts their identity and position — SOLAS mandates it for ships of 300 gross tonnage and upwards on international voyages, and many flag states extend the requirement further. The result is a firehose: at peak hours, we ingest roughly 700,000 position reports every sixty minutes. Each one is a point in space and time — latitude, longitude, timestamp, vessel identifier, plus speed, heading, and a handful of other fields. Position-in-time is the core of it.&lt;/p&gt;

&lt;p&gt;If you squint at this data, it looks like a document. A position report has fields. You can serialize it as JSON. MongoDB will happily store it. And for the first few months, that was fine. We were building fast, the schema was changing daily, and MongoDB's flexibility was genuinely useful. Hard to have migration problems when there's nothing to migrate.&lt;/p&gt;

&lt;p&gt;But here's the thing about vessel positions: they aren't documents. They're measurements. They have a timestamp and a location, and those two properties aren't just metadata — they're the entire point. The questions you ask of this data are fundamentally about time and space: &lt;em&gt;Where was this ship two hours ago? What vessels are within 50 kilometers of Rotterdam right now? Show me everything that passed through the English Channel since Tuesday.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At the time, MongoDB had no native concept of any of this. (It has since added time-series collections, though they remain limited compared to purpose-built solutions.) It didn't understand that timestamps partition naturally into chunks, that old data expires, or that latitude and longitude define a point on a sphere where "within 50 kilometers" is a question with real mathematical structure. You can bolt on 2dsphere indexes and TTL policies, but you're fighting the grain of the database. And at 700,000 rows per hour, fighting the grain gets expensive fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Needed (and What Exists)
&lt;/h2&gt;

&lt;p&gt;I wrote the requirements on a whiteboard one afternoon and stood back. Time-series ingestion at sustained throughput. Automatic partitioning by time. Compression of old data. Retention policies that don't involve cron. Spatial queries on a sphere. Full-text search. Relational joins. And ideally, something I could operate without a dedicated DBA. Looking at the list, I remember thinking: this is either one very specific database, or three separate ones duct-taped together.&lt;/p&gt;

&lt;p&gt;I spent a week evaluating alternatives. InfluxDB handles time-series beautifully but its spatial support was experimental, living in Flux — which is now being deprecated in InfluxDB 3.0, taking the geo package with it. ClickHouse kept coming up in benchmarks but the operational overhead scared me, and PostGIS isn't an option there. MongoDB we already knew about.&lt;/p&gt;

&lt;p&gt;TimescaleDB is PostgreSQL with a time-series engine bolted on at a level deep enough that it feels native. And because it &lt;em&gt;is&lt;/em&gt; PostgreSQL, you get PostGIS for spatial queries, H3 for hexagonal indexing, GIN indexes for full-text search, and nearly thirty years of battle-tested relational database engineering. Turns out we didn't need three databases duct-taped together. We needed one.&lt;/p&gt;

&lt;p&gt;We chose it. Then we had to figure out what "time-series thinking" actually means in practice.&lt;/p&gt;

&lt;p&gt;Sounds interesting? Check out &lt;strong&gt;TimescaleDB&lt;/strong&gt; →&lt;/p&gt;

&lt;h2&gt;
  
  
  Data With a Shelf Life
&lt;/h2&gt;

&lt;p&gt;The central abstraction in TimescaleDB is the &lt;strong&gt;hypertable&lt;/strong&gt;. From the outside, it looks like a regular PostgreSQL table. You INSERT into it, you SELECT from it, you index it. But underneath, the data is automatically partitioned into &lt;strong&gt;chunks&lt;/strong&gt; — contiguous slices of time, each stored as a separate physical table.&lt;/p&gt;

&lt;p&gt;I didn't appreciate how much this changes until I stopped thinking about storage and started thinking about expiry.&lt;/p&gt;

&lt;p&gt;Our vessel_positions hypertable uses 1-hour chunks. That means every hour of AIS data lives in its own self-contained partition. When we set a 78-hour retention policy, TimescaleDB doesn't scan through millions of rows looking for old records to delete — it just drops the chunks that have aged out. The entire partition disappears. It takes milliseconds.&lt;/p&gt;

&lt;p&gt;16.5 million vessel positions, 78-hour retention, 1-hour chunks. The table is always roughly the same size, no matter how long the system runs.&lt;/p&gt;

&lt;p&gt;Compression works the same way. After a chunk is two hours old — meaning we're no longer actively writing to it — TimescaleDB compresses it automatically. We segment the compression by MMSI (the vessel's radio identifier, broadcast in every AIS message) and order by timestamp descending. This means "give me the latest position for vessel X" can be answered from the compressed data without decompressing the entire chunk. The storage savings are substantial; the performance improvement for time-range queries is even better, because the query planner knows which chunks to skip entirely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;vessel_positions&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;timescaledb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timescaledb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compress_segmentby&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'mmsi'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timescaledb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compress_orderby&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'timestamp DESC'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;add_compression_policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'vessel_positions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'2 hours'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In MongoDB, I had a cron job that ran a cleanup script every few hours. It failed silently for a week once and nobody noticed until disk usage alerted. In TimescaleDB, we just declare the retention policy and the database handles expiry itself. One fewer thing running at 3 AM that I have to worry about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hexagon Problem
&lt;/h2&gt;

&lt;p&gt;Here's a question that sounds simple: &lt;em&gt;find all vessels within 100 kilometers of a given point.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;PostGIS can answer this. You create a GIST spatial index on your geometry column, and the function &lt;code&gt;ST_DWithin&lt;/code&gt; will find every point within a given distance. Under the hood, it uses the spatial index to eliminate obvious non-candidates via bounding box checks, then computes exact distances for the rest. It works. It's well-engineered.&lt;/p&gt;

&lt;p&gt;But when your table has 16 million rows and new ones arrive at 12,000 per minute, "it works" isn't quite enough. The GIST index is good, but it still has to traverse a tree structure built on geometry — bounding boxes nested inside bounding boxes. For high-volume tables with constant inserts, this gets heavy.&lt;/p&gt;

&lt;p&gt;So we added a layer in front of it. And that layer is made of hexagons.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://h3geo.org/" rel="noopener noreferrer"&gt;H3&lt;/a&gt; is a spatial indexing system originally developed at Uber for matching riders to drivers. It tiles the entire surface of the Earth with hexagons at multiple resolutions — coarser at low resolutions, finer at high ones. Every point on the planet falls inside exactly one hexagon at each resolution level, and each hexagon has a unique integer identifier.&lt;/p&gt;

&lt;p&gt;We use resolution 5, where each hexagon has an edge length of roughly 9 kilometers and an area of roughly 253 square kilometers. The entire Earth is covered by about 2 million of these cells. Every vessel position, when it's inserted, gets a computed H3 cell stored alongside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;h3_cell_res5&lt;/span&gt; &lt;span class="n"&gt;H3INDEX&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;h3_lat_lng_to_cell&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;location&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;point&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;STORED&lt;/code&gt; keyword matters. The H3 cell is computed once, at insert time, and written to disk as an integer. No recalculation needed at query time. And because it's an integer, we can slap a plain B-tree index on it — the simplest, fastest index PostgreSQL knows how to build.&lt;/p&gt;

&lt;p&gt;Now, when someone asks for vessels within 100 kilometers of a point, the query doesn't go straight to the spatial index. First, we compute which H3 cells overlap the search area — a quick geometric calculation that returns a handful of integer IDs. Then we filter the table to only rows matching those cell IDs, using the B-tree index. Integer equality. Blazing fast. This turns 16 million candidate rows into a few thousand.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Then&lt;/em&gt; PostGIS takes over, running &lt;code&gt;ST_DWithin&lt;/code&gt; on the survivors for exact distance calculations. A few thousand rows through a precise spatial filter is trivial.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Stage 1: H3 pre-filter (integer comparison, B-tree)&lt;/span&gt;
&lt;span class="n"&gt;h3_cell_res5&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;h3_grid_disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h3_lat_lng_to_cell&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ST_MakePoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;point&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;-- Stage 2: PostGIS exact filter (geometry, GIST)&lt;/span&gt;
&lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ST_DWithin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;location&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ST_SetSRID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ST_MakePoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;4326&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;geography&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;-- Stage 3: TimescaleDB chunk pruning (time range)&lt;/span&gt;
&lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three layers of filtering, each narrowing the candidate set for the next: H3 knocks it down from millions to thousands, PostGIS from thousands to hundreds, and chunk exclusion keeps you from scanning data outside the time window entirely.&lt;/p&gt;

&lt;p&gt;Why hexagons, specifically? Because hexagons are the only regular polygon that tiles a plane with uniform adjacency — every neighbor shares an edge, and the distance from center to center is the same in every direction. Squares have diagonal neighbors that sit further away than edge neighbors, which distorts distance calculations. For spatial proximity queries, hexagons give you the least distortion. Uber didn't invent this insight — anyone who's looked at a honeycomb has seen it — but they did build a production-grade library around it.&lt;/p&gt;

&lt;p&gt;We tried resolution 4 first. The cells were too big — a single cell covered so much ocean that the pre-filter wasn't filtering much. Resolution 6 was better spatially but generated too many cells per query, and the B-tree had to check them all. Resolution 5 was the one where a 100-kilometer radius query overlapped a manageable number of cells while still meaningfully shrinking the candidate set. We benchmarked it and moved on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feeding the Beast
&lt;/h2&gt;

&lt;p&gt;700,000 position reports per hour means roughly 194 inserts per second, sustained. That's not a terrifying number for PostgreSQL — a well-tuned instance can handle far more — but the naive approach still hurts. Individual INSERT statements, each sent as a separate network round trip, spend more time in protocol overhead than actual writing. The database is fast; the network between your application and the database is not.&lt;/p&gt;

&lt;p&gt;The obvious answer is PostgreSQL's COPY protocol, which streams raw row data in binary, bypassing the SQL parser entirely. We use it for &lt;code&gt;vessel_eta&lt;/code&gt; and &lt;code&gt;cache_ais_messages&lt;/code&gt;. But for &lt;code&gt;vessel_positions&lt;/code&gt;, we can't. The reason is our own schema: the &lt;code&gt;h3_cell_res5&lt;/code&gt; generated column uses the H3INDEX type, and H3INDEX doesn't implement PostgreSQL's binary I/O functions. The COPY protocol requires binary serialization for every column type in the target table. No binary I/O, no COPY.&lt;/p&gt;

&lt;p&gt;So we use &lt;code&gt;pgx.Batch&lt;/code&gt; with &lt;code&gt;SendBatch&lt;/code&gt; instead — the extended query protocol. It packs hundreds of parameterized INSERT statements into a single network round trip, and PostgreSQL executes them server-side without per-statement overhead. Not as fast as COPY, but an order of magnitude better than individual round trips:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;pgx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Batch&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;positions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;`INSERT INTO vessel_positions
         (mmsi, imo, vessel_name, latitude, longitude, location,
          timestamp, processed_timestamp, suspected_glitch, ...)
         VALUES ($1, $2, $3, $4, $5,
                 ST_SetSRID(ST_MakePoint($5, $4), 4326),
                 $6, $7, $8, ...)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MMSI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;imo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VesselName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Latitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Longitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProcessedTimestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SuspectedGlitch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c"&gt;/* ... */&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SendBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that the geometry is computed server-side with &lt;code&gt;ST_SetSRID(ST_MakePoint(...))&lt;/code&gt;. I briefly considered pre-computing EWKB in the application to avoid calling a function 700,000 times per hour. But since we were already on SendBatch rather than COPY, and &lt;code&gt;ST_MakePoint&lt;/code&gt; is cheap server-side, the optimization wasn't worth the added complexity. Sometimes the schema you designed to make reads fast makes writes slightly harder. I'd make that trade-off again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Character That Broke the Ports
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffsvidk6ncqh9tyx8w474.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffsvidk6ncqh9tyx8w474.png" alt="A port at night with container cranes lit against the dark sky, overlaid with a code error showing mismatched struct field names" width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When your database speaks JSON but your structs still think in BSON.&lt;/p&gt;

&lt;p&gt;Here is a bug that could only exist in a migration.&lt;/p&gt;

&lt;p&gt;Our port data comes from two external sources. One is a scraper that crawls MyShipTracking's sitemap and extracts basic port info — name, country, coordinates, UN/LOCODE. The other is the World Port Index, maintained by the US National Geospatial-Intelligence Agency, which provides detailed harbor characteristics: depths, pilotage requirements, tug availability, dozens of operational fields.&lt;/p&gt;

&lt;p&gt;Neither source writes directly to the production &lt;code&gt;ports&lt;/code&gt; table. Instead, they dump raw JSON documents into staging tables — &lt;code&gt;cache_port_mst&lt;/code&gt; and &lt;code&gt;cache_port_wpi&lt;/code&gt; — and a consolidation step merges them. MST provides breadth (6,488 ports), WPI provides depth (~3,700 ports with rich metadata). The consolidator joins them on UN/LOCODE and writes the merged result to production. Clean separation of concerns.&lt;/p&gt;

&lt;p&gt;After the migration went live, the MST scraper worked perfectly. 6,488 ports in the staging table. But the WPI staging table was empty. Zero rows. The consolidator, finding nothing to merge, produced nothing. The production &lt;code&gt;ports&lt;/code&gt; table: empty. And because port events rely on the ports table for UN/LOCODE enrichment, 156,000 port events were created with no geographic identifier. Everything looked healthy from the outside. The data was garbage.&lt;/p&gt;

&lt;p&gt;The root cause was one word.&lt;/p&gt;

&lt;p&gt;The WPI port struct, a holdover from the MongoDB era, still carried dual serialization tags — something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;WpiPort&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;UnloCode&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`bson:"unlo_code" json:"unloCode"`&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The staging table upsert function works by marshaling each struct to JSON, then extracting a key field by name. The key parameter was &lt;code&gt;"unlo_code"&lt;/code&gt; — the BSON tag name, used by MongoDB's driver. But &lt;code&gt;json.Marshal&lt;/code&gt; uses the JSON tag: &lt;code&gt;"unloCode"&lt;/code&gt;. The function looked for a field called &lt;code&gt;unlo_code&lt;/code&gt; in the JSON document, found nothing, and returned an error. Not a silent error, technically — the function threw a clear &lt;code&gt;"key field not found"&lt;/code&gt; message. But without alerting on the nightly WPI sync job, a returned error that nobody checks is as good as silent. It ran, it failed, it failed again, every night at 1 AM, for days.&lt;/p&gt;

&lt;p&gt;The fix was changing one string: &lt;code&gt;"unlo_code"&lt;/code&gt; to &lt;code&gt;"unloCode"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But the real fix was broader. I searched the codebase and found &lt;strong&gt;nearly 240 leftover &lt;code&gt;bson:"..."&lt;/code&gt; tags&lt;/strong&gt; scattered across the data contract structs. The MongoDB driver wasn't even imported anymore — these tags were pure vestigial code, left over from the old world. Every one of them was a potential version of the same bug: a name from a system that no longer existed, waiting to be confused with a name from the system that did.&lt;/p&gt;

&lt;p&gt;I spent two days on this. Two days staring at logs, convinced the WPI API had changed its response format, before I thought to check the struct tags. It's a naming collision between two eras of the same system, and Go is happy to let it happen — struct tags are opaque strings the compiler ignores completely. No linter will save you. You have to notice it yourself, or wait for production to notice it for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Numbers Look Like
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;vessel_positions&lt;/td&gt;
&lt;td&gt;Hypertable&lt;/td&gt;
&lt;td&gt;16.5M&lt;/td&gt;
&lt;td&gt;~700K/hour, H3 + GIST + B-tree indexes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vessel_eta&lt;/td&gt;
&lt;td&gt;Hypertable&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;78h retention, compressed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;port_events&lt;/td&gt;
&lt;td&gt;Hypertable&lt;/td&gt;
&lt;td&gt;156K&lt;/td&gt;
&lt;td&gt;Dedup index, UN/LOCODEs empty (pre-fix)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cache_ais_messages&lt;/td&gt;
&lt;td&gt;Hypertable&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Raw AIS buffer, 12h retention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vessels&lt;/td&gt;
&lt;td&gt;Regular&lt;/td&gt;
&lt;td&gt;~50K&lt;/td&gt;
&lt;td&gt;Consolidated from multiple sources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ports&lt;/td&gt;
&lt;td&gt;Regular&lt;/td&gt;
&lt;td&gt;~6,500&lt;/td&gt;
&lt;td&gt;After WPI fix; 0 before&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;light_aids&lt;/td&gt;
&lt;td&gt;Regular&lt;/td&gt;
&lt;td&gt;35,237&lt;/td&gt;
&lt;td&gt;Navigation infrastructure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dgps_stations&lt;/td&gt;
&lt;td&gt;Regular&lt;/td&gt;
&lt;td&gt;163&lt;/td&gt;
&lt;td&gt;DGPS reference stations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;navtex_messages&lt;/td&gt;
&lt;td&gt;Regular&lt;/td&gt;
&lt;td&gt;~93/day&lt;/td&gt;
&lt;td&gt;Deduplicated by content hash&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Production table statistics after migration&lt;/p&gt;

&lt;p&gt;All of this runs on a single EC2 &lt;code&gt;r7i.large&lt;/code&gt; — 2 vCPUs, 16 GB of RAM. I keep expecting to need to upgrade and I keep not needing to. PostgreSQL with the right extensions, doing the work that previously required MongoDB plus a constellation of application-level workarounds for everything MongoDB couldn't do natively.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4h9ti989yhdfe8obqt8q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4h9ti989yhdfe8obqt8q.png" alt="A bulk carrier navigating a Norwegian fjord at dawn, with a faint AIS data trail arcing behind it" width="800" height="241"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A bulk carrier transiting a Norwegian fjord — one of roughly 700,000 position reports we process every hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Tell You at a Bar
&lt;/h2&gt;

&lt;p&gt;Look, MongoDB was the right call when we started. I'd choose it again for that stage. The mistake was staying six months too long — past the point where the data had obviously hardened into a shape and we were just too busy to deal with it.&lt;/p&gt;

&lt;p&gt;Honestly, moving the data was the easy part. The hard part — the part that's still ongoing — is finding all the places where the old system's assumptions are embedded in the code. A struct tag referencing a serialization format you don't use anymore. A key parameter someone copied from a different struct's bson tag. Those things survive the migration and sit there until they don't.&lt;/p&gt;

&lt;p&gt;We're still finding &lt;code&gt;bson&lt;/code&gt; tags. Probably will be for a while.&lt;/p&gt;

</description>
      <category>database</category>
      <category>timescaledb</category>
      <category>postgres</category>
      <category>maritime</category>
    </item>
    <item>
      <title>The 946-Millisecond Tax: Migrating API Key Auth from Bcrypt to HMAC-SHA256</title>
      <dc:creator>VesselAPI</dc:creator>
      <pubDate>Sun, 10 May 2026 11:56:03 +0000</pubDate>
      <link>https://forem.com/vessel_api/the-946-millisecond-tax-migrating-api-key-auth-from-bcrypt-to-hmac-sha256-4c74</link>
      <guid>https://forem.com/vessel_api/the-946-millisecond-tax-migrating-api-key-auth-from-bcrypt-to-hmac-sha256-4c74</guid>
      <description>&lt;p&gt;I never profiled our authentication middleware. Why would I? It's a key check. The request comes in, you verify the key, you move on. It's plumbing. Then one afternoon I stuck a timer on it and watched it print 946 milliseconds. I re-ran it. Same. Every authenticated request to our API was spending nearly a full second deciding whether the caller was allowed in, before it did a single useful thing.&lt;/p&gt;

&lt;p&gt;We were hashing API keys with bcrypt. It felt like the right thing to do. It wasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 100-Millisecond-Per-Key Tax
&lt;/h2&gt;

&lt;p&gt;When VesselAPI's authentication was first built, someone — by someone I mean me, it was me — did the reasonable thing. User creates an API key, we hash it with bcrypt at cost factor 10 and store the hash. On each incoming request, extract the Bearer token, load the stored hashes from PostgreSQL, and run &lt;code&gt;bcrypt.CompareHashAndPassword&lt;/code&gt; against each one until a match is found or the list runs out.&lt;/p&gt;

&lt;p&gt;Bcrypt is a password hashing function. It was engineered, on purpose, to be slow. That deliberate slowness is its entire value proposition for passwords: if an attacker steals your database full of password hashes, the CPU cost of bcrypt makes brute-force recovery so expensive it's impractical. At cost factor 10, each comparison takes roughly 65–100 milliseconds depending on hardware. For a user logging into a web app once a day, that's invisible. For every single API call, it's a wall.&lt;/p&gt;

&lt;p&gt;Here's what compounded it: bcrypt hashes aren't indexable. The function incorporates a random salt, so the same input produces a different hash each time. You can't compute the hash of an incoming key and look it up in the database — you have to load all the stored hashes and compare them one by one. With 11 active keys in production at the time of our migration, the worst case was 11 bcrypt comparisons. Just under 1.1 seconds of pure CPU work before we even said hello to the request.&lt;/p&gt;

&lt;p&gt;11 active keys × ~100ms bcrypt = up to 1.1 seconds of authentication.&lt;/p&gt;

&lt;p&gt;The health endpoint with no auth ran in 581ms. Authenticated endpoints ran in 1,466ms. We were paying nearly a second for permission to serve the response.&lt;/p&gt;

&lt;p&gt;At the traffic volumes we had, this wasn't causing visible degradation. The load test user was doing 94,315 requests in a month but spread across time. The problem wasn't that the system was falling over — it was that it was architecturally incapable of scaling. With 11 keys to check, bcrypt at cost factor 10 limits you to under one authenticated request per second per core. That's not a "tune this later" ceiling. It's a physics ceiling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bcrypt Is the Wrong Tool for API Keys
&lt;/h2&gt;

&lt;p&gt;We grabbed bcrypt because that's what you use for credentials. We never stopped to ask whether API keys are credentials in the same sense passwords are.&lt;/p&gt;

&lt;p&gt;Bcrypt's whole thing is being slow on purpose. If someone steals your password database, the cost of each hash attempt is what stands between them and everyone's plaintext passwords. That makes sense for passwords — people pick terrible ones, reuse them, and there are massive precomputed tables for cracking common choices.&lt;/p&gt;

&lt;p&gt;API keys are not that. Ours are 256 bits of cryptographically random noise. You'd need something like 2&lt;sup&gt;255&lt;/sup&gt; guesses to brute-force one. At a billion attempts per second, that's — let me check — longer than the Sun will exist. So what was bcrypt's cost factor actually buying us? Nothing. The keys were already uncrackable. We were paying 100 milliseconds per request for protection against a threat that doesn't apply.&lt;/p&gt;

&lt;p&gt;The real risk with API keys is leakage: someone commits one to a public repo, or it shows up in a log file, or a compromised client exfiltrates it. Bcrypt doesn't help with any of those. What helps is a fast, deterministic hash — something you can index — plus a server-side secret protecting the stored hashes.&lt;/p&gt;

&lt;p&gt;So we use a pepper — a server-side secret. We compute HMAC-SHA256 over the API key using a 64-character random secret stored in AWS Secrets Manager. The stored value is the HMAC output, not the key. An attacker with the database still needs the pepper from Secrets Manager to do anything with those hashes — a completely separate security boundary. And HMAC-SHA256 takes about a microsecond to compute, not a hundred milliseconds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;Before&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bcrypt&lt;/span&gt; &lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ow"&gt;and&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;compare&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nf"&gt;all &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;O&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="n"&gt;ms&lt;/span&gt; &lt;span class="n"&gt;per&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stored&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt; &lt;span class="n"&gt;allKeyHashes&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bcrypt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CompareHashAndPassword&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="nf"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="nf"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;After&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HMAC&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;indexed&lt;/span&gt; &lt;span class="nf"&gt;lookup &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;O&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;μs&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;one&lt;/span&gt; &lt;span class="n"&gt;DB&lt;/span&gt; &lt;span class="nb"&gt;round&lt;/span&gt; &lt;span class="n"&gt;trip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;mac&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="nf"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pepper&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;mac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="nf"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;keyHMAC&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;EncodeToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;VerifyApiKeyHMAC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyHMAC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;indexed&lt;/span&gt; &lt;span class="n"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replacing bcrypt with HMAC alone got auth overhead from ~946ms to ~811ms. Better, but not the kind of better you write a blog post about. Most of that remaining time was network round trips to PostgreSQL — the hash was fast, the wire wasn't. So we kept going.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnyrbkrqkapvose1509e3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnyrbkrqkapvose1509e3.png" alt="Split view of two server racks — left side cluttered with tangled cables and amber warning lights, right side clean and minimal with green status LEDs" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero Database Queries on the Hot Path
&lt;/h2&gt;

&lt;p&gt;I'll spare you the detour where I briefly considered Redis before realizing I was optimizing a 17-key lookup and needed to calm down. The cache design we landed on is simple. Two &lt;code&gt;sync.Map&lt;/code&gt; instances live in the auth service: &lt;code&gt;keyCache&lt;/code&gt; maps HMAC hashes to key metadata, and &lt;code&gt;userCache&lt;/code&gt; maps user IDs to subscription and quota information. When both are populated, an authenticated request never touches the database.&lt;/p&gt;
&lt;h4&gt;
  
  
  Before: Every Request
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Extract Bearer token&lt;/li&gt;
&lt;li&gt;DB: load all key hashes&lt;/li&gt;
&lt;li&gt;bcrypt.CompareHashAndPassword (each key, ~100ms)&lt;/li&gt;
&lt;li&gt;DB: check per-key quota&lt;/li&gt;
&lt;li&gt;DB: increment per-key usage counter&lt;/li&gt;
&lt;li&gt;3–4 round trips, up to 1.1s of CPU&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;
  
  
  After: Steady State (cache warm)
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Extract Bearer token&lt;/li&gt;
&lt;li&gt;HMAC-SHA256 with pepper (~1µs)&lt;/li&gt;
&lt;li&gt;sync.Map lookup: keyCache (tens to hundreds of ns)&lt;/li&gt;
&lt;li&gt;atomic.AddInt64: local counter (~25ns)&lt;/li&gt;
&lt;li&gt;Memory check: usage &amp;amp;lt; limit? (nanoseconds)&lt;/li&gt;
&lt;li&gt;Zero database queries&lt;/li&gt;
&lt;/ul&gt;



&lt;p&gt;The usage tracking is worth a closer look, because it's where the real database write elimination happens. In the old system, every authenticated request incremented a per-key counter in PostgreSQL. That's a write on the hot path, with all the associated row contention and WAL traffic. Now, we increment a local &lt;code&gt;int64&lt;/code&gt; via &lt;code&gt;atomic.AddInt64&lt;/code&gt; and a background goroutine wakes up every 30 seconds, atomically swaps all the counters to zero, and flushes the totals to the database in a single batch call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;Simplified&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;real&lt;/span&gt; &lt;span class="n"&gt;flush&lt;/span&gt; &lt;span class="n"&gt;iterates&lt;/span&gt; &lt;span class="n"&gt;userCache&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;where&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt;
&lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;lives&lt;/span&gt; &lt;span class="n"&gt;alongside&lt;/span&gt; &lt;span class="n"&gt;quota&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;each&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;span class="nf"&gt;func &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cachedAuthRepository&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;userCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="nb"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;userEntry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;atomic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SwapInt64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;localUsageCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;accumulate&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="n"&gt;DB&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;single&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt; &lt;span class="nb"&gt;all&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt; &lt;span class="n"&gt;counts&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Usage numbers can be up to 30 seconds stale, which is fine for monthly quota enforcement but worth understanding. We added one safeguard: when the in-memory counter says a user has exceeded their quota, we do a fresh database lookup before returning 429. This handles plan upgrades — if someone bumps from Starter to Growth mid-session, the cache still thinks their limit is 2,500 until the next DB refresh, so they'd incorrectly hit a wall. The fallback catches it. It adds one DB round trip at the moment that matters most, which is the right trade-off.&lt;/p&gt;

&lt;p&gt;We also fixed something embarrassing while we were in there. The old quota model tracked usage per-key, not per-user. Each key had its own counter and its own limit. Which meant that a user who created three keys got three times the quota. Nobody exploited this, as far as I know, but only because nobody thought to try. We were one curious developer away from an unlimited plan at the price of a free tier and some creativity. Seventeen keys across seven users — early days — so nobody noticed. Per-user counters now. Should have been from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lazy Migration
&lt;/h2&gt;

&lt;p&gt;We had 17 API keys in production on migration day. A one-off script to backfill HMAC hashes for all of them would have taken about ten minutes to write and thirty seconds to run. We did the lazy migration instead, and I think it was worth the extra complexity.&lt;/p&gt;

&lt;p&gt;Why not just run a backfill script? We could have. It would have taken thirty seconds. But then the bcrypt fallback path — which we wrote, and which we'd need if anything went wrong — would never get exercised in production until the one time it actually matters. With lazy migration, every old key that gets used proves the fallback works. It's testing in prod, but the honest kind.&lt;/p&gt;

&lt;p&gt;The flow goes like this. Compute the HMAC of the incoming key. Check the cache — miss. Check the database for a matching &lt;code&gt;key_hmac&lt;/code&gt; value — miss, because old keys don't have one yet. Fall through to bcrypt, verify against the old hash, succeed. Now kick off an async HMAC backfill, populate the cache, serve the request. Next time that key shows up, it hits the HMAC path directly and never touches bcrypt again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;Simplified&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="n"&gt;real&lt;/span&gt; &lt;span class="n"&gt;signatures&lt;/span&gt; &lt;span class="n"&gt;differ&lt;/span&gt; &lt;span class="n"&gt;slightly&lt;/span&gt;
&lt;span class="n"&gt;hmacHex&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computeHMAC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pepper&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mf"&gt;1.&lt;/span&gt; &lt;span class="nc"&gt;Cache &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;warm&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;keyCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hmacHex&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;keyEntry&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mf"&gt;2.&lt;/span&gt; &lt;span class="n"&gt;HMAC&lt;/span&gt; &lt;span class="n"&gt;indexed&lt;/span&gt; &lt;span class="nf"&gt;lookup &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;migrated&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;VerifyApiKeyHMAC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hmacHex&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;keyCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hmacHex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mf"&gt;3.&lt;/span&gt; &lt;span class="n"&gt;Bcrypt&lt;/span&gt; &lt;span class="nf"&gt;fallback &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;un&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;migrated&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;once&lt;/span&gt; &lt;span class="n"&gt;per&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;keyID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;VerifyApiKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrUnauthorized&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;loadKeyEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;go&lt;/span&gt; &lt;span class="nf"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BackfillHMAC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hmacHex&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hmac backfill failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keyID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}()&lt;/span&gt;
&lt;span class="n"&gt;keyCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hmacHex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nil&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New keys created through the API key manager get their HMAC computed at creation time and never touch the bcrypt path at all. The lazy path is there for the legacy keys, and once they've each been used once, it's never exercised again.&lt;/p&gt;

&lt;p&gt;Need vessel data? Check out &lt;strong&gt;VesselAPI&lt;/strong&gt; →&lt;/p&gt;

&lt;h2&gt;
  
  
  The Rollout
&lt;/h2&gt;

&lt;p&gt;It took three deployment attempts on February 7th. Wrong Secrets Manager config format, then a fix baked into an AMI that hadn't propagated yet, then a missing IAM permission in a different repo. Standard infrastructure bingo. On the third try the server started, we hit &lt;code&gt;/v1/health&lt;/code&gt; with a Bearer token, got 200 OK, and briefly celebrated — before realizing the health endpoint doesn't use auth. We'd validated our new authentication system against the one endpoint that skips it. Hit &lt;code&gt;/v1/vessel/{id}&lt;/code&gt; instead, watched the bcrypt fallback fire, watched the HMAC backfill, hit it again — sub-10ms. That one counted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Numbers Actually Look Like
&lt;/h2&gt;

&lt;p&gt;Production measurements from February 7th, network overhead stripped out:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;DB Queries&lt;/th&gt;
&lt;th&gt;Auth Overhead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bcrypt fallback, cold cache&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;~946ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HMAC lookup, cold cache&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;~811ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache hit&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&amp;lt;10ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Under 10 milliseconds with a warm cache. Zero database queries. The auth overhead went from being the dominant cost of every request to being lost in the measurement noise.&lt;/p&gt;

&lt;p&gt;Throughput ceiling with bcrypt and 11 keys: under 1 authenticated request per second per core. With HMAC and a cache, authentication is no longer the bottleneck for anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trade-offs Worth Naming
&lt;/h2&gt;

&lt;p&gt;What changed beyond "it got faster":&lt;/p&gt;

&lt;p&gt;The security model is different. With bcrypt, even if you have the database, you can't reverse the hashes. With HMAC, if you get both the database &lt;em&gt;and&lt;/em&gt; the pepper, you can verify candidate keys. Our keys have enough entropy that this doesn't matter in practice, but it's a weaker property and I'd rather be upfront about it.&lt;/p&gt;

&lt;p&gt;Usage numbers can be stale by up to 30 seconds. A user could overshoot their quota slightly before the flush catches it. At monthly limits measured in thousands, I genuinely don't care. If we ever need per-second rate limiting, we'd use Redis, not this.&lt;/p&gt;

&lt;p&gt;There's also a concurrency wrinkle I should own up to: the usage counter itself is atomic, but the &lt;code&gt;dbUsageCount&lt;/code&gt; and &lt;code&gt;monthlyLimit&lt;/code&gt; fields on the same struct are plain &lt;code&gt;int&lt;/code&gt; values read and written without synchronization. Multiple goroutines can read stale values, and worse, the quota-refresh path writes those fields directly on the shared struct without a lock — two concurrent refreshes could clobber each other. I think this is fine at our scale. I'm not entirely sure it's fine. It probably needs a mutex, and I'll probably add one the next time I'm in that file and feeling responsible.&lt;/p&gt;

&lt;p&gt;The cache entries do have a TTL — both key and user entries expire after a configured duration, and the lookup re-fetches from the database when they go stale. But there's no active invalidation push. If you revoke a key, it stays valid in memory until that TTL window elapses. At current scale — 17 keys, 7 active users — this is the trade-off that actually kept me up at night, more than the others. We'll add a revocation broadcast before we onboard any enterprise customers. For now, the TTL window is short enough that I can live with it.&lt;/p&gt;

&lt;p&gt;Two weeks after migration day, all 17 pre-existing keys have been lazily migrated. New keys get HMAC on creation. The bcrypt fallback path exists in the code and will presumably never run again, but I'm not ready to remove it yet. It's not hurting anyone, and the fallback existing is a different kind of insurance than the fallback being needed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1vafser7sw6qum6s5rvi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1vafser7sw6qum6s5rvi.png" alt="An old brass padlock sitting open on a weathered wooden desk, cobwebs on the shackle, a laptop with terminal code blurred in the background" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The performance improvement worked like the math said it would. What I didn't expect was how many other things the migration shook loose — the per-key quota bug, the stale schema assumptions in other services, the concurrency shortcuts we'd been getting away with. You open up one table and suddenly you're looking at every decision you made in the first six months and wondering which ones are still load-bearing. Some of them weren't. Some of them still are.&lt;/p&gt;

</description>
      <category>authentication</category>
      <category>performance</category>
      <category>api</category>
      <category>hmac</category>
    </item>
    <item>
      <title>Self-Hosted Vessel Email Alerts with AWS Lambda and SES</title>
      <dc:creator>VesselAPI</dc:creator>
      <pubDate>Wed, 06 May 2026 12:29:45 +0000</pubDate>
      <link>https://forem.com/vessel_api/self-hosted-vessel-email-alerts-with-aws-lambda-and-ses-dh9</link>
      <guid>https://forem.com/vessel_api/self-hosted-vessel-email-alerts-with-aws-lambda-and-ses-dh9</guid>
      <description>&lt;p&gt;The thing you actually want is an email.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;Ever Given&lt;/em&gt; just entered Suez, or that one of your chartered tankers' ETA has shifted by six hours.&lt;/p&gt;

&lt;p&gt;VesselAPI sends notifications as webhooks and WebSocket messages. That's the right contract for software, the wrong contract for humans. Webhooks are how systems talk. Email is how systems talk to people. The translation layer between them — the thing that turns a webhook stream into vessel port arrival departure email notifications, the sort of thing your inbox already knows how to thread, sort, and search — is small enough to read in one sitting, which is what this post is.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;a href="https://vesselapi.com/blog/email-alerts-aws-lambda-ses#arch" rel="noopener noreferrer"&gt;The Architecture in One Diagram&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;a href="https://vesselapi.com/blog/email-alerts-aws-lambda-ses#step-1" rel="noopener noreferrer"&gt;Step 1: Verify an Email Address in SES&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;a href="https://vesselapi.com/blog/email-alerts-aws-lambda-ses#step-2" rel="noopener noreferrer"&gt;Step 2: Scaffold the Project&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;a href="https://vesselapi.com/blog/email-alerts-aws-lambda-ses#non-obvious" rel="noopener noreferrer"&gt;The Two Non-Obvious Parts&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;a href="https://vesselapi.com/blog/email-alerts-aws-lambda-ses#step-3" rel="noopener noreferrer"&gt;Step 3: Deploy with SAM&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;a href="https://vesselapi.com/blog/email-alerts-aws-lambda-ses#step-4" rel="noopener noreferrer"&gt;Step 4: Register the Webhook&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;a href="https://vesselapi.com/blog/email-alerts-aws-lambda-ses#step-5" rel="noopener noreferrer"&gt;Step 5: Send a Test Event&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;a href="https://vesselapi.com/blog/email-alerts-aws-lambda-ses#troubleshoot" rel="noopener noreferrer"&gt;When It Doesn't Work the First Time&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; &lt;a href="https://vesselapi.com/blog/email-alerts-aws-lambda-ses#cost" rel="noopener noreferrer"&gt;What This Costs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://vesselapi.com/blog/email-alerts-aws-lambda-ses#production" rel="noopener noreferrer"&gt;Going to Production&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://vesselapi.com/blog/email-alerts-aws-lambda-ses#next" rel="noopener noreferrer"&gt;What Comes Next&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You'll want these installed before starting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS CLI&lt;/strong&gt; — for SES identity verification and CloudWatch log reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS SAM CLI&lt;/strong&gt; — builds and deploys the stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python 3.12&lt;/strong&gt; — matches the Lambda runtime; needed if you want to run the example repo's test suite locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS credentials&lt;/strong&gt; configured for your target region (&lt;code&gt;aws configure&lt;/code&gt; or env vars).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A VesselAPI key&lt;/strong&gt; on a plan that includes notifications (see the pricing CTA below).&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🤖 Setup with Claude Code&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Or paste this into a Claude Code session and let it sort the local toolchain out:&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;check whether the AWS CLI, AWS SAM CLI, and Python 3.12 are installed, and that my AWS credentials are configured for us-east-1 — install or fix anything that's missing
&lt;/code&gt;&lt;/pre&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://github.com/vessel-api/vessel-email-alerts-aws-sam" rel="noopener noreferrer"&gt;Full source for this post: &lt;strong&gt;vessel-api/vessel-email-alerts-aws-sam&lt;/strong&gt; on GitHub →&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture in One Diagram
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vesselapi notification
   |  POST  (HMAC-signed JSON)
   v
API Gateway (HTTP API)
   v
Lambda
   |-- verify HMAC signature
   |-- DynamoDB: have we seen this delivery_id before?
   |-- render email (per event type)
   `-- SES: SendEmail
   v
Your inbox
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API Gateway HTTP API&lt;/strong&gt; is the public URL VesselAPI POSTs to. The HTTP API flavor (not REST API) is roughly a third of the price and supports everything we need for one route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lambda&lt;/strong&gt; does the work: verify, deduplicate, render, send.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DynamoDB&lt;/strong&gt; stores delivery IDs we've already processed so retries from VesselAPI don't double-send. One row per event, 24-hour TTL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SES&lt;/strong&gt; sends the mail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IAM&lt;/strong&gt; glues the function's permissions together.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The function only runs when a webhook arrives, and at idle the whole thing costs zero.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A note before we start: the events VesselAPI emits — port arrival, port departure, ETA changed, geofence enter/exit — are derived from AIS, not ground truth. AIS-reported destinations and ETAs are entered by the bridge and are sometimes stale, abbreviated ("FOR ORDERS"), or misspelled. Geofence events apply hysteresis to avoid edge-flapping, but you'll still see the occasional false positive — a vessel drifting past a polygon edge in a busy anchorage, a duplicate broadcast from two coastal stations, an ETA tweak that flips back two minutes later. Worth knowing if you're tuning thresholds for an operational workflow; for "tell me when this ship enters that port," the defaults are fine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://vesselapi.com/pricing" rel="noopener noreferrer"&gt;Need a VesselAPI key? &lt;strong&gt;See the pricing &amp;amp; plans&lt;/strong&gt; →&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Verify an Email Address in SES
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🤖 Doing it with Claude Code&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Skip the console. Ask:&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;verify the SES identity me@example.com in us-east-1, then poll until it's confirmed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Claude runs &lt;code&gt;aws ses verify-email-identity&lt;/code&gt;, then loops on &lt;code&gt;aws ses get-identity-verification-attributes&lt;/code&gt; until the status flips to &lt;code&gt;Success&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the SES console, pick your region (we'll use &lt;code&gt;us-east-1&lt;/code&gt; throughout — change to taste), open &lt;strong&gt;Verified identities&lt;/strong&gt;, click &lt;strong&gt;Create identity&lt;/strong&gt;, and create an &lt;strong&gt;Email address&lt;/strong&gt; identity. AWS sends a confirmation email; click the link.&lt;/p&gt;

&lt;p&gt;That's it. We're using a verified email identity rather than a domain because it's instant — no DNS to wait on. For production you'll want a domain identity with DKIM, but for alerts to your own inbox an email identity is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Scaffold the Project
&lt;/h2&gt;

&lt;p&gt;The project layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vessel-email-alerts/
├── template.yaml
└── src/
    ├── handler.py
    ├── render.py
    └── requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;requirements.txt&lt;/code&gt; is empty — &lt;code&gt;boto3&lt;/code&gt; ships with the Lambda Python runtime, and we're not pulling in a templating library. Plain f-strings handle this.&lt;/p&gt;

&lt;p&gt;Here's &lt;code&gt;handler.py&lt;/code&gt; in full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;botocore.exceptions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ClientError&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;render_email&lt;/span&gt;

&lt;span class="n"&gt;logger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLogger&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INFO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Module-scope clients are reused across warm invocations -- one TLS
# handshake per container, not one per request.
&lt;/span&gt;&lt;span class="n"&gt;ses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ses&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ddb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dynamodb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;WEBHOOK_SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;TO_ADDRESS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TO_ADDRESS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;FROM_ADDRESS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FROM_ADDRESS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;IDEMPOTENCY_TABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IDEMPOTENCY_TABLE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lambda_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;headers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;isBase64Encoded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;_verify_signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-signature-256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_resp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid signature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;delivery_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-delivery-id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;delivery_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_resp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;missing X-Delivery-ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;_claim_delivery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delivery_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# Already processed -- ack so vesselapi stops retrying.
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_resp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;duplicate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;evt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;render_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;ses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;Source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FROM_ADDRESS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Destination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ToAddresses&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;TO_ADDRESS&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
            &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Subject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Release the claim so the next retry can try again. Without this,
&lt;/span&gt;        &lt;span class="c1"&gt;# any SES failure would silently lose the email.
&lt;/span&gt;        &lt;span class="nf"&gt;_release_delivery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delivery_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_resp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_verify_signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sig_header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;sig_header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sha256=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sig_header&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sha256=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):]&lt;/span&gt;
    &lt;span class="n"&gt;mac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_claim_delivery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delivery_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;ddb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;TableName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;IDEMPOTENCY_TABLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delivery_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;S&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;delivery_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expires_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;N&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;ConditionExpression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attribute_not_exists(delivery_id)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ClientError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ConditionalCheckFailedException&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_release_delivery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delivery_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;ddb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;TableName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;IDEMPOTENCY_TABLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delivery_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;S&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;delivery_id&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ClientError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed to release claim (delivery_id=%s): %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delivery_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_resp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;statusCode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;About a hundred lines, abridged a little for the post — the version in the example repo also has structured logging on every branch and a &lt;code&gt;try/except&lt;/code&gt; around the JSON parse that returns &lt;code&gt;400&lt;/code&gt; for malformed bodies. Two lines above are the load-bearing ones: the &lt;code&gt;compare_digest&lt;/code&gt; call and the &lt;code&gt;_release_delivery&lt;/code&gt; call. They're the difference between a pipeline that's fine on the happy path and one that survives the unhappy paths. We'll come back to both.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;render.py&lt;/code&gt;. There's one renderer per event type and a small dispatcher. Three of the seven for shape — port arrival, ETA change, and the generic fallback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;render_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_RENDERERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;_render_generic&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_vessel_label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vesselName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unknown vessel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;imo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;imo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (IMO &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;imo&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;imo&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_fmt_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;iso&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromisoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;iso&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Z&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+00:00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%Y-%m-%d %H:%M UTC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;iso&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_render_port_arrival&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_vessel_label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vessel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;portEvent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;port&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;when&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_fmt_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; arrived at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; arrived at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; on &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/strong&amp;gt; arrived at &amp;lt;strong&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/strong&amp;gt;.&amp;lt;/p&amp;gt;&amp;lt;p&amp;gt;Reported &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_render_eta_changed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_vessel_label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vessel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;etaChange&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_fmt_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;previousEta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_fmt_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currentEta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;shift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shiftMinutes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ETA shifted by &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;shift&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; min&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ETA changed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (shift: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;shift&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; minutes).&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/strong&amp;gt; ETA shifted by &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;shift&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; minutes.&amp;lt;/p&amp;gt;&amp;lt;p&amp;gt;Previous: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;br&amp;gt;Current: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_render_generic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_vessel_label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vessel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; event for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;_fmt_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;


&lt;span class="n"&gt;_RENDERERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;port.arrival&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_render_port_arrival&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;port.departure&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_render_port_departure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;# same shape as arrival
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eta.eta_changed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_render_eta_changed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eta.destination_changed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_render_destination_changed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;position.geofence_enter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_render_geofence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;position.geofence_exit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_render_geofence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other four renderers (departure, destination changed, draught changed, geofence) follow the same shape — pull the relevant fields from &lt;code&gt;evt["data"]&lt;/code&gt;, format them, return a &lt;code&gt;(subject, html, text)&lt;/code&gt; triple. The structure that scales is the dispatcher dict; adding a Slack or Teams channel later is the same pattern with a different output format.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc4sxviqlta1sxv7zinwi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc4sxviqlta1sxv7zinwi.png" alt="A sample vessel draught-changed alert email rendered in a mail client"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two Non-Obvious Parts
&lt;/h2&gt;

&lt;p&gt;It would be tempting to skim past two of those lines. They're the lines that decide whether this thing works or quietly turns into a problem in three months.&lt;/p&gt;

&lt;h3&gt;
  
  
  HMAC Verification, and Why &lt;code&gt;hmac.compare_digest&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Every VesselAPI webhook arrives with an &lt;code&gt;X-Signature-256&lt;/code&gt; header: &lt;code&gt;sha256=&lt;/code&gt; followed by the hex of &lt;code&gt;HMAC-SHA256(webhook_secret, raw_request_body)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The instinct is to skip this. The Lambda is on HTTPS. The URL is unguessable. Why bother? Because the URL leaks. It ends up in CloudWatch logs, in someone's terminal scrollback, in screenshots, in an AWS console somebody screen-shared on a call. Anyone who knows the URL can POST a fake event to it. Without verification, your inbox is now a free email-sending service for whoever finds it.&lt;/p&gt;

&lt;p&gt;The verification is two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;mac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason it's &lt;code&gt;hmac.compare_digest&lt;/code&gt; and not &lt;code&gt;==&lt;/code&gt; is the load-bearing detail of this whole post. A naive &lt;code&gt;==&lt;/code&gt; short-circuits at the first byte mismatch — meaning the comparison is faster when the strings differ early, slower when they differ late. That timing difference is enough for an attacker to recover the signature one byte at a time, given enough requests. &lt;code&gt;compare_digest&lt;/code&gt; always takes the same amount of time regardless of where the strings diverge. Use it.&lt;/p&gt;

&lt;p&gt;One byte-handling note: keep &lt;code&gt;body&lt;/code&gt; as &lt;code&gt;bytes&lt;/code&gt; end-to-end. &lt;code&gt;hmac.new&lt;/code&gt; wants bytes, &lt;code&gt;json.loads&lt;/code&gt; accepts bytes, and skipping the &lt;code&gt;decode()/encode()&lt;/code&gt; round-trip saves you from a class of bugs that only show up when the webhook source ever sends non-UTF-8 content.&lt;/p&gt;

&lt;h3&gt;
  
  
  Idempotency, and What Happens When SES Fails
&lt;/h3&gt;

&lt;p&gt;VesselAPI retries failed webhook deliveries with exponential backoff. That's a feature: a transient Lambda timeout doesn't lose the event. It's also a problem: the same event can arrive at your Lambda more than once, and without protection you'll send the same email twice. Or three times. Or thirteen.&lt;/p&gt;

&lt;p&gt;Every webhook arrives with a unique &lt;code&gt;X-Delivery-ID&lt;/code&gt;. We use it as the primary key in a small DynamoDB table with a &lt;em&gt;conditional&lt;/em&gt; put: if the row already exists, the put fails atomically, we return &lt;code&gt;200&lt;/code&gt;, and no email goes out. The 24-hour TTL means the table never grows.&lt;/p&gt;

&lt;p&gt;We return &lt;code&gt;200&lt;/code&gt; for duplicates rather than an error. If we returned an error, VesselAPI would retry, the put would fail again, we'd return another error, and we'd loop forever. Returning &lt;code&gt;200&lt;/code&gt; says "I've handled this," and the chain stops.&lt;/p&gt;

&lt;p&gt;The trap, which is easy to fall into and tempting to call "good enough": claim the delivery ID and &lt;em&gt;then&lt;/em&gt; call SES. If SES throws — throttle, transient 5xx, suppression-list hit — the claim is now sitting in DynamoDB with no email behind it. The next retry from VesselAPI looks up the delivery ID, finds the row, returns &lt;code&gt;200 duplicate&lt;/code&gt;, and the email is silently lost forever. That's the failure mode &lt;code&gt;_release_delivery&lt;/code&gt; exists for: on any SES exception, delete the claim row and re-raise so the retry reaches a fresh slate.&lt;/p&gt;

&lt;p&gt;The release itself can also fail (DynamoDB throttling, tiny window, very unlucky). When that happens we log loudly and let the original SES error propagate — the retry will be ack'd as a duplicate this once, but the failure is visible in CloudWatch instead of vanishing into the gap between two AWS services. The unit test for the whole sequence lives in &lt;code&gt;test_handler.py&lt;/code&gt; in the example repo — it's the case that catches the bug if you ever refactor the order of those two calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Deploy with SAM
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🤖 Doing it with Claude Code&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the example repo, ask:&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deploy this stack with my SES identity me@example.com and a fresh webhook secret
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Claude generates the secret with &lt;code&gt;openssl rand -hex 32&lt;/code&gt;, runs &lt;code&gt;sam build&lt;/code&gt; and &lt;code&gt;sam deploy --guided&lt;/code&gt;, and reads the &lt;code&gt;WebhookUrl&lt;/code&gt; back to you.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Drop &lt;code&gt;template.yaml&lt;/code&gt; next to &lt;code&gt;src/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;AWSTemplateFormatVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2010-09-09'&lt;/span&gt;
&lt;span class="na"&gt;Transform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS::Serverless-2016-10-31&lt;/span&gt;
&lt;span class="na"&gt;Description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Self-hosted vessel email alerts (vesselapi -&amp;gt; Lambda -&amp;gt; SES)&lt;/span&gt;

&lt;span class="na"&gt;Parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;WebhookSecret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;String&lt;/span&gt;
    &lt;span class="na"&gt;NoEcho&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;Description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;The secret you'll configure on the vesselapi notification.&lt;/span&gt;
  &lt;span class="na"&gt;ToAddress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;String&lt;/span&gt;
    &lt;span class="na"&gt;Description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Verified SES recipient (the inbox the alerts go to).&lt;/span&gt;
  &lt;span class="na"&gt;FromAddress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;String&lt;/span&gt;
    &lt;span class="na"&gt;Description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Verified SES sender identity.&lt;/span&gt;

&lt;span class="na"&gt;Resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;IdempotencyTable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS::DynamoDB::Table&lt;/span&gt;
    &lt;span class="na"&gt;Properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;BillingMode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PAY_PER_REQUEST&lt;/span&gt;
      &lt;span class="na"&gt;AttributeDefinitions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;AttributeName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;delivery_id&lt;/span&gt;
          &lt;span class="na"&gt;AttributeType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;S&lt;/span&gt;
      &lt;span class="na"&gt;KeySchema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;AttributeName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;delivery_id&lt;/span&gt;
          &lt;span class="na"&gt;KeyType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HASH&lt;/span&gt;
      &lt;span class="na"&gt;TimeToLiveSpecification&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;AttributeName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;expires_at&lt;/span&gt;
        &lt;span class="na"&gt;Enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="na"&gt;EmailSenderFunction&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS::Serverless::Function&lt;/span&gt;
    &lt;span class="na"&gt;Properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;Runtime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python3.12&lt;/span&gt;
      &lt;span class="na"&gt;Handler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;handler.lambda_handler&lt;/span&gt;
      &lt;span class="na"&gt;CodeUri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./src&lt;/span&gt;
      &lt;span class="na"&gt;Timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
      &lt;span class="na"&gt;MemorySize&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;256&lt;/span&gt;
      &lt;span class="na"&gt;Environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;Variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!Ref&lt;/span&gt; &lt;span class="s"&gt;WebhookSecret&lt;/span&gt;
          &lt;span class="na"&gt;TO_ADDRESS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!Ref&lt;/span&gt; &lt;span class="s"&gt;ToAddress&lt;/span&gt;
          &lt;span class="na"&gt;FROM_ADDRESS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!Ref&lt;/span&gt; &lt;span class="s"&gt;FromAddress&lt;/span&gt;
          &lt;span class="na"&gt;IDEMPOTENCY_TABLE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!Ref&lt;/span&gt; &lt;span class="s"&gt;IdempotencyTable&lt;/span&gt;
      &lt;span class="na"&gt;Policies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;DynamoDBCrudPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;TableName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!Ref&lt;/span&gt; &lt;span class="s"&gt;IdempotencyTable&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Statement&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Allow&lt;/span&gt;
              &lt;span class="na"&gt;Action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ses:SendEmail&lt;/span&gt;
              &lt;span class="na"&gt;Resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!Sub&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;arn:aws:ses:${AWS::Region}:${AWS::AccountId}:identity/${FromAddress}'&lt;/span&gt;
      &lt;span class="na"&gt;Events&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;Webhook&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HttpApi&lt;/span&gt;
          &lt;span class="na"&gt;Properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;Path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/webhook&lt;/span&gt;
            &lt;span class="na"&gt;Method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;post&lt;/span&gt;

&lt;span class="na"&gt;Outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;WebhookUrl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;Description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Paste this into your vesselapi notification's webhook_url.&lt;/span&gt;
    &lt;span class="na"&gt;Value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!Sub&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/webhook'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Function, API, table, IAM role, log group — about fifty lines of YAML. The equivalent Terraform is noticeably more verbose because it has to express each resource individually instead of leaning on &lt;code&gt;AWS::Serverless::Function&lt;/code&gt;'s built-in defaults. &lt;code&gt;NoEcho: true&lt;/code&gt; keeps the secret out of CloudFormation outputs and the AWS console; the env-var value is still readable to anyone with &lt;code&gt;lambda:GetFunctionConfiguration&lt;/code&gt;, which is the usual tradeoff and the reason the production checklist below moves it to Secrets Manager. The &lt;code&gt;ses:SendEmail&lt;/code&gt; policy is scoped to the verified &lt;code&gt;FromAddress&lt;/code&gt; identity ARN — a compromised function role can't be used to send mail from other identities in the account.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgpvp83ixmdsjdzmc2l6d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgpvp83ixmdsjdzmc2l6d.png" alt="The SAM template.yaml open in a code editor"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Build and deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sam build
sam deploy &lt;span class="nt"&gt;--guided&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--parameter-overrides&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;WebhookSecret&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;some-long-random-string&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;ToAddress&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;you@example.com &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;FromAddress&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;you@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--guided&lt;/code&gt; walks you through stack name, region, and the IAM confirmation. Pick &lt;code&gt;us-east-1&lt;/code&gt;, accept the IAM prompt, and let it run. About 90 seconds later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Outputs
-----------------------------------------
Key           WebhookUrl
Value         https://abc123xyz.execute-api.us-east-1.amazonaws.com/webhook
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save the URL. For the secret, &lt;code&gt;openssl rand -hex 32&lt;/code&gt; is a sensible default — use the same value for both the SAM parameter and the VesselAPI notification config below.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmzj850vdgjhkuboy2h4u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmzj850vdgjhkuboy2h4u.png" alt="The deployed EmailSenderFunction in the AWS Lambda console"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Register the Webhook with VesselAPI
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🤖 Doing it with Claude Code&lt;/strong&gt;&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;register a vesselapi webhook for IMO 9811000 pointing at the WebhookUrl from the last deploy, use the same secret
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Claude pulls the URL out of the SAM stack output, reuses the secret, and POSTs the notification config.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.vesselapi.com/v1/notifications &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$VESSELAPI_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "lambda-email-alerts",
    "imos": [9811000],
    "event_types": ["port.arrival", "port.departure", "eta.eta_changed"],
    "webhook_url": "https://abc123xyz.execute-api.us-east-1.amazonaws.com/webhook",
    "webhook_secret": "&amp;lt;the same secret you passed to SAM&amp;gt;"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;9811000&lt;/code&gt; is the IMO of the &lt;em&gt;Ever Given&lt;/em&gt; itself — handy if you want a vessel that moves often and is easy to recognise in your inbox while you verify the pipeline. Swap in the IMOs you actually want to watch. The &lt;code&gt;event_types&lt;/code&gt; filter is optional; omit it to get every event type for the watched vessels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Send a Test Event
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🤖 Doing it with Claude Code&lt;/strong&gt;&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fire a test event at the lambda-email-alerts notification and tail the CloudWatch logs until it reports sent
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Claude hits the test endpoint, follows the log stream, and stops when it sees the success line — or the error line, with a translation of what's actually wrong.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;VesselAPI exposes a test endpoint that fires a synthetic event through your full delivery path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.vesselapi.com/v1/notifications/&amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/test &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$VESSELAPI_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few seconds later, the email should be in your inbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  When It Doesn't Work the First Time
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🤖 Doing it with Claude Code&lt;/strong&gt;&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;read the last 5 minutes of CloudWatch logs for the EmailSenderFunction and tell me what's failing
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Claude pulls the log stream and surfaces the relevant error — usually one of the two below, before you've finished asking.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It usually doesn't. Two things go wrong on first deploy, in roughly this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;&lt;code&gt;MessageRejected: Email address is not verified&lt;/code&gt;&lt;/strong&gt; — SES is in sandbox and either the &lt;code&gt;FromAddress&lt;/code&gt; or &lt;code&gt;ToAddress&lt;/code&gt; isn't a verified identity. Verify both, retry. If you used the same email for both, you only need to verify it once.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;&lt;code&gt;invalid signature&lt;/code&gt;&lt;/strong&gt; in the Lambda logs — the secret in the SAM parameter doesn't match the one in the VesselAPI notification. They have to be byte-for-byte identical, including no trailing newlines.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Check the CloudWatch log group at &lt;code&gt;/aws/lambda/&amp;lt;stack-name&amp;gt;-EmailSenderFunction-&amp;lt;hash&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Costs
&lt;/h2&gt;

&lt;p&gt;For a single user with ten watched vessels and a few events a day, in &lt;code&gt;us-east-1&lt;/code&gt; as of April 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Volume&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway HTTP API&lt;/td&gt;
&lt;td&gt;~100 req/mo&lt;/td&gt;
&lt;td&gt;$0.0001&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda&lt;/td&gt;
&lt;td&gt;well inside free tier&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB pay-per-request&lt;/td&gt;
&lt;td&gt;~100 writes/mo&lt;/td&gt;
&lt;td&gt;$0.0001&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SES&lt;/td&gt;
&lt;td&gt;100 emails/mo&lt;/td&gt;
&lt;td&gt;$0.01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch logs&lt;/td&gt;
&lt;td&gt;small&lt;/td&gt;
&lt;td&gt;pennies&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Total: a few cents to a dollar a month, dominated by SES if you send a lot. Idle cost is zero. AWS prices drift; check the current rates if you scale this beyond personal use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going to Production
&lt;/h2&gt;

&lt;p&gt;The deploy above is the right shape for "alerts to my own inbox." For anything wider, four changes — each incremental, none of which break the pipeline above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Move out of SES sandbox.&lt;/strong&gt; Request production access in the SES console; AWS Support's initial response usually arrives within 24 hours, full approval can take longer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify a domain identity with DKIM.&lt;/strong&gt; Send &lt;code&gt;From: alerts@yourdomain.com&lt;/code&gt; and have it display as authenticated rather than as a generic AWS noreply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wire up bounce and complaint handling.&lt;/strong&gt; Attach an SES Configuration Set and route &lt;code&gt;Bounce&lt;/code&gt; and &lt;code&gt;Complaint&lt;/code&gt; events to an SNS topic. A single typo'd &lt;code&gt;ToAddress&lt;/code&gt; can otherwise quietly land you on the SES suppression list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move the webhook secret to Secrets Manager.&lt;/strong&gt; A small SAM change; the function reads it on cold start and caches it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A future post will go deep on each of these. For now, the pipeline you have is sufficient.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How can I receive automatic email alerts when a vessel enters or leaves a port?
&lt;/h3&gt;

&lt;p&gt;Subscribe to VesselAPI's port-event webhook, point it at an AWS Lambda function, and have Lambda format the event and send it via Amazon SES. The result is an email in your inbox each time a tracked vessel arrives at or departs from a port — no dashboard polling and no extra server.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the difference between maritime email alerts and API-based vessel monitoring?
&lt;/h3&gt;

&lt;p&gt;API-based monitoring gives your software a stream of events to process programmatically — useful for dashboards, fleet analytics, or automated triggers. Email alerts translate that same stream into something a human reads in their inbox, and are best when one or two people need passive awareness of specific vessels rather than a full dashboard. The two are layers, not alternatives: the API is what your systems react to, the email is what a human reads at 2am.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much does it cost to run vessel email alerts on AWS Lambda?
&lt;/h3&gt;

&lt;p&gt;For a typical fleet of a few dozen vessels generating tens of port events per day, the cost is essentially zero — well within the AWS Lambda and SES free tiers. Even at 1,000 alerts per month, the total monthly cost is well under one US dollar. See the cost breakdown above for the line items.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a server to receive vessel email alerts?
&lt;/h3&gt;

&lt;p&gt;No. The pipeline runs entirely on AWS Lambda (serverless) with SES for email delivery and DynamoDB for idempotency. There is no long-running server to provision or maintain.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I prevent duplicate emails when AWS retries a webhook?
&lt;/h3&gt;

&lt;p&gt;Use DynamoDB with the webhook event ID as the partition key and a conditional &lt;code&gt;PutItem&lt;/code&gt; to deduplicate. If the conditional put fails because the row already exists, return success to the webhook caller without sending the email.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I set up alerts for multiple vessels at once?
&lt;/h3&gt;

&lt;p&gt;Yes. Register each vessel as a separate watcher through the VesselAPI dashboard or pass a list of MMSIs to the bulk-subscription endpoint. The same Lambda and SES pipeline serves all of them — the per-vessel cost is negligible.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Comes Next
&lt;/h2&gt;

&lt;p&gt;This is part 1 of a series on building your own notification consumers on top of VesselAPI webhooks. Part 2 keeps the same Lambda, the same HMAC verify, the same DynamoDB idempotency — and swaps the email renderer for Slack Block Kit, Microsoft Teams Adaptive Cards, or Discord embeds. The dispatcher pattern in &lt;code&gt;render.py&lt;/code&gt; is the seam to plug those in: one function per channel, one config flag to pick which one runs.&lt;/p&gt;

&lt;p&gt;People often treat maritime email alerts vs API-based vessel monitoring as a choice. They aren't — they're layers. The API integration is the thing your services react to. The email pipeline above it is what a human reads at 2am. Now your inbox knows when your ships move.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>lambda</category>
      <category>ses</category>
      <category>webhooks</category>
    </item>
    <item>
      <title>Every Ship Has a Secret Resume</title>
      <dc:creator>VesselAPI</dc:creator>
      <pubDate>Sat, 04 Apr 2026 22:27:01 +0000</pubDate>
      <link>https://forem.com/vessel_api/every-ship-has-a-secret-resume-1ji8</link>
      <guid>https://forem.com/vessel_api/every-ship-has-a-secret-resume-1ji8</guid>
      <description>&lt;p&gt;In 2011, a Japanese shipyard delivered a bulk carrier called &lt;em&gt;21 Glory&lt;/em&gt;. She was 37,000 deadweight tonnes — a handymax, built to carry grain, coal, and steel through the regional trade lanes of the Pacific.&lt;/p&gt;

&lt;p&gt;Stamped into her classification record was this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;NS*(BCM-A, BC-XII, GRAB)(PS-DA&amp;amp;FA)(IWS) MNS*&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is not a random string. It is a professional credential — a coded statement, issued by a classification society, that specifies what this ship is built to do, what she has been inspected for, and what she is permitted to carry.&lt;/p&gt;

&lt;p&gt;Lloyd's Register began recording ship conditions in a London coffee house in 1760. The core concept has not changed: independent examination, encoded into standardized formats. The notation string is the output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the Notation
&lt;/h2&gt;

&lt;p&gt;Here is what &lt;em&gt;21 Glory&lt;/em&gt;'s notation means, token by token:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Token&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NS*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Hull notation by ClassNK; asterisk indicates approved plans and surveyed construction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BCM-A&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bulk carrier strengthened for heavy cargo in all holds with alternate loading approval&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BC-XII&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SOLAS Chapter XII bulk carrier structural compliance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GRAB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Hull reinforced for grab (mechanical clamshell) discharge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PS-DA&amp;amp;FA&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Structural strength program using Direct Analysis and Fatigue Assessment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IWS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;In-Water Survey eligibility — divers instead of dry-dock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MNS*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Machinery notation with full-survey standard&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A vessel without &lt;code&gt;BCM-A&lt;/code&gt; would be unsuitable for iron ore with alternating empty holds.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;GRAB&lt;/code&gt; notation deserves special attention. Grab discharge means a crane lowers a massive clamshell bucket into the hold and scoops cargo out. The impact on the tank top — the hull interior — is enormous. Vessels rated for grab discharge have reinforced scantlings: thicker plating, stronger frames, additional stiffening. A vessel without this reinforcement risks structural damage during or after grab operations.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Rosetta Stone Problem
&lt;/h2&gt;

&lt;p&gt;There is no universal notation language. Each classification society invented its own.&lt;/p&gt;

&lt;p&gt;There are approximately a dozen major societies worldwide (IACS members), and they encode identical capabilities differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bulk carrier heavy cargo:&lt;/strong&gt; &lt;code&gt;BCM-A&lt;/code&gt; (ClassNK) vs &lt;code&gt;BC-A&lt;/code&gt; (Bureau Veritas, CCS, Korean Register)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unattended machinery:&lt;/strong&gt; &lt;code&gt;M0&lt;/code&gt; (ClassNK), &lt;code&gt;AUT-UMS&lt;/code&gt; (Bureau Veritas), &lt;code&gt;AUT-0&lt;/code&gt; (CCS), &lt;code&gt;UMA&lt;/code&gt; (Korean Register)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk2y32dukzwlgth7hgmmk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk2y32dukzwlgth7hgmmk.png" alt=" " width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Korean Register wraps notations in single quotes. CCS uses Unicode parentheses. One Bureau Veritas vessel had a notation string that was 847 characters long — with occasional full-width Unicode parentheses mixed in.&lt;/p&gt;

&lt;p&gt;Our first attempt at a cross-society parser took about a day to write and about three weeks to debug.&lt;/p&gt;

&lt;p&gt;Classification societies predate international standardization bodies by over a century. Lloyd's Register predated the first International Load Line Convention (1930) by 170 years. By the time anyone thought about harmonization, the notation formats were embedded in thousands of certificates, contracts, and insurance policies. Only Common Structural Rules (adopted 2006, harmonized 2015) achieved genuine cross-society alignment — which is why &lt;code&gt;CSR&lt;/code&gt; appears nearly identically across societies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Built
&lt;/h2&gt;

&lt;p&gt;We built collectors for six classification societies, parsing notation strings without normalization. The rationale: the notation string is a legal document, and paraphrasing a legal document is how you get sued.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://api.vesselapi.com/v1/vessel/9468217/classification?filter.idType=imo"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_API_KEY"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"classification"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"identification"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"vesselName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"21 GLORY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"imoNumber"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"9468217"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"flagName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Singapore"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"classStatusString"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"In Class"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"classification"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"mainClass"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NS*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"classNotationString"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NS*(BCM-A, BC-XII, GRAB)(PS-DA&amp;amp;FA)(IWS) MNS*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"surveys"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"survey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Special Survey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"lastDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"21 Jan 2026"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dueFrom"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"14 Sep 2030"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dueTo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"survey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Intermediate Survey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dueFrom"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"14 Jun 2027"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dueTo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"14 Dec 2027"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"survey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Annual Survey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dueFrom"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"14 Jun 2026"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dueTo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"14 Dec 2026"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"yard"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"hullYardName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SAIKI HEAVY INDUSTRIES CO., LTD."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"keelDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10 Dec 2010"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dateOfBuild"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"22 Apr 2011"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dimensions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dwt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;37202&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"grossTon69"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;22863&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fthmmw0c4f33tul8pu7pn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fthmmw0c4f33tul8pu7pn.png" alt=" " width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Coverage
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Classification Society&lt;/th&gt;
&lt;th&gt;Vessels with Notation Data&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nippon Kaiji Kyokai (ClassNK)&lt;/td&gt;
&lt;td&gt;~8,900&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Det Norske Veritas (DNV)&lt;/td&gt;
&lt;td&gt;~8,300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bureau Veritas (BV)&lt;/td&gt;
&lt;td&gt;~7,500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;China Classification Society (CCS)&lt;/td&gt;
&lt;td&gt;~4,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registro Italiano Navale (RINA)&lt;/td&gt;
&lt;td&gt;~2,800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Korean Register (KR)&lt;/td&gt;
&lt;td&gt;~1,900&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~33,400&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Not all vessels carry additional notations; some older or simpler ships have only base class symbols.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Notation String Decides
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cargo Suitability.&lt;/strong&gt; The notation determines cargo compatibility. For iron ore shipments, charterers verify for &lt;code&gt;BC-A&lt;/code&gt; (or equivalent) and &lt;code&gt;GRAB&lt;/code&gt;. A &lt;code&gt;BC-B&lt;/code&gt; vessel has restrictions on hold usage and draught, affecting loading patterns and structural safety. Getting this wrong is not a paperwork problem. It is a the-ship-might-break problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Survey Cycles.&lt;/strong&gt; &lt;em&gt;21 Glory&lt;/em&gt;'s record shows a Special Survey completed January 21, 2026, with the next due by September 2030. The Special Survey is the big one — a comprehensive inspection every five years, often requiring dry-docking, costing hundreds of thousands of dollars. Vessels near their deadline face potential delays, detention risk, or class loss if certification lapses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compliance Signals.&lt;/strong&gt; Notations like &lt;code&gt;PSPC-WBT&lt;/code&gt; (ballast tank protective coatings) and &lt;code&gt;IHM&lt;/code&gt; (Inventory of Hazardous Materials) signal IMO convention compliance. In some ports, missing an expected notation is functionally an invitation to be boarded and detained by port state control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Operational Quality Proxy.&lt;/strong&gt; Notations including &lt;code&gt;ESP&lt;/code&gt; (Enhanced Survey Programme), &lt;code&gt;IWS&lt;/code&gt;, and &lt;code&gt;CMS&lt;/code&gt; (Continuous Machinery Survey) correlate with better maintenance. They are a useful filter when screening numerous vessel candidates — not definitive proof of quality, but a signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The endpoint is at &lt;code&gt;/vessel/{id}/classification?filter.idType=imo&lt;/code&gt;. Lloyd's Register and ABS are on the roadmap.&lt;/p&gt;

&lt;p&gt;Somewhere right now, a surveyor is climbing into a ballast tank with a flashlight and an ultrasonic thickness gauge, measuring the steel plate by plate, writing down what she finds. The notation string is the output. We just make it queryable.&lt;/p&gt;

</description>
      <category>maritime</category>
      <category>api</category>
      <category>data</category>
    </item>
    <item>
      <title>Crude Oil on the Water: Tracking Tanker Flows with Claude and a Maritime API</title>
      <dc:creator>VesselAPI</dc:creator>
      <pubDate>Sun, 22 Mar 2026 22:25:03 +0000</pubDate>
      <link>https://forem.com/vessel_api/crude-oil-on-the-water-tracking-tanker-flows-with-claude-and-a-maritime-api-29f0</link>
      <guid>https://forem.com/vessel_api/crude-oil-on-the-water-tracking-tanker-flows-with-claude-and-a-maritime-api-29f0</guid>
      <description>&lt;p&gt;Just after midnight UTC today, a 274-metre crude oil tanker called ALTURA slipped out of Novorossiysk, Russia's largest Black Sea crude export terminal. She's flagged to Sierra Leone at the time of this query, which tells you nothing about where she was built and almost nothing about who owns her. (Shadow fleet vessels re-flag frequently. ALTURA has had at least three names and flags since 2023.) At 163,750 deadweight tonnes, she's a Suezmax, designed to transit the Suez Canal fully loaded, carrying up to around a million barrels of crude. Right now she's somewhere south of Novorossiysk, heading for the Turkish Straits.&lt;/p&gt;

&lt;p&gt;You probably don't care about any of that. Unless you trade oil.&lt;/p&gt;

&lt;p&gt;If you do, that departure is a data point -- one of nine tankers that left Novorossiysk today alone, ranging from Suezmax crude carriers down to small coastal chemical tankers. Multiply by Primorsk, Ust-Luga, Al Jubail, and the rest of the world's export terminals, and you get the global crude supply picture: not a number in a spreadsheet, but a fleet of ships you can actually watch move.&lt;/p&gt;

&lt;p&gt;The firms that turn this into a product -- Kpler, Vortexa, Windward -- charge serious money for it. They should; their platforms layer satellite imagery, cargo manifests, and proprietary models on top of the raw vessel data. But the raw vessel data itself? That part is more accessible than you might think.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Oil Is on the Water
&lt;/h2&gt;

&lt;p&gt;Here's the thing about seaborne crude: unlike a pipeline, it's visible.&lt;/p&gt;

&lt;p&gt;Every tanker over 300 gross tonnes carries an AIS transponder -- a radio beacon that broadcasts position and speed every few seconds when underway, along with identity and destination data at longer intervals. Originally designed to prevent collisions, AIS has become the accidental backbone of commodity intelligence. If a VLCC loads crude at Ras Tanura and sets course for Ningbo, you can see it happen.&lt;/p&gt;

&lt;p&gt;The signals that matter to a commodity desk are straightforward:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Departures from export terminals&lt;/strong&gt; -- Novorossiysk, Ust-Luga, Primorsk (Russia), Al Jubail, Ras Tanura (Saudi Arabia), Basra (Iraq). Each departure is supply entering the water.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Arrivals at refinery ports&lt;/strong&gt; -- Rotterdam, Singapore, Ningbo, Houston. Each arrival is supply being consumed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vessels in transit&lt;/strong&gt; -- who's heading where, when they'll arrive, how deep they're sitting in the water. A tanker's draught is a rough proxy for how much cargo she's carrying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The vessel herself&lt;/strong&gt; -- flag state, owner, classification society, inspection record. These details separate the compliant fleet from the "shadow fleet" that moves sanctioned barrels.&lt;/p&gt;

&lt;p&gt;None of this is secret. It's just scattered across different systems and formats. The trick is querying it conversationally instead of writing integration code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;MCP&lt;/a&gt; (Model Context Protocol) lets an AI model call external APIs during a conversation -- so it retrieves live data rather than guessing from training data. &lt;a href="https://vesselapi.com" rel="noopener noreferrer"&gt;VesselAPI&lt;/a&gt; provides the maritime data: AIS positions, port events, EU MRV emissions, port state inspections, and vessel registry information, wrapped in an MCP server.&lt;/p&gt;

&lt;p&gt;Add this to your project root as &lt;code&gt;.mcp.json&lt;/code&gt; (requires Node.js 18+):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"vesselapi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vesselapi-mcp"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"VESSELAPI_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"YOUR_API_KEY"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sign up at &lt;a href="https://vesselapi.com" rel="noopener noreferrer"&gt;vesselapi.com&lt;/a&gt; for a free API key. The JSON structure is the same across MCP clients -- file location varies (Cursor reads &lt;code&gt;.mcp.json&lt;/code&gt; from the project root; Claude Desktop uses its own settings path). That's the entire setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watching the Black Sea
&lt;/h2&gt;

&lt;p&gt;I asked Claude: &lt;em&gt;"What tankers departed Novorossiysk today?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The model called &lt;code&gt;list_port_events&lt;/code&gt; with the port's UN/LOCODE (RUNVS) and a departure filter. The raw results included everything: tankers, bulk carriers loading grain, tugs, service vessels. Novorossiysk isn't just an oil port. I had to call &lt;code&gt;get_vessel&lt;/code&gt; on each result to check the type, then filtered to tankers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vessel&lt;/th&gt;
&lt;th&gt;IMO&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ALTURA&lt;/td&gt;
&lt;td&gt;9292199&lt;/td&gt;
&lt;td&gt;Crude Oil Tanker&lt;/td&gt;
&lt;td&gt;Sierra Leone&lt;/td&gt;
&lt;td&gt;274m, 163,750 DWT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ANTARCTICA&lt;/td&gt;
&lt;td&gt;9910492&lt;/td&gt;
&lt;td&gt;Oil Tanker&lt;/td&gt;
&lt;td&gt;Liberia&lt;/td&gt;
&lt;td&gt;274m x 50m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NISSOS SIKINOS&lt;/td&gt;
&lt;td&gt;9884033&lt;/td&gt;
&lt;td&gt;Tanker&lt;/td&gt;
&lt;td&gt;Marshall Islands&lt;/td&gt;
&lt;td&gt;274m x 48m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TATIANA&lt;/td&gt;
&lt;td&gt;9340128&lt;/td&gt;
&lt;td&gt;Oil/Chemical Tanker&lt;/td&gt;
&lt;td&gt;Liberia&lt;/td&gt;
&lt;td&gt;183m, 38,396 DWT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ENERGY DIONE&lt;/td&gt;
&lt;td&gt;9995973&lt;/td&gt;
&lt;td&gt;Tanker&lt;/td&gt;
&lt;td&gt;United Kingdom&lt;/td&gt;
&lt;td&gt;274m x 48m&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three Suezmax-class vessels in a single day from one port. Look at the flags: Sierra Leone, Liberia, Marshall Islands, Liberia, United Kingdom. Four of five are registered to open registries -- countries with no meaningful connection to the cargo, the crew, or the route. That pattern has a name in the industry: flag of convenience. And when you see it concentrated at an oil terminal under sanctions pressure, it gets more specific: shadow fleet. Only ENERGY DIONE flies a flag you'd expect from a vessel operating in compliant trade.&lt;/p&gt;

&lt;p&gt;I picked ANTARCTICA and asked: &lt;em&gt;"Tell me everything about this vessel."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The model pulled &lt;code&gt;get_vessel&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Type:&lt;/strong&gt; Oil tanker&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flag:&lt;/strong&gt; Liberia&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Length:&lt;/strong&gt; 274m, &lt;strong&gt;Beam:&lt;/strong&gt; 50m&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Home port:&lt;/strong&gt; Monrovia&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are Suezmax dimensions. She's big enough to matter.&lt;/p&gt;

&lt;p&gt;Then: &lt;em&gt;"What are her emissions?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The model called &lt;code&gt;get_vessel_emissions&lt;/code&gt; and returned her EU MRV data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2024:&lt;/strong&gt; 3,382 tonnes CO2, 1,084 tonnes fuel consumed, 993 hours at sea&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2023:&lt;/strong&gt; 7,616 tonnes CO2, 2,431 tonnes fuel consumed, 1,686 hours at sea&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Technical efficiency:&lt;/strong&gt; 2.57 gCO2/t.nm&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Her 2024 emissions dropped by more than half. Not because she got cleaner -- because she spent half as much time at sea. That's consistent with a vessel that spent part of the year in floating storage or waiting for cargo. My guess is sanctions-related delays, but oversupply looks the same from the outside. Either way, a tanker that suddenly halves its sea time is a signal worth flagging.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Where is she right now?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;get_vessel_position&lt;/code&gt; returned:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Position:&lt;/strong&gt; 44.71 degreesN, 37.85 degreesE&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speed:&lt;/strong&gt; 6.6 knots, course 200.6 degrees&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;She's in the Black Sea, heading south-southwest for the Bosphorus. If she's carrying Urals crude, she'll transit the Turkish Straits and enter the Mediterranean. Then the question becomes whether she heads west toward Europe or east toward the Suez and Asia. That routing decision, visible in real-time via AIS, tells you something about where Russian crude is finding buyers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Other Side
&lt;/h2&gt;

&lt;p&gt;I flipped to the import side: &lt;em&gt;"What tankers are heading to Rotterdam in the next few days?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The model constructed an ETA window and called &lt;code&gt;get_port_inbound&lt;/code&gt;. Among the results: HAIGUI -- an Oil Products Tanker, Liberian-flagged, 114,900 DWT, built by Samsung Heavy Industries. She's sitting at a draught of 13.2 metres. For a vessel that size, that draught means she's carrying serious weight.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Check her inspection record."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;17 inspections across five regimes -- Paris MoU, Tokyo MoU, Indian Ocean MoU, Black Sea MoU, US Coast Guard. Zero detentions. Only 4 deficiencies across over a decade of inspections. Ports visited include Rotterdam, Houston, Philadelphia, Amsterdam, Sydney, Ulsan, Dalian, Mombasa. That's a vessel with a global trading pattern and a clean compliance record -- the kind of ship a European refiner accepts without questions.&lt;/p&gt;

&lt;p&gt;Her former name was GULF VALOUR until 2019. Name changes and ownership transfers are routine in commercial shipping, but they're also the bread crumbs that sanctions investigators follow. When a vessel changes name and flag within a short window, it's worth checking why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the Signal
&lt;/h2&gt;

&lt;p&gt;If I were building this into something real, the first thing I'd automate is departure counts from Novorossiysk and Primorsk. Week-over-week changes in Suezmax departures from Russian Black Sea ports are the crude signal -- more departures means more supply on the water, and you can estimate rough volumes by vessel class.&lt;/p&gt;

&lt;p&gt;The import side is harder and more interesting. Counting arrivals at Rotterdam, Ningbo, and Jamnagar tells you where the oil is going. If European arrivals drop while Indian arrivals spike, that's a sanctions-driven rerouting -- exactly the kind of structural shift that moves Brent-Dubai spreads before the commentary catches up.&lt;/p&gt;

&lt;p&gt;I tried to see if departure counts from Novorossiysk correlated with anything in the Brent forward curve. With only a week of port event data, the answer is: not enough signal, just noise. You'd need months. But the data is accumulating daily, and it's the same data that professional cargo-tracking firms start from before layering on satellite imagery and bill-of-lading records.&lt;/p&gt;

&lt;p&gt;The compliance angle is arguably the most interesting for a smaller operation. Cross-reference departing vessels against classification records and inspection histories. A Suezmax with no classification society, no inspection record, and a Sierra Leone flag departing Novorossiysk is a different kind of data point than a Lloyd's-classed, Paris MoU-inspected vessel doing the same run. That distinction is worth money to compliance teams and underwriters.&lt;/p&gt;

&lt;p&gt;You could set up a daily cron job with the Claude API to pull departures from ten export terminals. Pipe it into DuckDB. Track week-over-week. Build alerts for when a port goes quiet, or when shadow fleet tonnage surges at a terminal that normally services compliant trade.&lt;/p&gt;

&lt;p&gt;Everything in this post came from live API calls on March 22, 2026. The results will be different tomorrow. That's the point. Sign up at &lt;a href="https://vesselapi.com" rel="noopener noreferrer"&gt;vesselapi.com&lt;/a&gt; for a free key, drop the &lt;code&gt;.mcp.json&lt;/code&gt; from the Setup section into your project root, and start asking. Try &lt;em&gt;"What crude oil tankers departed Primorsk in the last 12 hours?"&lt;/em&gt; or &lt;em&gt;"Find all tankers heading to Singapore -- which ones have the deepest draught?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This was crude oil. The same approach works for LNG carriers at Sabine Pass, iron ore bulkers at Port Hedland, container ships stacking up outside Shanghai. The data is the same. The questions change.&lt;/p&gt;

&lt;p&gt;The oil is on the water. Now you can see it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Data queried from VesselAPI on March 22, 2026 via &lt;code&gt;vesselapi-mcp&lt;/code&gt;. Port events return all vessel types; filtering to tankers requires a second vessel-detail lookup. EU MRV emissions are self-reported and independently verified. AIS identifiers can be spoofed. This is not financial advice.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>tutorial</category>
      <category>api</category>
      <category>trading</category>
    </item>
  </channel>
</rss>
