Geotally

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

A brand visibility dashboard open on a laptop.

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.

a `prompt` box at left fans out to six labelled engine adapters stacked vertically, each with a small `retry / downgrade` glyph.

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.

Visit Geotally