Skip to main content
Neptune Labs
← Work

Neptune Atlas

Ship broking and chartering platform

Live and selling. Card billing runs on live Stripe keys with trials, promo codes and per-licence proration, and the map draws from 94,338 AIS fixes on file. neptuneatlas.com

A broking desk matches cargo to tonnage, negotiates a rate, and takes a commission. The desk this grew out of ran that cycle on an Odoo addon that loaded 2.15 million vessel rows at 55 rows a second, went offline for 25 minutes on a single 238 MB upload, and assumed one customer forever. Neptune Atlas is the same domain rebuilt as something several desks can subscribe to.

Type
In-house product
Scope
Full stack, multi-tenant SaaS, data pipeline and billing

Built with

  • FastAPI
  • Python
  • PostgreSQL 16
  • psycopg
  • React
  • Vite
  • TypeScript
  • Tailwind CSS
  • Stripe
  • AWS Amplify
  • AWS Lightsail
  • Docker

What we built

  • Vessel register of 94,412 hulls with tonnage search by size band, deadweight and position, loaded from a 2,152,729 row purchased CSV in 15.8 seconds
  • Positions, cargoes, fixtures, laytime and demurrage as separate records, because a live position, a position list and a port line-up are three different things
  • Multi-tenant Postgres 16: one shared reference schema holding the world fleet and 11,425 ports, marinas excluded because a marina is not a berth a Panamax calls at, a private schema per desk for its fixture book, and a control schema for accounts and audit that survives a desk being dropped
  • Five subscription tiers in the database rather than in code, from a free browse tier through graduated per-seat pricing to a quoted Enterprise, with an offline provider for desks that are invoiced by hand
  • Feature and quota entitlements resolved by a SQL function, so an operator reading the database during an incident gets the same answer the API gives
  • Sessions split into a 15 minute access token held in memory and a rotating HttpOnly refresh cookie, with reuse detection that kills the whole token family
  • Owner outreach against 30,718 companies, 11,234 of them with an email on file, plus a React desk client covering the dashboard, tonnage search, fixtures, laytime and the map

Decisions worth explaining

COPY and set-based SQL, never an ORM under ingestion

The predecessor imported the register row at a time inside per-row savepoints: 2.15 million rows at roughly 55 rows a second, which is 10.5 hours. The same file now lands in 15.8 seconds because bulk work goes through COPY and set-based SQL. That single choice is the difference between a register somebody reloads when the vendor ships an update and one nobody dares touch.

One copy of the world fleet, private books per desk

The fleet, ports, companies and AIS fixes live in a shared reference schema, enriched once. Each desk gets its own schema for fixtures, cargoes and contacts. A database per tenant would duplicate around 350 MB of world fleet per customer and make enrichment cost scale with customer count. A single schema with row-level security would ask a broker to trust a WHERE clause with their fixture book while a rival sits in the same table. The tenant is never read from a header, a subdomain or a token claim, it is looked up from the account id inside a signed token.

An idle product should cost nothing

The AIS refresher used to sweep all 94,412 hulls every five minutes, identical whether anybody had the map open or not. On one August day that spent 4,437,359 vendor credits against a 20,000 a month plan and every endpoint answered 402 by morning. There is no scheduled refresh any more: a position is bought when a person asks about that specific ship, gated four hours per hull, and the gate reads when we last ASKED rather than the age of the fix, because a dark ship whose last fix is from 2019 would otherwise be re-bought for ever. A timer costs money whether or not anybody is looking.

Report the blind spot instead of hiding it

19,220 hulls carry a valid IMO number but no deadweight on file, so a tonnage search cannot judge them either way. A filter that silently drops what it cannot judge gives a confident wrong answer, so every search returns an excluded count and the interface prints it. The same instinct runs through the schema: IMO check digits are a database CHECK rather than a convention, MMSI is never treated as identity because it is reassigned on a change of flag, and money is numeric all the way to the browser because rounding drift on commission and demurrage is real money leaving the desk.

Yours could be next.