Project · Building

Pumply

A crowdsourced fuel finder for Ghana. Nearby stations, recent prices and availability — built around the idea that the useful part is not the map, but trustworthy information.

Pumply started from a very ordinary problem. If you are driving and need fuel, you usually want to know three things before you get to a station: is fuel available, how much does it cost, and how recent is that information? In Ghana, that information is scattered. A station may post a new price on social media, another may not post anything, and by the time a driver finds out the price, they may already be standing at the pump.

The first version of the idea sounded simple: put fuel stations on a map and show their prices. The more I built it, the more I realised the map was the easy part. The actual problem is building a system that can take messy information from different sources, decide what can be trusted, keep the history, and still return something quickly enough that a driver can open the app and understand the situation in a few seconds.

The real product is information

Pumply shows petrol, diesel, LPG and EV stations around the user, together with the latest price and availability information we have for each station. Drivers can report prices, correct station information and add places that are missing. Brand-published prices can also act as a fallback trust layer when community information is thin.

Pumply map showing fuel stations and current petrol and diesel prices around Accra
The public map mixes station coverage with the latest price information Pumply has for each location.

That sounds like a directory, but I do not think about it as a directory anymore. A directory mainly answers where is the station? Pumply also has to answer what do we currently believe about this station, why do we believe it, and how old is that belief?

Those are very different questions.

A station can exist at the correct coordinates but have the wrong brand. A price can be perfectly accurate and still be too old to be useful. Two records can point to one physical forecourt. An OpenStreetMap record can give me useful coverage while still having an old name. A fuel company can rebrand a station without the station physically moving. Once I started dealing with those cases, the product became less about drawing pins and more about maintaining a changing model of the real world.

The map is a window into a geospatial database

The map is rendered with MapLibre GL. I use a MapTiler vector style in production, but MapLibre is the rendering engine. I chose that setup because I wanted control over the map rather than treating the map as a collection of DOM markers sitting on top of a canvas.

The station markers live in a single GeoJSON source inside MapLibre. That source is clustered, and the layers that render clusters, station markers and selected states are created once when the map loads. When the data changes, I update the source with setData(); I do not tear down the layers or call setStyle() again.

That distinction matters more than it sounds. Recreating a style or rebuilding hundreds of markers during interaction forces the map to repeat expensive work. Pumply keeps one map instance alive, one stable source/layer setup, and changes the data inside it.

Pumply map showing clustered fuel stations at a wider zoom level
At wider zoom levels, nearby stations collapse into clusters. Individual price markers appear as the map gets closer.

I also try not to make React react to every pixel of movement. During a pan or zoom, Pumply throttles the moving viewport work. The expensive data request happens after movement settles, rather than on every animation frame. The same principle applies to the map itself: no globe projection on low-end devices, no repeated world copies, controlled tile caching, and no tile fade that leaves a blank flash between zoom levels.

The map is also deliberately bounded around Africa. That is partly a product decision and partly a rendering decision. Pumply is being built for this region, so letting somebody zoom out until Ghana becomes a tiny dot inside an unnecessary world map does not help the experience.

Supabase is the API layer; PostgreSQL is the source of truth

Pumply uses Supabase, but I think it is more accurate to say that the core system is PostgreSQL with Supabase around it.

The database holds stations, pending stations, reports, brand pricing data, correction history and the other pieces the app needs. Supabase gives me the client API, authentication, RPC calls, Row Level Security and Edge Functions, but I try not to put important product rules only in the browser.

That is a rule I became stricter about as the product grew: if a rule protects the truth of the data, the backend should know about it too.

For example, the browser can tell somebody they are too far away to report a price, but that is only a user-interface check. A modified client could ignore it. So the backend performs the distance check again before accepting the report. The same idea applies to duplicate stations, quotas and price validation.

For Add Station, I now route the submission through a Supabase Edge Function rather than trusting a raw insert from whatever client is calling the database. The function validates the payload, makes sure the coordinates are inside Ghana, validates fuel types and optional prices, applies rate limits, asks PostgreSQL whether a conflicting station already exists, and only then inserts a pending station for review.

The browser is responsible for a good experience. The backend is responsible for not trusting the browser.

PostGIS is doing the spatial work

