Geotally
A multi-engine tracker that measures how brands appear in AI assistants' answers.

Geotally runs prompts across six answer engines and records which brands get mentioned and cited. It is at geotally.ai, with the application at app.geotally.ai.
What follows is how it is put together. The case for the product is on its own site.
The constraint
Six engines, and none of them is ours.
ChatGPT, Claude, Gemini, Perplexity, Google AI Overviews and Copilot each fail
in a different way, and most of the failures are not code. Gemini started
returning 429s at roughly 30 a day; the message body said the prepayment credits
had run out. Claude rejected user_location for Nigeria with "Country code NG
is not supported". SerpAPI, which backs the AI Overviews and Copilot adapters,
returned HTTP 400 for every market variant of Egypt, the UAE and Saudi Arabia,
and then began failing a third of all calls because the shared account had
burned its 1,000 monthly searches.
So the orchestrator has to treat a vendor outage as normal weather. Every
fan-out runs through allSettled, a rate-limit error pauses only that engine's
bucket, and an unsupported-country error downgrades the request and retries
rather than failing the run.
The stack, and why
Next.js 16 on Vercel for the application, Drizzle over Postgres, and a pg-boss worker on Railway alongside the database.
The Vercel functions are pinned to pdx1. That looks like fussiness until you
remember the Postgres instance is in us-west2. On the default region the app
picked up roughly 70ms of cross-country latency on every query, which is how a
dashboard ends up feeling slow while every individual query looks fine in the
logs. Region pinning is now an invariant in the repo, not a preference.
Three decisions
One runs row is one engine call, not one prompt-run. Six rows make a
prompt-run, and a run_group_id groups a fan-out per country. Quota is counted
as COUNT(DISTINCT run_group_id), not as row count. This matters more than it
sounds. Cost per engine call is about $0.025; cost per full six-engine
prompt-run is about $0.145. Multiplying row count by a per-prompt-run price
inflates every figure roughly sixfold, and two separate numbers in our own code
comments had done exactly that before anyone measured against the table. The
grouping key also has to be per country, or a brand tracked in two markets
consumes one quota unit instead of two.
Customers choose engines per brand, and we do not scrub any. enabled_engines
is a JSONB column where NULL means all six and a non-null value is a validated
subset with a minimum of one. Filtering happens at a single choke point in the
fan-out. The trade-off is that fewer engines lowers our cost of goods at
identical revenue, since quota is counted in prompt-runs, so the incentive
points the wrong way if you look at it from a spreadsheet. We shipped it anyway
because guessing which engine matters for a given brand is not our call.
A cost breaker sits in front of the worker. A per-run cap projects cost before dispatch and resolves an over-cap run without retries, and an account cap warns at 80 percent and stops at 100. The trade-off is that a legitimate large run can trip a breaker, so the caps have to be maintained per tier rather than set once.
Notifications send once, and prove it. Cost-cap warnings, snapshot-ready
alerts and competitor-movement alerts all go through one helper that claims a
slot in a notification_log table before it sends, on a unique constraint over
account, type and a dedup key. If the send fails the slot is released. The
helper never throws into the worker or the gating path, because an email
failure must not be able to stop a run. The trade-off is a table that grows and
needs pruning, which is cheaper than a customer getting the same 80-percent
warning nine times.
What broke
accounts.monthly_cost_cap_usd is nullable with no default. The signup path
wrote 0.00 for the free tier so a free account could not spend. The resolver
fell back to the tier default only on NULL, and a literal zero is a valid hard
budget, so it was honoured.
Stripe provisioning never touched that column. Every free-to-paid upgrade therefore kept a zero budget, and the breaker blocked every run. The customer paid and silently got nothing.
The fix was one line in the provisioning update and a regression test. The
lesson is more useful than the fix: on a nullable column, "unset" and
"deliberately zero" are different states, and only NULL distinguishes them. Any
new tier or limit column added to accounts now has to be reset in
provisioning too, because provisioning is the only place a free account becomes
a paid one.
A smaller one from the same period. Engine API keys were set on Vercel but one of them was never added to the Railway worker, so every scheduled Perplexity run failed for two weeks at about 13 a day. Two runtimes, two environments, one audit list.
Where it is now
Live and taking payment through Stripe, with a Ghana-registered and a US-registered entity behind it depending on the contract. An iOS companion app shipped to the App Store in September 2026. The free scan on the marketing site runs against one engine with a monthly spend cap, which is the same breaker pattern pointed at lead generation.
Built by BestDid
We built and run Geotally. The product itself, including what it measures and what it costs, is at geotally.ai.
Nearby: the Vendwa case study covers a tool-use agent on a messaging API, and the XWipe case study covers building against a rate-limited third-party API. Everything else is on our work page. To talk about a build, see services.