<?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>DEV Community: VesselAPI</title>
    <description>The latest articles on DEV Community by VesselAPI (@vessel_api).</description>
    <link>https://dev.to/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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3838965%2Fd6a827f7-26de-4683-a155-c41378675ee7.jpg</url>
      <title>DEV Community: VesselAPI</title>
      <link>https://dev.to/vessel_api</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vessel_api"/>
    <language>en</language>
    <item>
      <title>What 'Real-Time' Actually Means for a Vessel Tracking API</title>
      <dc:creator>VesselAPI</dc:creator>
      <pubDate>Sun, 21 Jun 2026 14:44:04 +0000</pubDate>
      <link>https://dev.to/vessel_api/what-real-time-actually-means-for-a-vessel-tracking-api-3a4l</link>
      <guid>https://dev.to/vessel_api/what-real-time-actually-means-for-a-vessel-tracking-api-3a4l</guid>
      <description>&lt;p&gt;Open any consumer ship-tracking website, zoom in on the Strait of Dover, and you'll see hundreds of little arrowheads gliding across the channel as if you were watching a live camera feed. It feels continuous. It feels like &lt;em&gt;now&lt;/em&gt;.&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%2Frealtime-vessel-tracking-websocket%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%2Frealtime-vessel-tracking-websocket%2Fimages%2Fhero.png" alt="Container ship at sea with suggested radio signals" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It isn't. Not really. And the gap between what "real-time" feels like and what it actually is turns out to be the most important thing to understand before you start building anything on top of a vessel tracking API.&lt;/p&gt;

&lt;p&gt;So let's look at it properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing you're actually receiving
&lt;/h2&gt;

&lt;p&gt;A ship doesn't broadcast its position to a satellite that beams it instantly into your laptop. It broadcasts an AIS message — a short burst on one of two VHF channels, about 256 bits long, containing its identity, position, course, speed, and heading. AIS stands for Automatic Identification System, and it was designed in the 1990s primarily so that ships could avoid hitting each other, not so that you could draw nice dots on a Mapbox tile.&lt;/p&gt;

&lt;p&gt;This origin matters. AIS is a &lt;em&gt;collision-avoidance&lt;/em&gt; protocol that the rest of the world has retrofitted into a global tracking network. The clue is in how often a ship transmits: a vessel moving fast and turning sharply sends a position every 2 seconds. A vessel anchored or moored might send one every 3 minutes. The system spends its bandwidth where danger lives, not where dashboards want it.&lt;/p&gt;

&lt;p&gt;Those VHF bursts then have to be &lt;em&gt;heard&lt;/em&gt; by something. Two somethings, really:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Terrestrial receivers&lt;/strong&gt; — antennas on coastlines, harbours, and ships, with a range of roughly 40 nautical miles. Coverage is excellent near land, nonexistent in the middle of the Pacific.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Satellites&lt;/strong&gt; — low-Earth-orbit constellations that pick up AIS signals from open ocean. Coverage is global, but a given patch of ocean might only be overflown every 10 to 90 minutes depending on the constellation. And satellites struggle in busy areas, because hundreds of ships transmitting on the same frequency at the same time produce a sort of radio shouting match.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the time a position reaches an API, it has been: broadcast by the vessel, received by a terrestrial antenna or a satellite, relayed to a ground station, decoded, deduplicated against other receivers that heard the same burst, and written to a database. The latency budget for that pipeline ranges from a couple of seconds (a tug in Rotterdam harbour) to tens of minutes (a bulk carrier two-thirds of the way across the Indian Ocean).&lt;/p&gt;

&lt;p&gt;So when an API says "real-time vessel tracking," what it can honestly promise is: &lt;em&gt;the most recent position we have, with the timestamp of when the vessel actually transmitted it&lt;/em&gt;. The honesty lives in that second clause.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F168ywpu79l9udlj0u5ys.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F168ywpu79l9udlj0u5ys.png" alt="Ship VHF antenna with distant coastal receiver" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The latency budget, written down
&lt;/h2&gt;

&lt;p&gt;Let's make this concrete. Here's a rough decomposition of the lag between a ship being somewhere and your code knowing it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Reporting interval&lt;/strong&gt; — 2 seconds to 3 minutes, depending on the vessel's state.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Reception delay&lt;/strong&gt; — near-zero for terrestrial, anywhere from seconds to an hour for satellite, depending on overflight schedule and message collisions.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Aggregation and ingestion&lt;/strong&gt; — a few seconds to tens of seconds inside the data provider's pipeline.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Your polling interval&lt;/strong&gt; — entirely up to you, and usually the biggest contributor to your perceived latency.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Numbers 1 and 2 are physics and orbital mechanics. You cannot beat them. Number 3 is engineering, and a good provider keeps it small. Number 4 is yours to design.&lt;/p&gt;

&lt;p&gt;This is why every position you fetch from a vessel API comes with a timestamp — and why that timestamp matters more than the moment you received the response. The data isn't lying when it's 12 minutes old. It's telling you the truth: &lt;em&gt;this is where the ship was 12 minutes ago, and we haven't heard a peep since&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pulling positions, properly
&lt;/h2&gt;