The part of PostgreSQL that makes Pumply much more useful is PostGIS.

A station does not only have lat and lng columns. It also has a geographic representation that PostGIS can reason about. Coordinates are stored using the normal WGS84 coordinate system, and PostGIS can treat them as geography on the earth rather than two unrelated numbers.

That lets the database answer questions like:

I could calculate some of those distances in JavaScript. In fact, the client does calculate distance for immediate feedback. But the important version happens in the database. PostGIS gives the backend the same geographic rules regardless of which client sends the request.

For the public map, the client sends the visible bounds to an RPC called fetch_stations_bounds. PostgreSQL builds a geographic envelope from those bounds and uses PostGIS to select stations inside it. It then joins in the latest relevant report data before returning the rows.

That means the phone does not need the entire Pumply database just because the map exists. It asks for the part of the world it can currently see.

What actually happens when the map moves

This is one of those parts that looks trivial in the interface but has quite a lot underneath it.

When the user finishes moving the map, Pumply takes the new north, south, east and west bounds and calls the database. The database finds the stations in that rectangle, then builds the useful station state around them.

For each station it can return the latest petrol, diesel and LPG report, the last time anything was reported, an EV state where relevant, and a recent station status. On the client, those rows are normalized into station objects, converted into GeoJSON features and pushed into MapLibre.

The important part is that price and availability do not have the same lifetime.

A petrol price does not automatically become false because 24 hours passed. If the last known price was GH₵x and nobody has reported a new one, deleting it from the map would replace an old piece of information with no information. Pumply therefore keeps the latest price until another report or a trusted update replaces it, while still keeping the timestamp so the interface can communicate age.

Availability is different. “This station is open” is much more time-sensitive. The database currently treats recent open/closed reports inside a much shorter window; outside that, the status becomes uncertain rather than pretending an old availability report is still current.

That separation was important for me. Freshness is not one global number. It depends on what kind of fact you are storing.

A price report has to prove more than a price

Crowdsourcing only works if contributing is easy, but accepting every contribution would make the product useless. So a price report goes through several gates.

Pumply report screen for petrol, diesel and current fuel availability
A report is structured around specific fuels, prices and availability rather than a free-form submission.

Pumply first needs a usable location fix. If the app already has a recent enough high-quality location, it can reuse it; otherwise it requests a fresh high-accuracy fix. For reporting, the current client rejects a location whose reported accuracy is too weak, and the user must be within 200 metres of the station.

The client checks that first because there is no point making somebody fill out a report and only then telling them they are too far away. But when the report reaches PostgreSQL, the backend calculates the distance again with PostGIS. If it is over 200 metres, the report is rejected there too.

Pumply blocking a price update because the reporter is too far from the station
The proximity gate in the real reporting flow. Remote updates are blocked before submission.

The 200-metre rule is not there because 200 is a magical number. It is there because price data is only useful when I can trust that the person reporting it is plausibly at the forecourt. Without a proximity gate, one person sitting at home could update ten or fifteen stations they have never visited. Once a few bad reports enter the system, the map can still look polished while the information underneath it has become unreliable.

Fuel pricing in Ghana is also unusually structured. The NPA’s 2024 pricing guidelines define two pricing windows each month — 1st–15th and 16th–end of month — and require OMCs/LPGMCs to notify the NPA of revised ex-pump prices. The same guidelines require the advertised billboard price at a retail outlet to match the dispensing-pump price, and say the NPA conducts price-monitoring exercises at outlets. That makes a fuel price more than casual user-generated content; it is a regulated commercial fact that people make purchasing decisions around.

There is also a quota. Pumply currently allows up to three price-update actions in 24 hours for the same user/device identity. I deliberately count a submission action rather than blindly counting database rows. If one user action reports more than one fuel type, those rows can share a submission-attempt ID so one tap does not accidentally consume the entire daily quota.

That is a small implementation detail, but it is the kind of detail that matters when abuse prevention meets normal product behaviour. Rate limiting should stop spam without punishing the legitimate workflow the interface itself created.

The same reporting rules also have to work for stations that originally came from seeded map data. I do not want “seeded station” to mean “special station that bypasses the normal trust rules.” Once a station participates in reporting, it has to go through the same backend enforcement path as the rest of the system.

