Case study
Whale Report
Lead backend engineer on Ocean Wise's whale-sighting platform, which turns sightings into real-time alerts for mariners, and on its versioned public partner API.
- Client
- Ocean Wise
- Dates
- 2025 to now
- Live
- app.ocean.org
- merged PRs as lead on the backend
- 430
- merged PRs across the Ocean Wise org
- 529
- endpoints in the v2 partner API
- 14
- NestJS
- TypeScript
- Prisma
- MySQL
- Redis
- BullMQ
- Azure
- OpenAPI
The problem
Ships strike whales. Whale Report, Ocean Wise’s platform, collects whale sightings from the public through its app and from partners such as hydrophone networks and researchers, and turns them into real-time alerts for mariners, so a ship can slow down or steer clear.
The backend behind it has to do three hard things at once. Partners need a stable, documented API they can build against. Every sighting carries its own sharing rules, from real-time safety use to open data. And sightings go stale: a whale heard an hour ago is a warning, one heard last week is history.
My role
Lead backend engineer since September 2025, on the NestJS service behind the apps and the partner API, working with Ocean Wise’s own engineers. I wrote nearly all of Partner API V2, and I set up a shared AI-assisted workflow for the whole engineering team.
What I built
- Partner API V2. A versioned public contract under
/v2, with sightings, report submission, alerts and reference data. The OpenAPI specification is generated from the code itself and linted, and whenever it changes it’s mirrored to Ocean Wise’s API documentation, so the docs follow the server rather than being written by hand. - Sharing scopes. Each record carries a ranked sharing level, and a partner’s key decides what it can read. A partner states the level of what it writes, rather than inheriting its read level. When reports are grouped into one sighting, the sighting’s scope can only narrow, checked and written in one atomic statement so two reports can’t race past the check.
- Expiry. A partner can give a sighting a lifetime in hours. Every read filters out what has expired, and the expiry also tells other systems when to purge their copies.
- Paging and retries. Cursor tokens are opaque and fingerprint the filters and the caller’s scope, with the time window pinned, so paging through results can’t drift or change scope halfway. Report submission is idempotent, so a partner can safely retry.
- Geospatial correctness. MySQL reads coordinates latitude first and GeoJSON longitude first. I moved the reads and the alert write paths onto one explicit axis order and wrote up the conventions, and built the splitting of alert areas that cross the 180° meridian into multipolygons, rolled out behind a feature flag and now live.
- Alerts that follow the right phone. Location history kept per device and pruned after seven days, so one account on several handsets no longer bounces alerts between unrelated positions. Rolled out behind a feature flag and now live.
- One feature-flag system for every app. Flags and runtime variables live in Azure App Configuration. The backend reads them directly, and one endpoint serves each app, the public app and the portal alike, its own flags, variables and minimum supported version. A feature rolls out across the backend and the apps from one place, without an app release.
- Tighter access. Stricter authorisation and fixed admin role checks, with personal data trimmed from report reads.
- A contract-test tier in CI. It boots the real HTTP pipeline, from guards through validation, against a throwaway database for the partner endpoints. When I found it running in no CI workflow at all, with failures nobody had seen, I fixed it and made every pull request and release depend on it.
- AI-assisted development for the team. A shared agent repository: Claude Code workflows as a private plugin teammates install from it, matching Cursor rules a script copies into each project, and a check that flags any workflow missing from either side.
Architecture
The backend is a NestJS service in a container on Azure App Service, with MySQL and Redis, all in Terraform. The public app and the app.ocean.org portal call it with Auth0 tokens; partners call it with API keys. Alerts go out through a queue on Redis to push notifications, SMS and email.
The hard part
How long a grouped sighting should live
A hydrophone buoy hears the same whale at 12:00, 12:30 and 13:00, each report with a one-hour lifetime. Those reports are grouped into one sighting. It should stay visible until 14:00. It dropped at 13:00, while two newer detections were still live.
The code took the shortest lifetime in the group, a rule carried over from scopes, where “only ever narrow” is right. For a lifetime it’s backwards: a group is alive as long as its newest member. So it should take the longest.
The obvious fix did nothing. Each report’s expiry was measured from the sighting’s start, so every report with a one-hour lifetime got the same expiry, and the longest of equal values is the same value. The expiry had to be measured from each report’s own time. And since an empty expiry means “never”, a report with no lifetime now keeps its group alive, which we confirmed was the intended behaviour before building it.
Lengthening the group’s life broke an assumption nobody had written down: that a report inside a live sighting is itself live. With the window longer, an expired report could keep feeding details into a live sighting. So every query that joins reports got the expiry filter too.
Review found one more path: the expiry update sat inside the scope check’s conditional write, so whenever a scope change was blocked, the expiry update was silently skipped too. It became its own statement. The new regression test uses two different scopes, because every earlier test had used one, which is exactly why that path had never shown up.
A second phase carried the same window down to alerts and their notifications, which had no expiry of their own.
Outcome
- Partner API V2 is live and documented, its specification generated from the code and its endpoints covered by contract tests on every pull request and release.
- Sightings, alerts and notifications now share one visibility window, so a whale that keeps being heard keeps being reported.
- Tighter authorisation and admin checks, and less personal data in report reads.
- 430 merged pull requests as lead on the backend, 529 across Ocean Wise, and a shared AI workflow for the whole team.