FX Rate Explorer: interactive rate comparison table#222
FX Rate Explorer: interactive rate comparison table#222patcapulong wants to merge 14 commits intomainfrom
Conversation
| { code: 'DKK', name: 'Danish Krone', region: 'Europe', usdMid: 6.87, gridSpread: 0.3, wiseSpread: 0.55 }, | ||
| { code: 'SEK', name: 'Swedish Krona', region: 'Europe', usdMid: 10.45, gridSpread: 0.4, wiseSpread: 0.90 }, | ||
| { code: 'NOK', name: 'Norwegian Krone', region: 'Europe', usdMid: 10.55, gridSpread: 0.4, wiseSpread: 0.90 }, | ||
| { code: 'CZK', name: 'Czech Koruna', region: 'Europe', usdMid: 23.15, gridSpread: 0.45, wiseSpread: 0.95 }, |
There was a problem hiding this comment.
do we need to hardcode these mid and spread values?
There was a problem hiding this comment.
Yes — these are last-resort fallback values, only used when all live data sources fail. The table has a 4-tier fallback cascade:
- Fresh cache (sessionStorage, < 30 min) — immediate, no fetch
- Stale cache (sessionStorage, 30 min–2 hr) — shown immediately while background refetch happens
- Last-known-good (localStorage, any age) — persists across sessions from a previous successful fetch
- Hardcoded array — static snapshot, only hit if the user has never had a successful fetch
In practice, tiers 1–3 cover almost all cases. A user would need to visit for the first time ever and have both the Grid and Wise APIs down simultaneously to see hardcoded values. They're dated (2026-02-24) so we know when to refresh them.
| export const flagUrl = (code) => | ||
| 'https://hatscripts.github.io/circle-flags/flags/' + (CURRENCY_TO_COUNTRY[code] || code.slice(0, 2).toLowerCase()) + '.svg'; | ||
|
|
||
| export const SOURCE_CODES = ['USD', 'EUR', 'GBP', 'CAD', 'SGD', 'HKD']; |
There was a problem hiding this comment.
Should we start with USD, USDC? We don't support the other source currencies yet.
There was a problem hiding this comment.
is it USD/USDC? or you can pick USD or USDC. we can't send EUR? or GBP? idk. it feels sad to only have USD lol. i thought audience for this is also prospective customers — wouldn't we be limiting ourselves a lot?
There was a problem hiding this comment.
for now i just have it use the same dropdown as the destination currency, and the unavailable currencies i say "coming soon"
| export const BATCH_SIZE = 10; | ||
|
|
||
| export const getStripeFxBps = (source, dest) => { | ||
| const majorPairs = ['USD-EUR','USD-GBP','EUR-USD','GBP-USD','EUR-GBP','GBP-EUR']; |
There was a problem hiding this comment.
where are you getting the stripe exchange rates?
There was a problem hiding this comment.
We don't have Stripe's actual exchange rate — they don't publish it or expose an API for it. What we have is their published fee structure (FX fee + cross-border fee + fixed fee per corridor) from https://docs.stripe.com/global-payouts/pricing. We apply those fees to the mid-market rate to estimate what a customer would receive.
Addressed this in more detail on the other thread — short version is this is the same approach Wise uses on their comparison page for providers without live rate data, and we explain the methodology in the footer.
LMK what you think. i'm happy to implement the thing where we hide stripe data on the "amount received" view, i think it's just better to have the table filled ya kno
| ); | ||
| })} | ||
| <td className={'rate-explorer-provider-cell' + (bestKey === 'BankAvg' ? ' rate-explorer-provider-best' : '')}> | ||
| {row.bankAvgBps !== null ? ( |
There was a problem hiding this comment.
instead of bank average, can we pick a bank either Chase or BoA?
There was a problem hiding this comment.
yeah i put BoA because it has 10 corridors. chase only has 5. we can do either, i already have the assets and stuff. LMK what you think.
There was a problem hiding this comment.
i reverted this because i accidentally reverted to last commit and lost all the work for this like a dummy. it was a lot to specify which bank to put for each sending currency and whatever. i kinda think bank average is fine. people looking at this should know that banks are not competitive at all
| }; | ||
|
|
||
| export const AIRWALLEX_MAJORS = ['USD','EUR','GBP','JPY','CHF','AUD','NZD','CAD','HKD','SGD','CNY']; | ||
| export const getAirwallexBps = (dest) => AIRWALLEX_MAJORS.includes(dest) ? 50 : 100; |
There was a problem hiding this comment.
not sure if you want to get claude to make all of the requests but airwallex does provide a fx api https://www.airwallex.com/api/fx/fxRate/liveRate?sellCcy=USD&buyCcy=EUR
There was a problem hiding this comment.
claude told me this 🫤
The Airwallex API is just a mid-market rate feed that powers their website converter. It's the "before fees" number they show to attract customers. The real 50 bps markup gets added when you actually send money — it's not in the API response at all.
We proved this by hitting the API in both directions (sell USD→buy EUR, then sell EUR→buy USD) at the same instant. If 50 bps were baked in, you'd see a ~100 bps gap between the two. Instead, the gap is only ~3 bps — interbank-level pricing. The 50 bps lives in their transaction layer, not their display rate API.
So using it would give us: near_interbank_rate + 50bps — which is the same thing we already compute as grid_mid_rate - 50bps. Same answer, more complexity, and only 10 currencies instead of our 40+.
There was a problem hiding this comment.
where is this getting the mid market rate btw?
There was a problem hiding this comment.
Or is it assuming that our rates are mid market rates?
There was a problem hiding this comment.
airwallex docs also say that they compare multiple FX liquidity providers https://www.airwallex.com/docs/transactional-fx/get-started/choose-your-fx-solution/rates
There was a problem hiding this comment.
We dug into this pretty thoroughly. Here's what we found:
Airwallex's public API (/liveRate) is a display rate feed — it powers their website currency converter. The rates it returns are near-interbank (~3 bps spread total when you compare forward vs reverse directions). The 50 bps markup from their pricing page gets applied at transaction time, not in the API response.
Their docs confirm this two-layer model: a "proprietary Rates pricing engine" that blends quotes from multiple bank liquidity providers (dynamic, based on volatility/size/liquidity), and then "any additional agreed margin applied on top" — which is the 50 bps for major currencies.
So even if we called the API, we'd get the pre-margin rate and still need to add 50 bps on top. Net result is basically the same as our current approach (mid-market - 50 bps).
We looked into using it anyway for the Airwallex column specifically, but there are two hard blockers:
- No CORS headers — browser blocks cross-origin requests to their API, and we're running client-side in Mintlify (no server component to proxy through)
- Only 11 currencies supported (USD, EUR, GBP, JPY, CHF, AUD, NZD, CAD, HKD, SGD, CNY) — everything else returns 400. We'd still need the hardcoded fallback for the other 30+ currencies in our table.
For ~2-5 bps of additional accuracy on 11 out of 40+ rows, standing up a proxy doesn't seem worth it for a docs page. The hardcoded 50/100 bps from their published pricing is actually slightly generous to Airwallex (real total cost is probably 52-55 bps once you include the engine-level spread).
Source: https://www.airwallex.com/docs/transactional-fx/get-started/choose-your-fx-solution/rates
| return 200; | ||
| }; | ||
|
|
||
| export const STRIPE_CB_BPS = { |
There was a problem hiding this comment.
maybe we can hide bridge from the amount received table since we can't get an accurate fx rate
There was a problem hiding this comment.
i'm sorry, but i have another response i had claude write up for me. idk how to say it better.
the question i asked is like, "should we hide the stripe amount received values because we don't know their FX rate, only their fees?"
Stripe's Global Payouts pricing page lists the FX cost as "0.50% of the amount" for USD/EUR/GBP conversions, plus a cross-border fee per corridor (e.g., 25 bps to UK/EU). We apply these published fees to the mid-market rate, same as the mid-market baseline the table already uses.
Could Stripe have an additional hidden spread in their base rate beyond the published fee? It's possible but unlikely — the fee is presented as the total FX cost, and undisclosed FX markups would be a regulatory issue (PSD2 requires full cost disclosure in the EU, and Stripe is publicly traded). We can't independently verify without a test account, but the same is true for any provider in the table — the bank data from the Wise Comparison API is also third-party estimates that can be hours stale.
The approach is the same one Wise themselves use on their comparison page for providers they don't have direct data from. We already updated the footer to explain the methodology: "Stripe and Airwallex amounts estimated from published fee schedules applied to the mid-market rate" with links to their pricing docs.
i know we want rigor, but i think this is fine for this use case. we are just listing amounts for user convenience and explain it in the footer. happy to chat about this
There was a problem hiding this comment.
can you ask claude in that context - if Stripe / Bridge is converting from a stablecoin sandwich converting from fiat to USDC and USDC to local fiat instead of banking partners, would it still follow the mid market rate?
There was a problem hiding this comment.
ya 😆 📞
Good question. The stablecoin sandwich is USD → USDC (on-ramp at ~1:1, Circle redeems at par) → USDC → local fiat (off-ramp). Since USDC is a USD-pegged asset, the USDC/EUR rate is economically equivalent to USD/EUR — arbitrageurs keep these aligned within a few bps for major pairs.
But more importantly: even if the stablecoin route introduces a few bps of friction vs traditional FX, that's Stripe's internal cost, not the customer's. Stripe publishes a fixed fee schedule ("0.50% of the amount") — they absorb any routing inefficiency from their own margin. The stablecoin sandwich is why they acquired Bridge: it's cheaper than correspondent banking, which means better margins for Stripe at the same published price to the customer.
So yes, it should track mid-market very closely, and any small deviation would be on Stripe's dime, not the customer's.
Add an interactive rate comparison table that lets prospects compare Grid's FX rates against competitors across popular corridors. - Rate Explorer snippet with fake data for USD, EUR, GBP corridors - Sample Rates page under Core Concepts - Full styling with dark mode and mobile responsive support Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Default state shows compact "Showing top 10 corridors" label instead of 12 chips. Chips only appear when the user customizes the selection. Also trims default corridors from 12 to 10 (drops SGD/HKD) and fixes missing --ls-green-400 CSS variable for dark mode advantage text. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace hardcoded rates with live data (Grid API → Coinbase fallback → hardcoded), remove competitor columns (Wise/Banks) and historical section, simplify table to Grid rate vs mid-market with spread %. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…-flags Redesign the rate explorer to match Figma specs: new "Send X to Y" header bar with currency chips, circle-flags via CDN, searchable currency dropdown with scroll-aware gradient masks, Grid delta column, full-width table layout, and mobile responsive breakpoints. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove unused flag emoji fields from CURRENCIES (circle-flags CDN replaced them), merge duplicate flag CSS classes, remove empty/redundant CSS rules, and standardize var→let/const throughout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add skeleton shimmer rows while Grid rates load (40 rows matching corridor count) - Cache Grid live rates in sessionStorage (read via lazy useState initializer, no paint blocking) - Cache empty fallback on API failure to prevent repeated skeleton flashes - Use CSS mask-image for Grid icon (inherits text color, external SVG file) - Add currency code to delta column in amount-received mode - Remove dead SkeletonTable component and unused CSS from static skeleton experiments - Full-width layout marker div (#rate-explorer-page) remains for CSS :has() selectors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add Stripe (FX + cross-border + fixed fee bps) and Airwallex (50/100 bps) as computed competitor columns from published pricing schedules - Add Avg Bank column from Wise API bank-type provider averages - Reduce dynamic provider columns from 3 to 2 - Wait for both live rates and competitor data before showing table - Replace generic skeleton bars with table-shaped skeleton (SkeletonTable) showing real column headers, currency rows with flags, and shimmer cells - Add StaticPlaceholder for zero-layout-shift first paint before React mounts - Override Mintlify's injected table styles (min-w-[150px], w-full, margins) - Hug-content column widths with width:1% trick Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…e explorer Add provider logo SVGs to thead (Stripe, Airwallex, Wise, dynamic providers). Replace Grid icon+text with full Grid wordmark logo. Add bank icon to Avg Bank column. Add subtle column hover highlight with JS-driven thead highlighting. Fix dark mode search icon, mobile padding, and Mintlify row hover override. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reword disclaimer to match table column order (Grid, Stripe, Airwallex, Wise, Bank avg.). Rename "Avg Bank" column to "Bank avg." Update competitor logos from refs/competitor-logos. Add green highlight for best-rate cells with row-hover support. Make non-Grid provider text gray 500. Adjust bank icon and grid icon spacing. Co-authored-by: Cursor <cursoragent@cursor.com>
Full-width banner between page header and rate explorer with background image, hover scale effect, and "Contact sales" CTA. Co-authored-by: Cursor <cursoragent@cursor.com>
Show all currencies in the source selector using the same dropdown UI as the destination selector. Supported currencies (USD, EUR, GBP, CAD, SGD, HKD) appear at the top; remaining currencies are grouped below a "Coming soon" divider in a disabled state. Co-authored-by: Cursor <cursoragent@cursor.com>
Move sample-rates.mdx from platform-overview/core-concepts/ to top-level fx-rates.mdx for a cleaner URL path. Delete the superseded rate-explorer snippet and add a dedicated OG image for the FX Rates page. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
97fd213 to
27b802c
Compare
|
@greptile can you review this |
Greptile SummaryThis PR adds a comprehensive, interactive FX rate comparison page at Key changes:
Issues found:
Confidence Score: 4/5
|
| Filename | Overview |
|---|---|
| mintlify/fx-rates.mdx | New interactive FX rate comparison page with live data fetching, currency selection dropdowns, and dynamic table rendering |
| mintlify/style.css | Comprehensive CSS for rate explorer component with dark mode support, sticky header behavior, and responsive design |
| mintlify/docs.json | Added fx-rates page to navigation under platform-overview section |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[User visits /fx-rates] --> B[StaticPlaceholder renders SSR]
B --> C[RateExplorer component mounts]
C --> D{Check sessionStorage cache}
D -->|Cache hit| E[Load cached rates]
D -->|Cache miss| F[IntersectionObserver waits]
F --> G[Component enters viewport]
G --> H[Fetch Grid live rates API]
G --> I[Fetch Wise comparison API<br/>batched, 10 concurrent]
H --> J[Parse & cache live rates]
I --> K[Process competitor data]
K --> L[Calculate Stripe/Airwallex rates<br/>from pricing schedules]
J --> M[Compute all corridors]
L --> M
M --> N[User selects currencies]
N --> O[Filter & display table]
O --> P[Highlight best provider per row]
P --> Q{User changes amount?}
Q -->|Yes| R[1200ms debounce]
R --> S[Bucket amount]
S --> D
Last reviewed commit: 27b802c
| const [highlightIdx, setHighlightIdx] = React.useState(-1); | ||
| const [competitorData, setCompetitorData] = React.useState(() => { | ||
| try { | ||
| var cached = sessionStorage.getItem('wise_USD_1000'); |
There was a problem hiding this comment.
Hardcoded cache key 'wise_USD_1000' doesn't match the dynamic cache key used in the effect (line 502). Initial state will only load cached data for USD 1000, not for other source currencies or amounts.
| var cached = sessionStorage.getItem('wise_USD_1000'); | |
| var cached = sessionStorage.getItem('wise_' + sourceCurrency + '_' + bucketAmount(amount)); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: mintlify/fx-rates.mdx
Line: 402
Comment:
Hardcoded cache key `'wise_USD_1000'` doesn't match the dynamic cache key used in the effect (line 502). Initial state will only load cached data for USD 1000, not for other source currencies or amounts.
```suggestion
var cached = sessionStorage.getItem('wise_' + sourceCurrency + '_' + bucketAmount(amount));
```
How can I resolve this? If you propose a fix, please make it concise.| }); | ||
| const [topProviders, setTopProviders] = React.useState(() => { | ||
| try { | ||
| var cached = sessionStorage.getItem('wise_USD_1000'); |
There was a problem hiding this comment.
Same hardcoded cache key issue - should match the dynamic key pattern.
| var cached = sessionStorage.getItem('wise_USD_1000'); | |
| var cached = sessionStorage.getItem('wise_' + sourceCurrency + '_' + bucketAmount(amount)); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: mintlify/fx-rates.mdx
Line: 412
Comment:
Same hardcoded cache key issue - should match the dynamic key pattern.
```suggestion
var cached = sessionStorage.getItem('wise_' + sourceCurrency + '_' + bucketAmount(amount));
```
How can I resolve this? If you propose a fix, please make it concise.| }); | ||
| const [competitorDone, setCompetitorDone] = React.useState(() => { | ||
| try { | ||
| var cached = sessionStorage.getItem('wise_USD_1000'); |
There was a problem hiding this comment.
Same hardcoded cache key issue - should match the dynamic key pattern.
| var cached = sessionStorage.getItem('wise_USD_1000'); | |
| var cached = sessionStorage.getItem('wise_' + sourceCurrency + '_' + bucketAmount(amount)); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: mintlify/fx-rates.mdx
Line: 422
Comment:
Same hardcoded cache key issue - should match the dynamic key pattern.
```suggestion
var cached = sessionStorage.getItem('wise_' + sourceCurrency + '_' + bucketAmount(amount));
```
How can I resolve this? If you propose a fix, please make it concise.
Summary
Interactive FX rate comparison page at
/fx-ratesthat lets visitors compare Grid's pricing against major competitors (Stripe, Airwallex, Wise, PayPal, Remitly, Western Union, OFX, and others) across 40+ currency corridors.Data sources
/v4/comparisons/), which returns data for Wise, PayPal, Remitly, Western Union, banks, and othersKey features
Performance
Files changed
mintlify/fx-rates.mdx— entire component (data, logic, JSX) in a single MDX filemintlify/style.css— allrate-explorer-*andfx-rates-*CSS classesmintlify/docs.json— nav entry updated tofx-ratesmintlify/images/icons/— competitor logos (Stripe, Wise, PayPal, Remitly, WU, OFX, Airwallex, etc.)mintlify/images/og/og-fx-rates.png— dedicated Open Graph imageTest plan
/fx-rateswith wide layout🤖 Generated with Claude Code