&lt;p&gt;Let's actually do it. The VesselAPI Python SDK is installed with &lt;code&gt;pip install vessel-api-python&lt;/code&gt;, and the client looks like this:&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;vessel_api_python&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;VesselClient&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;VesselClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your_key_here&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two distinct concepts in the API, and confusing them is the first mistake everyone makes. A &lt;strong&gt;vessel&lt;/strong&gt; is identity: name, IMO, MMSI, type, flag, tonnage. It doesn't move. A &lt;strong&gt;position&lt;/strong&gt; is a snapshot: latitude, longitude, speed, course, and the timestamp of the AIS burst that produced it. These live behind different endpoints.&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="c1"&gt;# Identity — who is this ship?
&lt;/span&gt;&lt;span class="n"&gt;vessel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessels&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;9811000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;vessel&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessel_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vessel&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;vessel&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="c1"&gt;# Position — where was it last seen?
&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;position&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9811000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;vessel_position&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pos&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;pos&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;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sog&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things to notice. The ID is positional and defaults to IMO; pass &lt;code&gt;filter_id_type="mmsi"&lt;/code&gt; if you want to look up by MMSI instead. The speed field is &lt;code&gt;sog&lt;/code&gt; — speed over ground, in knots — not &lt;code&gt;speed&lt;/code&gt;. And &lt;code&gt;timestamp&lt;/code&gt; is an ISO-8601 &lt;em&gt;string&lt;/em&gt;, which means if you want to compute "how stale is this?", you have to parse it first:&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;

&lt;span class="n"&gt;reported&lt;/span&gt; &lt;span class="o"&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;fromisoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timestamp&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="n"&gt;age&lt;/span&gt; &lt;span class="o"&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="n"&gt;reported&lt;/span&gt;
&lt;span class="nf"&gt;print&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;Position is &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; seconds old&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This little calculation is the most honest line of code you'll write all day. Use it. Surface that age in your UI. A dashboard that displays a 47-minute-old position as if it were live is lying to its users; a dashboard that says "last seen 47 minutes ago" is telling them something true and useful.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcdvwn2vme3s70q9obq9v.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcdvwn2vme3s70q9obq9v.png" alt="Map of Rotterdam with vessel positions on a developer screen" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tracking many ships at once
&lt;/h2&gt;

&lt;p&gt;Single-vessel polling is fine for, say, tracking your one chartered tanker. But most real applications care about an area — a port approach, a strait, a fishing ground. For that, the API exposes location queries:&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="c1"&gt;# Everything currently in a rectangle around Rotterdam
&lt;/span&gt;&lt;span class="n"&gt;box&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vessels_bounding_box&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;lat_min&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;51.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lat_max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;52.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lon_min&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;3.80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;lon_max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;4.55&lt;/span&gt;&lt;span class="p"&gt;,&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;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessels&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;print&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="n"&gt;vessel_name&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="n"&gt;mmsi&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="n"&gt;latitude&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="n"&gt;longitude&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="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Or everything within 10 km of a point
&lt;/span&gt;&lt;span class="n"&gt;near&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vessels_radius&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="mf"&gt;51.92&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="mf"&gt;4.48&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;radius&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10000&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;These return the latest known position for each vessel in the area. Each row has its own &lt;code&gt;timestamp&lt;/code&gt;, and those timestamps will not be the same. The tug in the inner harbour might be 4 seconds old; the cargo ship anchored offshore might be 6 minutes old. That's not a bug — it's the protocol working as designed.&lt;/p&gt;

&lt;h2&gt;
  
  
  How often should you actually poll?
&lt;/h2&gt;

&lt;p&gt;People reach for WebSockets and "live" streams because polling feels primitive. But for AIS data, the rhythm of your polling should match the rhythm of the underlying transmissions, not exceed it. There's no benefit to polling a moored ship every second when it only transmits every three minutes — you'll just see the same row, over and over, while burning your rate limit.&lt;/p&gt;

&lt;p&gt;A reasonable heuristic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Harbour and approach traffic&lt;/strong&gt; (lots of movement, terrestrial coverage): every 10 to 30 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coastal traffic at cruising speed&lt;/strong&gt;: every minute or two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open-ocean voyages&lt;/strong&gt; (satellite-dependent): every 5 to 15 minutes is plenty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a position's &lt;code&gt;timestamp&lt;/code&gt; hasn't advanced since your last poll, nothing new has happened. The vessel hasn't moved into a new reporting state, or no receiver has heard from it since. Either way, polling harder won't help.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing under the thing
&lt;/h2&gt;

&lt;p&gt;So: real-time vessel tracking is not a continuous video stream of the oceans. It is a sparse, opportunistic, physics-bounded sampling of millions of ships, glued together by a global network of antennas and satellites, and exposed to you as the freshest snapshot the system was able to assemble. The word "real-time" is doing some work it can't quite hold up.&lt;/p&gt;

&lt;p&gt;But once you see the seams — once you treat that &lt;code&gt;timestamp&lt;/code&gt; field as the first-class citizen it is — the whole thing stops feeling like a leaky abstraction and starts feeling like what it is: a remarkable feat of distributed coordination, where a 1990s collision-avoidance radio protocol has been quietly turned into a planetary sensor network.&lt;/p&gt;

&lt;p&gt;The ships have been broadcasting all along. Someone just had to listen.&lt;/p&gt;

</description>
      <category>ais</category>
      <category>realtime</category>
      <category>vesseltracking</category>
      <category>api</category>
    </item>
    <item>
      <title>The Email That Arrived Six Hours Late</title>
      <dc:creator>VesselAPI</dc:creator>
      <pubDate>Fri, 05 Jun 2026 11:07:45 +0000</pubDate>
      <link>https://dev.to/vessel_api/the-email-that-arrived-six-hours-late-375d</link>
      <guid>https://dev.to/vessel_api/the-email-that-arrived-six-hours-late-375d</guid>
      <description>&lt;p&gt;You set up the email alert weeks ago. A small, satisfying piece of automation: when the &lt;em&gt;MV Aristos&lt;/em&gt; enters the port approach zone, you get a notification. Your team prepares the berth. Customs is warned. The agent is on the way.&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%2Fchoosing-vessel-monitoring-approach%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%2Fchoosing-vessel-monitoring-approach%2Fimages%2Fhero.png" alt="A laptop showing an inbox at dawn while a ship is already docked outside the window" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The email arrives at 06:14. You read it on the train. You feel briefly organised.&lt;/p&gt;

