25 July 2026 ·

Three Things Sydney's Ferry Data Taught Me the Hard Way

Ferry Alerts runs on the same code as Train Alerts. The whole ferries build came out of adding one entry to a config module that says which vehicles to watch, what the brand's called and whether a stop is a station or a wharf. I'd spent weeks assuming that refactor was the big job. It took an afternoon.

The data was the job. I'd read the Transport for NSW Trip Planner spec end to end, written down what I expected the ferry feed to return and built against my own notes. Three of those expectations were wrong and I only found out when I finally pointed the thing at a real wharf with a real API key. One of them had already left a filter in the code that quietly did nothing at all. So here's what the live feed actually hands you, in case you're building on it too.

Circular Quay is one stop, not five

Start with the picker. You send "Circular Quay" to the stop_finder endpoint expecting a list with one entry per wharf. Back comes a single stop. Id 200020, named "Circular Quay Station", carrying a modes array of [1, 4, 5, 9]. That's heavy rail, light rail, bus and ferry all hanging off one parent stop. Transport for NSW models the Quay as one place rather than one stop per wharf, which honestly matches how anyone standing there thinks about it.

That's perfectly fine for a picker. Much less fine for the departures call. Ask departure_mon for upcoming events at that stop and you get a capped number back. Mine asks for 80 and all four modes compete for the same 80 slots. At Circular Quay only 25 of those 80 events were ferries. The other 55 were buses and light rail and heavy rail services my site's got no interest in. Worse, the 25 ferries I did get only reached 58 minutes into the future, because the slots ran out long before the timetable did.

That horizon is the number I care about. Each alert carries the two departures sitting behind yours. It also needs enough of the timetable in view to schedule its own next check. An hour of visibility on a route that runs every half hour isn't much of a runway.

The fix was to stop trimming the response after the fact and ask the API to leave the other modes out in the first place. departure_mon accepts an exclusion set, excludedMeans plus one exclMOT flag per class, so the query now names every class except the one this site is built for. At Circular Quay that moved ferries from 25 of 80 events to 80 of 80. The horizon went from 58 minutes out to 190.

The part that stings is that I'd looked at that parameter early and talked myself out of it. My reasoning went like this: excluding modes upstream doesn't reduce the number of calls I make, so I may as well filter on product class in my own code and keep the query simple. That is true about the call count and it completely misses that the limit is a shared budget. Filtering my side spends the budget first and then throws most of it away. The same change helped Train Alerts too, where Town Hall went from 31 of 80 events and a 49 minute horizon to 80 of 80 and 108 minutes.

Ferry headsigns are not wharf names

This one's squarely my fault and I want to walk through it properly, because it broke a feature and nothing failed loudly enough to tell me.

When I wrote the ferry plan up I predicted that ferry headsigns should be wharf names. In the same breath I wrote that this would make the sticky direction filter work nicely on the water. It felt obviously right! The picker offers you Manly Wharf, the boats go to Manly Wharf, so surely the headsign reads Manly Wharf. I never made the one call that would've checked.

Here are the real destinations coming back out of Circular Quay: Manly, Taronga Zoo, Pyrmont Bay, Watsons Bay, Sydney Olympic Park, Cockatoo Island, Double Bay, Mosman Bay, Darling Harbour, Parramatta and Neutral Bay. Not one of them says Wharf. The only exception in the entire response was a single service reading "Manly Wharf, Manly", a wharf name and a suburb glued together. So the F1 announces itself as Manly while the picker hands my filter the string "Manly Wharf".

Here's what that cost me. My normaliser stripped a trailing "Station" off both sides before comparing, which was all the cleanup trains ever needed. On ferries it left "manly wharf" sitting next to "manly" and the comparison failed on every single poll. The direction filter's got a deliberate fail-open branch, built after an earlier bug: until a direction has been proven to match something real, an alert will not go quiet, because a filter that's never matched anything is more likely broken than correctly quiet. Mine never matched anything, so it never got proven, so every ferry alert sat in that forgiving branch for its whole life. Set an alert for Manly out of Circular Quay and you'd have heard about Taronga Zoo, Watsons Bay and Parramatta as well.

Nothing in the logs said the direction filter had matched nothing. No error, no empty result to stare at, no failing test. A wrong prediction in a planning doc turned into a feature that had never once worked, on a code path built to be forgiving about exactly that. Read the follow-up list on that same plan and the item's right there, unticked: make one live departure call at a wharf and confirm ferry headsigns are wharf names. I built on the answer before I'd gone and got it.

The repair's tiny. The normaliser now strips Station, Wharf and Light Rail off both sides along with any punctuation, so "Manly Wharf" and "Manly" both come out as "manly" while "Manly Wharf, Manly" lands as "manly manly". The comparison isn't an equality check either. It asks whether the normalised headsign contains the normalised destination, which is what lets that doubled up one match as well.

Everything with a hull is product class 9

The third one's still open, so I'd rather show you where it sits than pretend I've solved it.

Every departure in this API carries a transportation.product.class. Ferries are 9 and that number is what the whole site's built on. What I assumed was that the private operators would arrive under a class of their own. They do not. Sydney Ferries, the Newcastle ferries, private operators and temporary services all come through as class 9. Who actually runs the boat is carried separately, over in iconId.

So ask for Manly out of Circular Quay and you match Manly Fast Ferry and Captain Cook right alongside the Sydney Ferries F1. The catch waits for you at the gate, because some of those services want a ticket bought from the operator instead of a tap of your Opal card. That split runs service by service rather than cleanly down company lines, which is exactly why the operator's the field worth reading.

I haven't decided what to do about it yet. Narrowing to iconId 10 for Sydney Ferries only is a couple of lines and it'd hide departures that plenty of people happily catch. Carrying the operator through and labelling it on every alert is more honest and quite a bit more work. Until I pick one it's called out plainly in the ferry alerts guide and the code keeps alerting on the lot. If you take one thing from this section, it's that product class tells you what kind of vehicle it is and nothing whatsoever about who runs it or what gets you aboard.

What the multi-mode refactor actually touched

Now it's done I can give you real figures instead of an estimate. The new code, all of it: a mode config module that resolves an incoming Host header to a mode, a server rendered homepage built from that config, per-host robots and sitemap files and two database migrations that replaced the old hand-applied schema and put a mode on every subscription. After that it was small parameterised diffs across the files I already had: the API client for product class and the stop filter, the subscription for carrying its mode, the notifier for the right sender and origin, the router for reading the mode off the request and the page chrome for the copy that shifts between modes.

What I did not touch is the more interesting list. The alarm loop, the schedule logic, the web push stack and the whole delivery pipeline are all exactly as they were. None of that code has an opinion about whether the thing you're waiting for floats! Adding a third mode is now mostly one more entry in that config, plus whatever API budget work the new mode needs.

Make the call before you write the logic

Three wrong guesses and the pattern behind them is identical every time. The spec was accurate about everything it covered. Everything I got wrong lived in a gap the spec didn't spell out. I filled every one of those gaps with whatever seemed obvious to me from the outside and then went ahead and built on it.

A single authenticated call at Circular Quay would've caught all three in about ten minutes. Instead I got a filter that never worked, a horizon a third of the length it should've been and a ticketing question I'm still chewing on.

So that's the rule I'm keeping now and it's a boring one. Nothing gets built on the shape of somebody else's response until I've made the call and read the whole thing back. The response was free and sitting right there the entire time. Ferry Alerts runs on the corrected version of all three, which is the version I'd have shipped in the first place if I had only gone and looked. Enjoy your boat!

← All posts · Set up a free ferry alert