Adding a station needs a different set of rules

Adding a station is harder than reporting a price because a bad price report can be replaced; a bad station can pollute the map itself.

Pumply Add Station screen with map pin placement, station details and fuel types
Adding a station starts with its location, then its identity and available fuels.

The app therefore asks two separate geographic questions.

The first is: is the person actually close enough to the place they are trying to add? The current product rule is 500 metres for petrol/diesel stations and 1 kilometre for LPG or EV additions. That is a Pumply data-integrity rule, not a claim that every one of those numbers is a current legal siting distance. Its purpose is simple: somebody should not be sitting several neighbourhoods away and creating infrastructure they cannot actually see.

The second question is different: does Pumply already know about a station of this kind nearby?

I do not use one duplicate radius for every type of infrastructure. The current conflict rules are:

Those numbers have regulatory history behind them, but the history matters. A 2024 geospatial study of fuel and gas stations in Kumasi evaluated stations against the older EPA and Town & Country Planning spacing standards of 500 m between successive fuel stations and 1,000 m between successive LPG stations. The researchers found that 97 of 140 fuel stations — 69% — failed the 500 m criterion, while 4 of 11 gas stations — 36% — failed the 1 km criterion. Only 18% of the stations in the study met all of the EPA requirements they tested.

That finding created an important constraint for Pumply: the database has to describe the city that actually exists, not the city the spacing rule says should exist. If two legitimate stations in Kumasi are 200 metres apart, I should not hide one because a historic planning standard says they ought to be farther apart. Existing real stations are seeded, audited and preserved. The duplicate rule is mainly a guard on new submissions, where it stops a user from creating a second record for a station Pumply already knows about or from casually adding questionable infrastructure into a dense area.

Ghana’s planning standards have also changed. The revised LUSPA Zoning Guidelines and Planning Standards published in 2025 use 300 m as the spacing requirement between petroleum product retail outlets on the same side of a road, with a similar 300 m staggered requirement across a single carriage road and a concentration consideration of no more than three outlets within a 1 km stretch. They specify 500 m between a petroleum retail outlet and an LPG refilling plant. The LPG planning table in the same document also uses 300 m between LPG stations and 500 m between an LPG plant and a service station.

So Pumply’s 500 m petrol/diesel and 1 km LPG duplicate radii should be read as deliberately conservative product rules informed by the older Ghanaian siting framework and the messy station landscape we found — not as a statement that those are today’s statutory distances. The distinction matters. I would rather block a questionable new duplicate and review it than quietly let the database split one physical station into two identities.

EVs are even more explicit: I do not currently have a Ghanaian regulatory spacing rule that justifies the 50 m EV duplicate radius. That number is a practical identity heuristic — close enough to catch two records that are probably the same charging location without pretending EV infrastructure behaves like a petrol or LPG forecourt. If the regulatory picture becomes clearer, that rule can change.

The important part is the fuel-class overlap. A petrol station and an LPG facility being near each other does not automatically mean they are duplicate records; the revised planning standard itself treats the separation between those different facility types differently. Two EV charging points can also exist much closer together than two traditional fuel stations. So “there is another coordinate nearby” is not enough to call something a duplicate.

The client can warn early, but the backend checks again. The Edge Function asks a Postgres RPC for nearby candidates, applies the fuel-specific conflict rule, and refuses the insert when a blocking conflict exists. The database also has duplicate protection of its own, so the integrity of the station table does not depend on one React screen behaving correctly forever.

New stations go into a pending state. Approval is a truth decision, not just a CRUD operation. If an existing station is being renamed or rebranded, I would rather preserve the existing station identity and history than create a shiny new UUID and leave the old truth behind.

Seed data gave Pumply coverage, not truth

I started with thousands of fuel-station records from OpenStreetMap because an empty crowdsourced map has a chicken-and-egg problem: nobody wants to contribute to a product that looks empty, but the product stays empty if nobody contributes.

Seeding solved coverage. It did not solve correctness.

That distinction has become one of the most important ideas in Pumply. An OSM point is evidence that something was mapped there at some time. It is not a promise that the brand, name, fuel types and current identity are all correct today.