&lt;p&gt;Then you get to the office and find out the &lt;em&gt;Aristos&lt;/em&gt; tied up at 04:30.&lt;/p&gt;

&lt;p&gt;What happened isn't quite a bug, and it isn't quite a lie. It's the gap between two things that sound almost identical when you read the marketing copy: &lt;strong&gt;maritime email alerts&lt;/strong&gt; and &lt;strong&gt;API-based vessel monitoring&lt;/strong&gt;. They both promise to tell you where your ships are. Only one of them is actually built to.&lt;/p&gt;

&lt;p&gt;Six hours is the bad day, to be clear. Most of the time the gap is ten or twenty minutes — long enough to miss the pilot boarding ground, short enough that nobody writes it down. Six hours is what happens when ten or twenty minutes meets a quarantine folder.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing about email
&lt;/h2&gt;

&lt;p&gt;Email is, structurally, a notification protocol whose architecture dates to the early 1970s, designed for messages that absolutely do not need to be timely. SMTP itself was formalised in 1982, and it was built to survive a node going down for a week. The entire postal metaphor — inboxes, deliveries, retries — is the wrong shape for "this ship is about to reach the pilot boarding ground."&lt;/p&gt;

&lt;p&gt;Here is roughly what happens between an AIS transmission and your inbox. A provider ingests AIS positions from terrestrial receivers and, for open ocean, from satellite constellations. The raw feed itself is fast in some places and slow in others: a Class A transponder on a SOLAS-mandated ship over 300 GT will report every two to ten seconds when underway and every three minutes at anchor; a Class B unit on a smaller coastal trader reports every thirty seconds or so. So already, "real-time AIS" is a range, not a number.&lt;/p&gt;

&lt;p&gt;That feed then gets batched, deduplicated, and run through a rule engine on some interior schedule. When a rule fires, a message is queued to SMTP. SMTP hands off to your mail provider, which runs the message through greylisting, spam filters, rate limits, and DMARC checks. Eventually your mail client polls, and the email appears.&lt;/p&gt;

&lt;p&gt;It's the stacking that gets you. Each step is harmless on its own. Together they produce a system whose typical latency lives in the minutes and whose worst case lives in the hours, because somewhere in that chain something will occasionally get stuck. Greylisting alone — the spam-prevention trick where unknown senders are politely told to try again later — routinely adds fifteen minutes. A DMARC misalignment can route the alert into a quarantine folder you'll find on Thursday. None of that is the alert provider's fault. It's just what email &lt;em&gt;is&lt;/em&gt;: a protocol hardened against spam, not optimised for signalling.&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%2Ff46aw6y96niuexjrsu9n.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%2Ff46aw6y96niuexjrsu9n.png" alt="Vessel traffic control room with glowing AIS screens" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What an API is actually doing
&lt;/h2&gt;

&lt;p&gt;A vessel-tracking API — the kind you'd query from your TMS, your berth-planning tool, or a small internal dashboard — is doing something architecturally different. Instead of pushing a human-readable message through a chain of best-effort hops, it lets your software ask the provider's database a direct question and get a structured answer back.&lt;/p&gt;

&lt;p&gt;The most important consequence is that the data is structured. An email says "MV Aristos entered Zone Piraeus-Approach at 04:27." An API response says:&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;"mmsi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;240958000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"imo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9776418&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;"ARISTOS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;37.9421&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lon"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;23.6398&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sog"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;8.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cog"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;287&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"nav_status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PIRAEUS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"eta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2024-11-12T04:30:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2024-11-12T04:27:13Z"&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;(Illustrative example — &lt;code&gt;nav_status: 0&lt;/code&gt; is the raw AIS code for "under way using engine"; most APIs normalise it into a string. The MMSI and IMO are fictional.)&lt;/p&gt;

&lt;p&gt;You cannot automate against a sentence. You can do almost anything against that JSON: correlate it with your cargo manifest, trigger a berth-allocation algorithm, update a customer-facing ETA, or fire your own email, on your own infrastructure, with your own latency budget.&lt;/p&gt;

&lt;p&gt;Latency, the other thing people care about, mostly collapses — with caveats. A REST call typically returns in a few hundred milliseconds. A WebSocket or streaming endpoint can push position updates within a second or two of the provider receiving them. But that's for vessels within range of terrestrial AIS receivers. For open-ocean ships visible only to satellites, the position you fetch in 200 milliseconds may itself be five to twenty minutes old, depending on the constellation's revisit rate. A fast API does not abolish physics; it just stops adding insults on top.&lt;/p&gt;

&lt;p&gt;And then there's the part that sounds like a disadvantage and isn't: an API is pull, not push. You decide when to ask. Forty vessels every thirty seconds, if you want — though at that rate you should probably be on a streaming endpoint instead of hammering REST, both for your own sanity and your provider's rate limits. Webhooks sit in the middle: structured push notifications that give you timing control with the data quality of an API call.&lt;/p&gt;

&lt;p&gt;The honest version is that polling has its own pains. Your loop has to actually keep running. Authentication tokens have to keep refreshing. You have to detect gaps when the network blips, because unlike email, nothing screams when a request silently never happens. Whoever told you APIs were free of operational debt was selling something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest case for email
&lt;/h2&gt;

&lt;p&gt;If you are a single operator tracking three or four ships, and you don't have a developer, and your workflow is "look at email, walk down the hall, tell someone" — email alerts are &lt;em&gt;fine&lt;/em&gt;. They are, in fact, better than fine. No integration, no API keys, no JSON parsing, no rate-limit management. The five-minute delay doesn't matter, because the human in the loop is also operating on minutes-to-hours timescales.&lt;/p&gt;

