Case Study
Corridor: Making Remittance Fees Actually Comparable
Live product: corridor-red.vercel.app ↗
Source: github.com/aryavariaa/Corridor ↗
Role: Solo builder (product, data sourcing, engineering)
Status: Live, actively maintained
The problem
Sending money internationally means picking a provider, and every one of them quotes a different “exchange rate” and a different fee, in a format designed to make direct comparison hard. A provider can advertise “no fee” while marking up the exchange rate 4% below the real mid-market rate. Nobody actually knows what they're paying until the money lands.
I built a site called Corridor to answer one question honestly: for a given amount, currency pair, and provider, what does it actually cost to send this money, compared to the real mid-market rate?
Scope decisions
The first version tried to cover every corridor and every provider. That was a mistake: it meant guessing at numbers I couldn't verify. I narrowed to a 5-currency send-side framework (USD, GBP, EUR, AUD, CAD as the sending currency) and rebuilt the site around one page per corridor: 66 of them today, across 11 currencies in total (the five I send from, plus six I only send to), so each corridor page is backed by verified, deterministic data instead of spreading verification effort too thin. The homepage started as a browsable directory of every corridor. It's now a short compare form: pick where you're sending from and to, optionally an amount, and go.
The real challenge: data you can trust
The hard part of Corridor was never the UI. It was making sure the numbers weren't lying.
Automating what's actually verifiable, not what's convenient. The mid-market exchange rate comes from Frankfurter, a free, no-key API aggregating official central bank rates, reliable enough to automate fully. For provider rates, I initially considered scraping or estimating, but landed on the Wise Comparison API, a real, documented, unauthenticated endpoint. Testing it directly (not trusting the docs alone) showed it only ever returns data for Wise, PayPal, and Western Union, regardless of amount or corridor. No other provider I looked at is covered by an API I could verify.
The tempting move here is to fill those gaps with estimated numbers so the site looks more complete. I didn't do that. For a long stretch the other providers stayed on the site as manually sourced, dated rows rather than quietly faked coverage. Then I took it a step further, and it's the decision I'm most deliberate about: Corridor now ranks only the three providers a feed backs (Wise, PayPal, Western Union), and the other seven are switched off. Their data, sourcing notes, and guard logic are untouched; they're hidden by a one-line allowlist in the code. The rule is that a provider doesn't appear unless its rates are genuinely live or refreshed daily, and I won't estimate data to pad the count. (PayPal's hand-entered rows are the one exception I keep visible, and I cover them below.) A hand-sourced number is a snapshot, and a snapshot of a rate that moves every day starts going stale the day I take it. Reliability over provider count. A comparison tool that can't be trusted is worse than no tool at all.
Catching a wrong assumption before it became a wrong fix. Even with real APIs feeding the data, a provider row can still be wrong, and one day, an entire corridor (US→Colombia) suddenly showed impossible negative cost percentages across multiple providers at once. That pattern (a whole corridor breaking at once, not one isolated row) looked like a systemic bug, and my first assumption was that it traced back to a recent switch in mid-market rate provider (from a free rate API to Frankfurter).
I was wrong, and figuring out why I was wrong was the actual work. I re-verified the fetch logic, the currency parsing, and the rate direction line by line against Frankfurter directly. No bug there. The real cause: the Colombian peso had genuinely moved about 3.2% against the dollar since the underlying provider data was last dated. A handful of thin-margin rows (the ones with the least documentation behind them to begin with) didn't have enough margin to absorb a real market move that size, so they fell below live mid-market and started reading as “impossible.”
That distinction mattered. A code bug gets a code fix. A real market move needs the underlying data re-sourced, not the logic touched. Conflating the two would have meant either shipping a code change that fixed nothing, or reflexively distrusting a rate API that was working correctly.
The bug that was actually there
The investigation did surface one genuine bug, just not the one I went looking for: the rate-fetching logic had no defense against a single bad reading, so I added a guard that rejects any fetch swinging more than 3% from the last known-good rate and serves a cached rate instead. Good instinct, but the first version of that guard had its own flaw: once a real, sustained move like COP's exceeded the threshold, it would keep comparing every future reading against the same frozen pre-move baseline forever, rejecting everything indefinitely with no way to recover short of a restart.
The fix: a rejected reading only becomes the new trusted baseline once a second, independent fetch confirms it, distinguishing “the market actually moved” from “one fetch was flaky” instead of assuming either permanently.
With the mechanism fixed, I re-sourced the specific provider rows the move had broken directly from Wise's live comparison feed, and documented, rather than quietly ignored, the handful of rows (XE, Remitly, Revolut, and PayPal on certain Colombia routes) that the API doesn't cover and still need manual, browser-verified sourcing. (Colombia has since been dropped and isn't one of today's 66 corridors.)
To keep future data drift from recurring silently, I also wrote refresh-wise-rows.mjs, a script scoped only to the three providers with verified API coverage (Wise, PayPal, Western Union), dry-run by default, that refuses to auto-apply any refreshed rate that would beat the best other provider in the same corridor and tier by more than 8% unless explicitly forced. The failure mode I designed against wasn't “the script breaks.” It was “the script silently overwrites a correct number with a bad one because an API had a bad day.”
What I'd flag to a hiring manager
This project is less “I built a website” and more “I made a series of calls about what to trust, what to verify, and what to admit I couldn't cover,” which is closer to product judgment than to engineering. Catching myself assuming a code bug when the real cause was a real market move, building a safeguard that itself needed a second pass to get right, and leaving specific provider rows explicitly documented as unverified rather than silently wrong or silently faked: each of those is a tradeoff between completeness and honesty, made explicitly rather than defaulted into.
Teaching the ranking to explain itself
The ranking told users what was cheapest. It didn't tell them why, and “why” is what makes someone trust a number enough to act on it, especially when the gap between the top two providers is a fraction of a percent.
This is a trust product, so the design constraint mattered more than the feature itself: the model can only narrate numbers it's handed. It can never introduce a figure that isn't already on the page. The prompt is built server-side from the same ranking data the UI renders (top provider's fee and markup, runner-up's same figures, the computed delta) with an explicit instruction against inventing anything, cached per corridor and tier so the model runs once per unique state rather than once per page view, and a plain-numbers fallback if the call fails or times out.
“Why [the top provider] wins: [it] is the top pick because it charges [x]% in costs versus [the runner-up]'s [y]%, meaning you'll receive [amount] more with [it] when sending [amount] to [country].”
That's the shape of the sentence, with the brackets filled from the ranking on the same page. I'm not quoting one day's figures here because they move daily. Every number that does appear is independently checkable against the ranking table beside it.
The bug the build couldn't catch
The feature passed type-checking, linting, and a full production build, deployed cleanly with the API key configured, and didn't appear on the live site. No error banner, no broken layout. Just the plain ranked list, as if it had never shipped.
The console told the real story: a React hydration mismatch, firing on every load. The server-rendered HTML didn't match what React rendered client-side, so React discarded the entire subtree and re-rendered from scratch, including the insight text that had, in fact, been computed correctly server-side. Root cause: a handful of toLocaleDateString() calls had no pinned time zone. Vercel builds under UTC; a browser renders in whatever time zone the visitor is in, so the same date could format to a different calendar day on each side, which was enough divergence to trip React's hydration check. Fix: pin the time zone explicitly on every affected call, verified by building the app under two different system time zones and confirming the output was now identical.
What I'd flag to a hiring manager: a green build and a working feature are different claims. This one passed every static check and still failed silently for every real visitor, caught only by loading the actual page and reading the console instead of trusting the pipeline.
From manual script to autonomous pipeline
refresh-wise-rows.mjs was safe but passive: it only ran when I remembered to run it. Moving it onto a schedule meant trusting a Lambda to write to production data with nobody watching, which raised the bar on the guard logic rather than lowering it.
The Lambda runs daily at 06:17 UTC via EventBridge. It runs the same refresh code as the script (the file is bundled into the Lambda at build time, so the two can't drift apart), refreshes the Wise, PayPal and Western Union rows through the Wise Comparison API, and commits directly to main (triggering the same Vercel deploy a manual commit would) only for changes that clear two guards. One is the peer-outlier check the script already had: it blocks any fetched value implying a rate that beats the best other provider in the same corridor and tier by more than 8%, since a number that looks great but is inconsistent with every comparable provider is more likely a bad read than a real edge. The other is new with the Lambda: it blocks any row whose rate would move more than 8% from its current value, because nobody is watching an unattended run. Both use an 8% threshold, but they ask different questions: one compares a row to its peers, the other to its current value. GitHub access comes from a fine-grained PAT scoped to this one repo's contents, held in Secrets Manager; IAM is a dedicated deployer policy with no broader permissions. As of October 2026 a normal run refreshes 308 rows, which is every row the API backs.
Building the earlier 20-corridor expansion on top of this, the same 8% peer-outlier check (run as a manual sweep over each batch of newly sourced rates) caught a wrong XE tier amount before it reached a commit, and a misread Remitly promo cap was caught by re-verifying against the provider's own calculator.
PayPal: what's refreshed and what's entered by hand
PayPal is the provider where I have to be most careful about what “automated” means. The comparison feed returns a PayPal quote on 22 of the 66 corridors, and on those the Lambda refreshes it daily like Wise and Western Union. On the rest (35 of the 66 corridors today: every route from USD, GBP, AUD and CAD into a euro-receiving country, plus US→India, US→Vietnam and EUR→India) the feed has no PayPal quote, and I couldn't find a public feed for PayPal's consumer rates to use instead. Those rows are entered by hand from Xoom, PayPal's money transfer service, and they show the date they were checked instead of posing as live.
Hand entry has its own trap. Xoom's public calculator tags quotes meant for first-time customers, so it's easy to capture a promotional rate by accident. A standing check now flags any hand-entered PayPal row whose implied cost is under 1% against that day's mid-market rate, and flagged rows carry an “Under review” label on the site instead of passing as clean.
From two tiers to any amount
The two preset amounts (Everyday and Large) were never arbitrary. They were the boundary of what the site could actually verify, sized to match what each provider's calculator would return without guessing. Users kept asking for an arbitrary amount anyway, which meant the feature had to preserve the one rule the whole product is built on: never show a number as fact that wasn't actually checked.
The fix split providers by how their data actually gets sourced. Wise and Western Union come from the same live API used for the two presets, so a custom amount queries it directly, server-side, at the moment it's requested, which gives a real number, not an approximation of one. PayPal does too, wherever a corridor's PayPal figure actually comes from that API; on the corridors where I source it by hand from PayPal's own Xoom page (the API doesn't return it there), a custom amount is interpolated and tagged Estimated like any other hand-sourced number. The other seven providers (switched off since, as described above) had no live source at all; interpolating between their two verified points was the only honest option, so those rows carried a visible Estimated tag rather than blending in with verified ones, and a provider with only one verified point was left out rather than guessed.
Two limits came out of building it rather than being decided up front. The clamp range was specified as 0.5x–3x the Large tier, but on a corridor where Large runs far above Everyday, that floor would have rejected the Everyday preset itself, so it's anchored to Everyday instead. And interpolation only means “between two known points” up to the Large tier; past it, it's extrapolation along the same line, which the UI now says plainly rather than implying more confidence than the math has.
The riskiest failure mode wasn't the interpolation math, it was the anomaly banner reusing language written for a different situation: when an estimated row landed on a negative reading, it showed the same explanation built for a genuine benchmark divergence, which would have said something false about why the number looked the way it did. The fix needed a small piece of code as well as new copy: passing each row's basis, verified or estimated, into the anomaly data so the message could actually tell the two situations apart, and the old, misleading wording was live for under an hour before the fix shipped.
Later still, the presets themselves went. With custom amounts working, two buttons beside a free-form field were redundant, so the field became the only input, prefilled with the old Everyday amount so a page still opens on a fully verified number. The clamp, no longer anchored to presets that didn't exist, became a flat range in the sending currency, 100 to 10,000 to start with. The five send currencies are close enough in value that one range fits every corridor. Removing the presets also surfaced a bug in the live-quote path: it decided whether PayPal was “live” by provider name, so on a hand-sourced corridor it would have used Wise's feed number instead of the hand-sourced one the rest of the page was built on. It now checks how that corridor's own rows were sourced, and the banner names only the providers that were actually live for that quote.
I later widened the range to 10 to 50,000, and the interesting part is where the two ends came from. I didn't pick a round number. I probed the live comparison feed across USD, GBP, AUD and CAD corridors in October 2026. At 1 unit it returns no quote from Wise, PayPal or Western Union on USD or AUD sends, so the floor is 10, where all three start to appear. At 50,000 Wise still quotes everywhere, but PayPal drops out above roughly 10,000, and Western Union on some corridors above roughly 25,000. A wider range was tempting; the real question was how far a quote stays truthful. The rule I settled on: if the feed answers without a provider at your amount, that provider is left out and named on the page, not stretched from its two verified amounts. Where estimates still exist, for hand-entered rows, they only run between half the lower verified amount and five times the higher one. A page that shows fewer providers at the edges is better than one that invents the missing ones.
Growing the euro-send side
Only two corridors sent from euros (to India and the Philippines), against fourteen each for USD, GBP, AUD and CAD. Ten is the ceiling: a Eurozone country can't be a euro-send destination, since that would be sending euros to euros, so euro-send can reach every non-Eurozone destination I already model (Australia, Canada, the UK, Mexico, Vietnam, Bangladesh, the US and South Africa, plus the existing India and Philippines). I added the other eight.
The refresh job only ever updates fields on rows that already exist, so it can't create a corridor. The 42 new rows had to be seeded once. I seeded them with the Lambda's own quote-parsing code against live Wise Comparison API quotes, so each row is what the daily run would have written, then ran the real refresh in dry-run mode and confirmed it picked up all 42 with no change in value. The next scheduled run refreshed them along with everything else.
A second kind of negative
The Colombia incident had already drawn the line between a bug and a market move. A newer corridor's data surfaced a third category: a reading that was neither a coding error nor a real market shift, but a benchmark that was honestly measured and still wrong for the moment.
Frankfurter, the mid-market rate source, fixes once a day. A provider queried live can be quoting against a market that's already moved since that fixing, which looks identical to a genuine cost problem unless you check. Rather than guess, the investigation surveyed all 20 corridors that existed then: every current row compared against the day's fixing, split by whether it came from a live query or a stored quote, and every negative re-checked against a second live benchmark to see which side of the gap it actually sat on. The result contradicted the working theory: of nineteen negative readings, only two turned out to be timing lag; the other seventeen were real drift that had accumulated since their rows were last sourced, some of it mild, some old enough to need re-sourcing outright.
That distinction decided the fix. Tolerance for a small negative applies only to a live quote or a row sourced that same day, never to an older stored row, since the survey showed those are usually a real signal, not noise. Before shipping it, the logic was tested against a synthetic reading built from the magnitude of an old, already-resolved anomaly on an unrelated corridor (data that never touched the live site) to confirm the new tolerance couldn't quietly wave through something that size. It didn't. And rather than disappear silently, a row inside the tolerance still shows its number, with a short note underneath explaining why it wasn't flagged. A near-miss stays visible instead of quietly passing as clean.
The same investigation turned up an unrelated bug on the homepage: a corridor's headline “cheapest” card was picking whichever provider ranked first, including one currently reading negative, the exact number the detail page would refuse to show without an explanation, displayed on the homepage in the same styling as a legitimate rate. Cards now exclude any row reading negative from the headline pick and note how many are under review, rather than headlining a number the rest of the site wouldn't stand behind.
Beyond the ranking
Two more pieces shipped on top of it. Head-to-head pages put any two providers on a corridor side by side, with a one-line verdict (how much more one of them sends you) and how the same pair compares on other corridors; the sitemap lists 180 of them. And each corridor page has a 60, 14 and 7-day chart of the mid-market rate from Frankfurter, fetched in the browser, so a visitor can see whether today's rate is good or bad before comparing providers. It's a reference rate, and the page says so; it has nothing to do with the provider pipeline.
Self-hosting the analytics
Corridor already used Plausible for pageviews and two custom events, through Plausible's hosted service. I moved it, and this portfolio's own traffic, onto Plausible Community Edition running on one small AWS Lightsail server, behind HTTPS on a subdomain of my own domain. The Community Edition is free software, so the cost is the hosting: about $12 a month for the server.
The plan I started from assumed the stack needed a separate reverse proxy to get a certificate. Reading the current release's source showed it doesn't: it requests and renews its own Let's Encrypt certificate once it's told which ports to listen on, so that was one less thing to run and maintain. Same habit as with the Wise API: check the thing itself instead of trusting a description of it.
Locking it down was mostly ordinary: key-only SSH limited to a single IP address, only the web ports public, registration closed to everyone but invited users, a dedicated IAM user that can reach Lightsail and nothing else (Lightsail can't scope permissions to individual resources, so that's as narrow as it gets), and daily automatic snapshots. The one step that needed real thought was the first account. A fresh install lets the first visitor register, and a newly issued certificate is published to public Certificate Transparency logs, which scanners watch. So I closed the web ports to my own address until the admin account existed, then reopened them and confirmed registration was shut.
What the first test actually showed
Both sites were loading the right tracking script, and the server's health check was green, but my first test visits never appeared in the database. The script had run, and a pageview I triggered by hand from the same browser went through, so the setup itself was fine. The automatic pageview was missing because my test browser's tab was hidden, which is consistent with how the tracker avoids counting background and prerendered tabs. Visits from a normal, visible tab registered right away.
The check that settled it was querying the events table directly. A script tag in the HTML and a healthy server don't show that a real visitor gets counted.
Plausible itself sets no cookies (I confirmed that in the browser), so I didn't add a consent banner for it. What this setup doesn't give me: it is one small server with snapshots, not a redundant deployment; there's no email configured, so a forgotten admin password can't be reset by mail; and the pageviews already recorded on the hosted service didn't come across. For a portfolio and a side project those are acceptable tradeoffs, but they are tradeoffs, not none.
Results
Corridor is live and actively maintained at corridor-red.vercel.app: 66 corridors across five send currencies, a homepage reduced to a compare form, a ranking limited to the three providers a feed backs, custom-amount pricing from 10 to 50,000 as the only input, head-to-head pages, a mid-market rate chart, an AI-generated insight layer that explains each ranking without ever inventing a number, a daily AWS Lambda that keeps the feed-backed rates current without anyone running a script by hand, and a data-integrity process (automated where a feed can actually verify it, entered by hand, dated and checked for promotional quotes everywhere else) that has now caught a wrong assumption, a real bug, several sourcing errors, a silent production failure, and a benchmark that lags the market it's supposed to represent.
What's next
Custom-amount pricing, the same-day tolerance logic, the AWS automation, and the euro-send expansion are all done as of this update. What's actually next:
Automation coverage, not automation existence. The Lambda and refresh-wise-rows.mjs cover Wise and Western Union, and PayPal wherever the feed returns it. PayPal is still entered by hand on 35 of the 66 corridors, because the feed has no PayPal quote there. Nine corridors into Vietnam, South Africa and Bangladesh have only Wise and Western Union, because the feed has no PayPal quote on them (US→Vietnam is the exception, with a hand-entered PayPal row), and for the three sent from euros I haven't checked Xoom. The other seven providers stay switched off until I find a source I trust for each one; bringing one back means re-sourcing its numbers and changing one line, not rebuilding anything. Extending coverage means finding verified endpoints, not building more infrastructure.
AI Rate Insights usage data. Still pending real traffic. The self-hosted Plausible instance described above is wired to receive AI Insight Shown and Anomaly Explanation Shown events; this section gets updated once there's something real to report instead of a description of the tracking itself.