This is why I have spent so much time on station truth: comparing near-duplicate records, checking rebrands, fixing generic names, preserving existing UUIDs when the real station changes brand, and separating “I know this is wrong” from “I do not have enough evidence yet.”

The database needs room for uncertainty. Otherwise an admin tool turns uncertainty into fake confidence.

Brand prices and community prices are different signals

A brand publishing its official prices is useful, but it answers a different question from somebody standing at a station and reporting what they see.

Brand data can tell Pumply what a company says its price is. Community data can tell Pumply what is actually being observed at a particular station. Sometimes those signals agree. Sometimes one is newer. Sometimes one is missing.

I keep those ideas separate instead of flattening everything into one magical “correct price.” Brand prices are useful as a fallback and trust layer; station reports preserve their own history and timestamps.

The pricing-window model is not something I invented for the admin. NPA’s pricing guidelines define two review windows per month, and its downstream statistics are published using the same 1st and 16th pricing-window structure. That is why Pumply keeps window history instead of treating every new brand price as an overwrite of the previous number.

That history matters because debugging a price system is almost impossible if every update destroys the evidence of what came before. It also lets the admin answer a more useful question than “what is the price?”: what changed this window, which source changed it, and what was Pumply showing before?

The admin is part of the product architecture

A crowdsourced product needs more than a public interface.

Behind Pumply there are workflows for pending stations, corrections, live stations, brand prices, price history, station activity and records that need attention. I originally thought of admin tooling as something secondary — the boring interface you build after the “real” product works. Pumply changed my mind.

If the public product depends on data quality, then the tools used to inspect and repair that data are part of the product architecture.

I also keep important administrative changes auditable. When a station is renamed, rebranded, merged, approved or otherwise changed, I want to be able to understand what happened later. This becomes more important as the database grows because “just fix the row” stops being a safe operating model once other data and history depend on that row.

The system is designed around disagreement

A lot of Pumply can be described as deciding what to do when two things disagree.

The user location and station location disagree: calculate the distance.

Two station records disagree about whether they are the same place: compare geography, fuel class, identity and evidence.

A brand price and a community price disagree: keep the signals and their timestamps instead of pretending the conflict never existed.

An old map name and the physical sign at the station disagree: update the identity while preserving the station’s history.

That is why I think the deeper problem in Pumply is not maps. It is data reconciliation under uncertainty.

The map is just where the result becomes visible.

What I have learned from building it

Pumply has made me much more suspicious of product ideas that sound like “just show X on a map.” A map can make a messy system look deceptively simple because all the complexity collapses into a marker.

Under one Pumply marker there can be a station identity, geographic coordinates, fuel classes, a current brand, an old brand, multiple price reports, timestamps, availability reports, official brand data, corrections, opening hours and an audit trail.

The user should not have to think about most of that. That is the point of the product. Complexity belongs underneath the interface, not inside the user’s head.

But as the person building it, I need to understand that complexity well enough to decide which parts can be simplified and which parts cannot.

That is probably the biggest thing Pumply has taught me so far: simple products are often built on systems that take complexity seriously.

Where it is now

Pumply is still being built. The web app is working, the Android version has been through testing, and I am continuing to improve station truth, reporting, pricing, performance and the admin system behind them. The architecture has already changed several times as I have found places where an early shortcut was not strong enough.

I am starting with Ghana, but the underlying problem is not uniquely Ghanaian. Wherever location data, official data and real-world observations disagree, the same questions appear: what is the source, how recent is it, what can contradict it, and how much confidence should the product show?

I do not know every form Pumply will take yet. I know the problem well enough to keep following it.

References

Land Use and Spatial Planning Authority — Revised Zoning Guidelines and Planning Standards (2025) ↗

Richard Boadu Antwi, Stephen Okai, Jonathan Quaye-Ballard & Eren Erman Ozguven — Geospatial Analysis of Fuel and Gas Station Distribution: Evaluating the Compliance and Impact of Station Siting on Public Health and Safety in Kumasi, Ghana ↗

National Petroleum Authority — Petroleum Products Pricing Guidelines 2024 ↗

National Petroleum Authority — Petroleum Downstream Statistical Bulletin, Q4 2025 ↗