&lt;p&gt;The problem is what happens when that workflow grows. The moment you have more than a handful of vessels, or more than one person needs the data, or the data needs to flow into &lt;em&gt;another&lt;/em&gt; system — email becomes a bottleneck disguised as a feature. You end up with people forwarding alerts to each other. You end up with Outlook rules trying to parse vessel names out of subject lines. You end up, eventually, building a brittle script that reads emails and extracts data from them, which is the saddest possible reinvention of an API. If you find yourself writing an email scraper, the universe is trying to tell you something.&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%2Fedr5rxrbyw5limexoptr.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%2Fedr5rxrbyw5limexoptr.png" alt="A developer's screen showing JSON vessel data next to a ship tracking map" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The expensive failure mode
&lt;/h2&gt;

&lt;p&gt;The really costly version of this isn't the dramatic six-hour delay. It's the demurrage dispute.&lt;/p&gt;

&lt;p&gt;A vessel arrives at 04:30. Your alert lands at 06:14. Weeks later, in a laytime calculation, your records say the ship arrived at 06:14 — that's the timestamp in your system, because that's when the email came in — and the port's records say 04:30. The difference is real money, and the arbitrator has to pick which timestamp to believe. Notice of Readiness disputes turn on exactly this sort of evidentiary gap. The cheap-looking email alert just became the most expensive line item in the quarter.&lt;/p&gt;

&lt;p&gt;Around that sit the quieter costs. Berth windows missed by twenty minutes because the arrival alert was queued behind a spam scan. Customer ETAs that were correct in the provider's database but stale in yours, because nobody received the update. An ops team that has slowly developed a learned distrust of its own alerting system, which is the worst failure mode in any monitoring tool. Once people stop trusting the alerts, they start checking manually, and the automation might as well not exist.&lt;/p&gt;

&lt;p&gt;None of these look like the alerting &lt;em&gt;broke&lt;/em&gt;. They look like ordinary operational friction. That's what makes them hard to attribute and easy to live with.&lt;/p&gt;

&lt;p&gt;Email alerts are a notification layer. An API is a data layer. You can build the first on top of the second — many operations teams do exactly that, sending themselves Slack messages or emails generated from API data they control. You cannot build the second on top of the first without writing an email scraper.&lt;/p&gt;

&lt;p&gt;The ship doesn't care when the email arrives. It's already alongside.&lt;/p&gt;

</description>
      <category>vesselmonitoring</category>
      <category>maritimeapis</category>
      <category>ais</category>
      <category>emailalerts</category>
    </item>
    <item>
      <title>What a Port Event Actually Is (And Why Your Timestamps Are Lying to You)</title>
      <dc:creator>VesselAPI</dc:creator>
      <pubDate>Tue, 02 Jun 2026 10:47:48 +0000</pubDate>
      <link>https://dev.to/vessel_api/what-a-port-event-actually-is-and-why-your-timestamps-are-lying-to-you-3k43</link>
      <guid>https://dev.to/vessel_api/what-a-port-event-actually-is-and-why-your-timestamps-are-lying-to-you-3k43</guid>
      <description>&lt;p&gt;Ask someone when a ship "arrives" at a port and you'll get an answer that sounds obvious. It arrives when it gets there. When it docks. When the cargo comes off. When the captain stops driving.&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%2Fport-events-vessel-tracking%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%2Fport-events-vessel-tracking%2Fimages%2Fhero.png" alt="Container ship in Singapore harbor at dusk with other vessels in the background" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All of these are different events. And if you're building anything that depends on knowing where ships are and what they're doing — a logistics dashboard, an emissions tracker, a demurrage calculator, a trading signal — the difference between them is the difference between software that works and software that quietly lies to you.&lt;/p&gt;

&lt;p&gt;This is a piece about what a &lt;em&gt;port event&lt;/em&gt; actually is, why the timestamp on it is more philosophically loaded than it has any right to be, and how to pull clean data out of an API without tripping over the assumptions baked into the question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fridge magnet version
&lt;/h2&gt;

&lt;p&gt;Here's the mental model most people start with: ships sail around, and every so often one of them enters a port. The port notices. A row gets written somewhere. That row is a "port event."&lt;/p&gt;

&lt;p&gt;That model is wrong. Not approximately wrong — wrong in a way that will burn you the first time you trust it.&lt;/p&gt;

&lt;p&gt;In reality, no port "notices" anything. There is no welcome desk at the edge of Singapore harbor. What actually happens is this: commercial vessels of any size that matters — the rule is nominally 300 gross tons on international voyages, 500 GT on domestic ones, plus all passenger ships, but the operational answer is "anything commercially significant" — are required by SOLAS to broadcast their position, speed, heading, and identity. Fishing fleets and pleasure craft are mostly invisible. The protocol is called AIS (Automatic Identification System), broadcasting on two dedicated VHF channels (161.975 and 162.025 MHz) in the maritime band — pure digital TDMA, not voice, distinct from the channels humans actually talk on. Smaller vessels often carry the lighter Class B variant voluntarily.&lt;/p&gt;

&lt;p&gt;Coastal stations receive these broadcasts within roughly 40–60 nautical miles of shore; satellites fill in the open ocean, with gaps depending on constellation coverage and how badly signals collide in busy lanes. The aggregate raw stream runs into the hundreds of millions to over a billion messages per day globally — scattered across the planet with no semantic meaning attached. Each message carries an MMSI, a nine-digit identifier that is the actual join key if you ever want to correlate a port event back to the raw position stream.&lt;/p&gt;

&lt;p&gt;A "port event" is something a computer infers from that stream. It's a &lt;em&gt;derived&lt;/em&gt; fact, not a recorded one.&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%2Frte0gb2nxtrlby3cq15w.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%2Frte0gb2nxtrlby3cq15w.png" alt="Overhead view of vessel traffic clustered around Singapore port" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Inferring a thing that didn't actually happen
&lt;/h2&gt;

&lt;p&gt;To turn position fixes into port events, you need two things: a definition of what a port is in geographic terms, and a rule for when a ship has interacted with it.&lt;/p&gt;

&lt;p&gt;The first one sounds easy and isn't. Ports aren't points; they're sprawling, irregular regions that include anchorages outside the breakwater, terminal berths inside it, sometimes a river pilot boarding area many kilometers offshore, and frequently a chunk of open sea where ships wait — sometimes for days, sometimes for weeks — for a berth to free up. Singapore's anchorage zones extend well into the strait. Houston's ship channel runs roughly fifty nautical miles from open water to the inner turning basin, serving a string of independently operated terminals that are colloquially "the port" but legally several different things.&lt;/p&gt;

&lt;p&gt;So the first decision is: where does the port end? Serious port-event systems use polygons — hand-drawn or algorithmically refined boundaries, layered: an outer "port limits" polygon, inner anchorage polygons, and individual berth polygons. A vessel crossing one of these boundaries generates a candidate event. The providers who skip the layering and use a single outer polygon are selling you noise — every anchorage stay becomes "arrived," every drift across the line becomes a fresh port call.&lt;/p&gt;

&lt;p&gt;The second decision is what counts as a crossing. A naive "is the ship inside the polygon?" check produces nonsense: GPS noise causes vessels at the edge to flicker in and out, generating dozens of fake arrival/departure pairs per hour. Real systems apply hysteresis (you have to be inside for N minutes before it counts) and speed thresholds — a moored ship reports near-zero speed-over-ground, though you have to filter the AIS sentinel codes near 102 knots. The standard reserves &lt;code&gt;1023&lt;/code&gt; (102.3 knots) for "SOG unavailable" and &lt;code&gt;1022&lt;/code&gt; (≥102.2 knots) as an overflow marker; both will otherwise inject impossible speeds into your data.&lt;/p&gt;

&lt;p&gt;What comes out the other end is a clean event stream: &lt;em&gt;arrival&lt;/em&gt;, &lt;em&gt;berth&lt;/em&gt;, &lt;em&gt;departure&lt;/em&gt;. Each one with a timestamp.&lt;/p&gt;

&lt;p&gt;And the timestamps are where it gets interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why sub-minute matters
&lt;/h2&gt;

&lt;p&gt;Query a well-built port events endpoint for Singapore — say &lt;code&gt;/portevents/port/SGSIN&lt;/code&gt;, using the UN/LOCODE — and you should get back events resolved to the second. Not the hour. Not the nearest five minutes. The second.&lt;/p&gt;

&lt;p&gt;The demurrage case is the one that matters most, and it gets the least attention. Emissions models get the press; trading signals get the venture money. But the demurrage clock — the meter that charges a charterer for keeping a ship waiting — starts ticking at a contractually defined moment, often tied to Notice of Readiness tendering at the port limits. NOR is a document the master serves, not an AIS event; but AIS-derived port event timestamps are increasingly used as corroborating evidence, and a charterer's lawyer disputing a \$40,000-per-day demurrage claim on a Capesize bulker (an illustrative figure — real rates swing widely with the market) will absolutely cross-reference your data against the NOR timing. If your provider rounded to the nearest hour, you have just introduced uncertainty into someone's evidence. (We learned this the expensive way. Five-minute buckets were fine until they weren't.)&lt;/p&gt;

&lt;p&gt;Emissions estimates drift quietly across a fleet when timestamps are coarse, and the trading case is the most fragile of all: watching how fast ships cycle through berths at Port Hedland is, in effect, watching iron ore loading throughput in something close to real time. If your berth timestamp is fuzzy by twenty minutes, your derivative is noise.&lt;/p&gt;

&lt;p&gt;The reason the data &lt;em&gt;can&lt;/em&gt; be sub-minute is that AIS Class A position reports arrive every 2 to 10 seconds for a vessel underway — strictly, every 10 seconds at port-approach speeds (0–14 knots), every 6 seconds in the 14–23 knot range, every 2 seconds above 23 knots, with course changes collapsing the interval to 2–3 seconds across the board. Class B transponders report every 30 seconds when moving, dropping to 3 minutes when slow; newer Class B+ devices match Class A rates. Stationary vessels at anchor with reported SOG ≤ 3 knots drop to once every 3 minutes — though GPS noise will sometimes keep an anchored ship in the faster-reporting mode anyway, which is one reason boundary flicker is so persistent.&lt;/p&gt;

&lt;p&gt;In practice? A lot of providers downsample for storage and the precision dies upstream. If all your timestamps end in &lt;code&gt;:00:00&lt;/code&gt;, that's your answer.&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%2F22lt047xfcig9g7xbb5u.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%2F22lt047xfcig9g7xbb5u.png" alt="Ship bridge clock and electronic chart display showing a precise position fix" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Querying it without tripping
&lt;/h2&gt;

&lt;p&gt;The endpoint is &lt;code&gt;GET /portevents/port/{unlocode}&lt;/code&gt;. UN/LOCODE is the five-character code maintained by UN/CEFACT — two letters of country code, three characters of location, occasionally including digits (&lt;code&gt;GB2WB&lt;/code&gt; is a real one). &lt;code&gt;SGSIN&lt;/code&gt; is Singapore. &lt;code&gt;NLRTM&lt;/code&gt; is Rotterdam. &lt;code&gt;USNYC&lt;/code&gt; is New York; though if you're querying the New York/New Jersey container complex specifically, your provider may use &lt;code&gt;USNWK&lt;/code&gt; (Newark) for the New Jersey terminals — the boundary varies by data source, and "the port of New York" turns out not to be one thing. UN/CEFACT publishes updates twice a year, so if you're building something that runs unattended for years, track the changes.&lt;/p&gt;

&lt;p&gt;The first thing that'll burn you is that the event types aren't interchangeable. An &lt;em&gt;arrival&lt;/em&gt; means the vessel crossed into the port limits — it might still be miles from any berth, anchored, waiting. A &lt;em&gt;berth&lt;/em&gt; event means it's actually alongside. A &lt;em&gt;departure&lt;/em&gt; means it crossed out again. If you want "time spent loading," you want berth-to-departure, not arrival-to-departure. The difference can be days.&lt;/p&gt;

&lt;p&gt;Coverage is uneven. Fishing fleets, very small craft, and naval vessels — which are SOLAS-exempt and may suppress AIS in operational contexts, though plenty of warships and auxiliaries transmit routinely in commercial ports — are inconsistent at best. Query a fishing port and the silence is the answer.&lt;/p&gt;

&lt;p&gt;Big ports sometimes contain multiple terminals under one LOCODE, so you might see two berth events for the same call if a vessel shifts berth mid-stay — that's correct behavior, not a bug.&lt;/p&gt;

&lt;p&gt;And events arrive out of order. Satellite AIS latency means transmissions from quieter ports can be buffered until the next overhead pass; for busy ports the more common cause of late arrivals is provider ingestion batching. Either way: your code should not assume chronological order, and providers that stamp ingestion time instead of transmission time will quietly destroy your analysis.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing under the thing
&lt;/h2&gt;

&lt;p&gt;"A ship arrived at a port" is not an observed fact. It's a model. Somewhere between the radio signal and the JSON, a decision was made about where the port ends and what counts as being inside it. That decision is invisible in the response, and it shapes everything downstream.&lt;/p&gt;

&lt;p&gt;The nice thing about port events, compared to most derived data, is that the inference layer is unusually well-defined. The polygons exist. The rules are reproducible. The timestamps, done right, are exact to the second.&lt;/p&gt;

&lt;p&gt;You just have to remember that the ship didn't arrive. Something decided it had.&lt;/p&gt;

</description>
      <category>portevents</category>
      <category>api</category>
      <category>ais</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Drawing Lines on the Ocean</title>
      <dc:creator>VesselAPI</dc:creator>
      <pubDate>Mon, 25 May 2026 14:48:24 +0000</pubDate>
      <link>https://dev.to/vessel_api/drawing-lines-on-the-ocean-10gn</link>
      <guid>https://dev.to/vessel_api/drawing-lines-on-the-ocean-10gn</guid>
      <description>&lt;p&gt;Imagine you've drawn a circle on a map. It surrounds the Port of Rotterdam, with a bit of slack so you catch ships in the approach. You want to know, in something close to real time, when a particular tanker crosses that line. Not when someone checks a dashboard. Not in a daily report. The moment it happens.&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%2Fadvanced-notifications-geofence-polygon%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%2Fadvanced-notifications-geofence-polygon%2Fimages%2Fhero.png" alt="Container ship crossing a buoyed boundary at dawn" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the face of it, trivial. You have a position. You have a polygon. Check if one is inside the other. Done.&lt;/p&gt;

&lt;p&gt;Except almost nothing about that sentence is true in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing you think you have
&lt;/h2&gt;

&lt;p&gt;The first lie is "you have a position." You don't, really. You have a position that a vessel's AIS transponder broadcast some number of seconds or minutes ago, that was picked up by a terrestrial receiver (if the vessel is near shore) or a satellite (if it isn't), that was relayed through one or more data networks, that was deduplicated against other reports of the same broadcast, that was timestamped — possibly multiple times, by different systems with slightly different clocks — and that finally arrived in a database.&lt;/p&gt;

&lt;p&gt;The position is real. The "now" is negotiable.&lt;/p&gt;

&lt;p&gt;Class A transponders — required under SOLAS for commercial vessels of 300 GT and up on international voyages — report as often as every 2 seconds for a vessel above 23 knots or one changing course aggressively, every 6 seconds at 14–23 knots, and every 10 seconds for anything underway below 14 knots regardless of course changes. Anchored or moored, it drops to 3 minutes. (The full ITU-R M.1371 table has more buckets than that; the point is the rate scales with how interesting the vessel currently is.) Class B SO units on smaller craft report every 30 seconds above 2 knots and every 3 minutes at or below — and Class B CS units, increasingly common, just report every 30 seconds and yield the channel to Class A. In a place as busy as Rotterdam, a small vessel's updates can get elbowed out and arrive minutes apart.&lt;/p&gt;

&lt;p&gt;Satellite AIS is a separate latency story, and honestly the part of this stack I find most quietly enraging — half the vendors in this space sell "real-time satellite AIS" and quietly mean "ten minutes old, on a good day." The protocol's anti-collision mechanism — Self-Organizing TDMA — assumes each vessel can passively listen to nearby traffic and pick an unoccupied time slot from the local frame map. That assumption holds beautifully at ship-to-ship VHF ranges of roughly 20 nautical miles (the design figure; in practice it varies a lot with antenna height). From orbit, it falls apart: thousands of vessels beyond each other's range have independently claimed the same time slots, and a satellite hears all of them at once. Modern LEO constellations claw a lot of those messages back with wider-bandwidth receivers and probabilistic decoding, but gaps remain. By the time you see the vessel cross the line, the next position might place it 200 meters past it. Or 2 kilometers. The crossing itself is something you have to infer.&lt;/p&gt;

&lt;p&gt;Which brings us to the second lie: "check if one is inside the other."&lt;/p&gt;

&lt;h2&gt;
  
  
  Inside, outside, and the hysteresis problem
&lt;/h2&gt;

&lt;p&gt;Here is what naive geofencing looks like in production: a vessel anchors precisely on the boundary of your polygon. Wind and current shift, and the vessel swings through an arc — a ship on 60 meters of chain in 15 meters of water can sweep through a circle over 100 meters wide, and that's &lt;em&gt;real&lt;/em&gt; movement, not noise. On top of that, modern receivers add a few meters of GPS jitter under open sky; multipath in a busy harbor, obstructions, or loss of differential correction can push that to tens of meters regardless of how new the equipment is. Every few minutes, a new position arrives — sometimes just inside the fence, sometimes just outside.&lt;/p&gt;

&lt;p&gt;Your webhook fires. And fires. And fires. By morning your downstream system has received 847 "vessel entered port" notifications for the same ship that hasn't actually moved.&lt;/p&gt;

&lt;p&gt;This is the same problem thermostats have. The solution is the same too: &lt;strong&gt;hysteresis&lt;/strong&gt;. You don't fire on a single crossing. You fire on a &lt;em&gt;state change&lt;/em&gt; that has settled.&lt;/p&gt;

&lt;p&gt;Concretely, your handler tracks, for each (vessel, geofence) pair, the last known state — &lt;code&gt;inside&lt;/code&gt;, &lt;code&gt;outside&lt;/code&gt;, or &lt;code&gt;unknown&lt;/code&gt; — and only emits a notification when a new observation contradicts the stored state and you have some confidence the contradiction is real.&lt;/p&gt;

&lt;p&gt;The simplest version of "real" is a debounce window. We got this wrong the first time by using a single 60-second window platform-wide. Worked fine for a 5nm port-approach fence. Fired seventeen times per crossing on a harbor entrance fence a pilot boat could cross in twenty seconds. It needs to be per-fence, scaled to the smallest dimension you care about and the fastest legitimate vessel.&lt;/p&gt;

&lt;p&gt;A buffer zone — enter when the vessel is 100m &lt;em&gt;inside&lt;/em&gt; the boundary, exit when it's 100m &lt;em&gt;outside&lt;/em&gt; — works beautifully for circles. For arbitrary polygons it's harder; computing an inward offset of a concave shape is a real piece of geometry, not a one-liner, so most implementations apply the buffer at evaluation time rather than try to redraw the polygon.&lt;/p&gt;

&lt;p&gt;Your point-in-polygon test also needs to work in projected coordinates or use a proper spherical distance formula. Raw lat/lon arithmetic produces shapes that are noticeably non-circular at high latitudes — at Rotterdam's 51.9°N, a degree of longitude is about 69 km on the ground, not 111. That's a 38% squash. A "5 nautical mile radius" drawn with naive Euclidean distance is an ellipse, and a visibly wrong one.&lt;/p&gt;

&lt;p&gt;There's a subtler trap waiting too. If two position updates for the same vessel arrive within milliseconds — entirely possible with high-frequency feeds and multiple workers — a naive "read state, decide, write state" sequence races against itself. Both workers read &lt;code&gt;outside&lt;/code&gt;, both see &lt;code&gt;inside&lt;/code&gt;, both fire. In Postgres, the cleanest fix is a conditional update with a timestamp guard: &lt;code&gt;UPDATE ... WHERE state = 'outside' AND last_observed_at &amp;lt; $position_timestamp&lt;/code&gt;, then check the affected row count. The timestamp clause matters more than it looks — without it, a satellite position from twenty minutes ago, arriving late after a fresh terrestrial position, will cheerfully roll the state back and re-fire the crossing. (Pessimistic locking with &lt;code&gt;SELECT FOR UPDATE&lt;/code&gt; is simpler to reason about; at geofence throughputs the difference rarely matters.)&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%2Fxp098u5hbg0tx6ls0gnn.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%2Fxp098u5hbg0tx6ls0gnn.png" alt="Overhead view of vessels at a busy harbor entrance" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the webhook should actually contain
&lt;/h2&gt;

&lt;p&gt;When the notification finally does fire, what goes in the payload matters more than people expect. The receiver may need to argue with it — dismiss it as a stale position, route it to the right downstream system, decide whether to wake someone up. Give them the materials to make that call.&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;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"geofence.entered"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"evt_01HXYZ..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"occurred_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;"2024-11-14T08:42:17Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vessel"&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;"mmsi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;338234567&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"imo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9074729&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;"EXAMPLE VESSEL"&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;"geofence"&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gf_rotterdam_approach"&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;"Rotterdam Approach"&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;"position"&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;"lat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;51.9461&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lon"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.1234&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sog"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;8.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cog"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;87&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reported_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;"2024-11-14T08:41:55Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"nav_status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"under_way_using_engine"&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;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"previous_state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"outside"&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;Ship MMSIs are 9 digits with a Maritime Identification Digit (MID) prefix roughly in the 201–775 range; prefixes starting with 0, 8, or 9 indicate coast stations, group calls, or special devices like AtoN buoys and MOB beacons, not vessels. IMO numbers are 7 digits, last digit a check digit (the 9074729 above validates; we had two bug reports in the first month about an earlier example that didn't, from engineers who pasted it straight into a validator). Use fictional-but-structurally-valid identifiers in your docs.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;event_id&lt;/code&gt; is the receiver's lifeline. Webhooks get retried. Networks fail. Your handler crashes between sending the HTTP request and committing the "sent" flag. The receiver will see the same event twice, and the only thing that lets them deduplicate cleanly is a stable, unique identifier per &lt;em&gt;logical&lt;/em&gt; event — not per delivery attempt. (Their dedup store should be bounded too; storing every &lt;code&gt;event_id&lt;/code&gt; forever is an unbounded leak nobody notices until it's six months in.)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;occurred_at&lt;/code&gt; versus &lt;code&gt;position.reported_at&lt;/code&gt; is the gap between when the vessel was where it says, and when your system was confident enough to call the crossing. The vessel was at that lat/lon at 08:41:55. You decided 22 seconds later. That delta is the truth about how much to trust the event.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;confidence&lt;/code&gt; we argue about more than anything else. Current rule: &lt;code&gt;high&lt;/code&gt; when the position is under 5 minutes old &lt;em&gt;and&lt;/em&gt; the AIS position-accuracy bit is 1 (indicating &amp;lt;10m, usually DGPS-augmented); &lt;code&gt;low&lt;/code&gt; when the last known position is older than 15 minutes or satellite-derived with a long gap; nothing in between. We tried &lt;code&gt;medium&lt;/code&gt; and customers ignored it — one of our senior engineers defended it for two sprints on the grounds that "of course there's a middle case" — and the dashboard data was unambiguous: nobody filtered on it, nobody alerted on it, it was decoration. We killed it. Five minutes is generous for a tight fence — a vessel at 20 knots covers over 3 km in that time — so for harbor-scale fences we tighten it. Write the thresholds down. Argue about them in code review, not in incident retros.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;nav_status&lt;/code&gt; is worth including, but treat it as advisory. It's a 4-bit field that's &lt;em&gt;supposed&lt;/em&gt; to reflect operational status, but in practice it's set once at commissioning, or updated via an integrated bridge system that may or may not be wired to anything useful, or just left at the default. Vessels routinely transmit &lt;code&gt;under_way_using_engine&lt;/code&gt; while sitting at anchor because nobody changed the setting. Useful for suppression heuristics; dangerous as a hard filter, and frankly this is the AIS field I'd most like to set on fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Delivery: the part everyone underestimates
&lt;/h2&gt;

&lt;p&gt;The webhook itself is the easy bit. POST a JSON body to a URL. Twenty lines of code.&lt;/p&gt;

&lt;p&gt;What separates a hobby project from production is what happens when the POST fails. And here's the thing nobody tells you up front: your retry schedule is going to be wrong, and you'll only find out which way when something breaks.&lt;/p&gt;

&lt;p&gt;Ours used to retry every 10 seconds for 30 minutes. Sounded reasonable. Then a customer — call them Skipper, since they were a logistics shop with a fondness for nautical names — did a 35-minute deploy on a Friday afternoon. By the time their server came back, the queue had drained itself dry against 502s. They missed six hours of arrival notifications across half a fleet, and the angry Slack came in at 2am Saturday because that's when their ops team noticed the discrepancy against the carrier portal. We now use 30s, 2m, 10m, 1h, then hourly for 24h, with ±20% jitter on every interval — without the jitter, when a shared hosting provider recovers from an outage, every retry timer in your system fires at the same millisecond and you DDoS the customer on their way back up. Every retry reuses the same &lt;code&gt;event_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sign the payloads. HMAC-SHA256 of the request body with a per-customer secret, sent in a header — Stripe calls theirs &lt;code&gt;Stripe-Signature&lt;/code&gt;, GitHub uses &lt;code&gt;X-Hub-Signature-256&lt;/code&gt;. The header name doesn't matter. What matters is that the timestamp goes &lt;em&gt;inside&lt;/em&gt; the signed material, and you reject anything more than a few minutes old. Without that, anyone who captures one valid request can replay it forever. Stripe's scheme is the widely-copied reference; copy it shamelessly.&lt;/p&gt;

&lt;p&gt;Then there's the delivery log. Every attempt — success, failure, response code, response body, latency — written somewhere queryable. The argument "we never got the notification for vessel X" happens roughly once a month, and the log ends it in thirty seconds: yes you did, here's the 200; or no, we tried six times, your server returned 503 every time.&lt;/p&gt;

&lt;p&gt;And two layers of failure handling, which get conflated more often than they should. A consecutive-failure threshold — say, ten failures inside a minute — stops you immediately hammering an endpoint that's clearly down. (Strictly speaking this isn't a full circuit breaker; we don't probe for recovery, we just back off and let the retry schedule do the probing.) A separate endpoint-health policy decides when to give up entirely, mark the endpoint dead, and page someone. We use 24 hours of accumulated failure for that, not 48 — 48 was too forgiving, and customers preferred being woken up to discovering on Monday that nothing had worked since Friday.&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%2Fpk156adxju1aa0m58ry2.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%2Fpk156adxju1aa0m58ry2.png" alt="Close-up of a brass ship's bell on weathered teak" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The geofence itself is a choice
&lt;/h2&gt;

&lt;p&gt;One last subtlety. A geofence isn't just a shape. It's a &lt;em&gt;modeling decision&lt;/em&gt; about what you actually care about.&lt;/p&gt;

&lt;p&gt;A circular fence around a port's coordinates is easy to draw and almost always wrong. Ports aren't circles. They have channels, anchorages outside the breakwater, restricted zones inside. A 5-mile radius around Rotterdam's city center will include vessels transiting the North Sea that have nothing to do with the port — and will simultaneously miss the Maas pilot boarding ground, which sits well offshore from the outer terminals. Rotterdam's port complex stretches more than 40 km from the sea inland, so "Rotterdam" as a single coordinate is already a fiction.&lt;/p&gt;

&lt;p&gt;A polygon traced along the actual port boundary is better, but vessels at the outer anchorage are arguably already arriving, and a freight forwarder cares about a different boundary than a bunker fuel supplier or a port authority. In practice one vessel crosses several geofences on a single voyage — different fences for different customers asking different questions about the same ship.&lt;/p&gt;

&lt;p&gt;Get the infrastructure right and you still have to decide where to put the line. The circle on the map is the easy part.&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>geofencing</category>
      <category>ais</category>
      <category>tutorial</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://dev.to/vessel_api/the-946-millisecond-tax-migrating-api-key-auth-from-bcrypt-to-hmac-sha256-4c74</link>
      <guid>https://dev.to/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://dev.to/vessel_api/self-hosted-vessel-email-alerts-with-aws-lambda-and-ses-dh9</link>
      <guid>https://dev.to/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>
  </channel>
</rss>
