All docs

balance.available — the FX sweep trigger

balance.available marks funds becoming available. For multi-currency platforms it is a natural point to audit recent cross-border conversion slippage.

What the event means

balance.available fires when funds become available in your Stripe balance. On its own it carries no discrepancy information.

Why it is a useful trigger

It fires predictably and on a cadence tied to settlement, which makes it a natural moment to sweep recent transfers for conversion anomalies without adding a separate cron job.

FeeGuard uses it to scan the last 24 hours of cross-border transfers, comparing each realised exchange rate against that day's baseline for the same currency pair and flagging deviations beyond 0.8%.

What the payload does and does not contain

The event object is a Balance — available and pending amounts, broken down by currency and by source type. It carries no reference to any charge, transfer, or payout.

That makes it useless as a detection input on its own. There is nothing in the payload to reconcile. Its value is entirely in *when* it fires, not what it says.

Treating it as a scheduling signal rather than a data source is the right mental model.

Why it beats a cron job

A periodic sweep of recent transfers could run on a timer. Using balance.available instead has three practical advantages.

It is aligned with settlement. The event fires when funds actually become available, which is when the conversions you want to inspect have finished settling. A timer fires whenever it fires.

It scales with the platform. A busy platform generates the event more often than a quiet one, so sweep frequency tracks activity without anyone tuning an interval.

It is one less thing to operate. No scheduler to configure, monitor, or debug when it silently stops firing — which is the failure mode cron jobs are notorious for.

Establishing an FX baseline without a third-party feed

The obvious approach to measuring conversion loss — compare Stripe's rate against a mid-market feed — mostly restates the fact that Stripe charges a spread. That is a pricing negotiation, not a reconciliation finding, and it introduces an external dependency with its own spread into a number you need to trust.

The more useful measure is relative: how did this transfer's realised rate compare to the rate the same currency pair achieved on comparable transfers the same day?

The first observation for a currency pair each day becomes that day's baseline, cached for comparison. Subsequent transfers on the same pair are measured against it. Deviations beyond roughly 0.8% are worth investigating; anything inside that is ordinary variation.

This deliberately measures outliers rather than absolute spread. Outliers are actionable — they point at a specific transfer where something went wrong. Absolute spread is a contract term.

// First transfer of the day for this pair seeds the baseline
let baseline = await getCachedRate(from, to, dayStamp);

if (baseline === null) {
  await cacheRate(from, to, realisedRate, dayStamp);
  return null;  // nothing to compare against yet
}

const deviation = Math.abs(realisedRate - baseline) / baseline;
if (deviation > 0.008 && realisedRate < baseline) {
  // converted worse than peers — worth flagging
}

Only losses, never windfalls

A conversion that came out *better* than the day's baseline is not a discrepancy. It is a good outcome, and reporting it as a finding would be noise in a queue people are trying to work through.

The comparison should be directional: flag only where the realised rate is worse than baseline by more than tolerance. Symmetric deviation checks roughly double the finding count while adding nothing actionable.

Bounding the sweep

A busy platform can create thousands of transfers in a 24-hour window, and balance.available may fire several times a day. An unbounded sweep would consume the tenant's Stripe rate limit and time out.

A sensible bound is a fixed lookback (24 hours) and a hard cap on transfers examined per sweep. Missing a few transfers on an exceptionally busy day is an acceptable trade against a worker that reliably completes — and the next sweep picks up anything still inside the window.

It fires more often than you expect

On an active platform this event arrives several times a day, sometimes several times an hour. Any handler attached to it needs to be cheap, or guarded.

Doing meaningful work on every occurrence — a full transfer sweep, say — will consume the tenant's Stripe rate limit and starve the handlers that matter more. A simple guard is to record when the last sweep ran and skip if it was recent, which turns an unpredictable trigger into a predictable cadence without needing a scheduler.

The alternative failure is subtler: several occurrences processed concurrently, each sweeping the same transfers and racing on the same cached FX baseline. A per-organization lock removes that entirely.

Multi-currency balances are separate buckets

The payload breaks available and pending down by currency. A platform holding USD, EUR, and GBP has three independent balances, each with its own payout schedule and its own settlement behaviour.

A common mistake is summing them into a single "available" figure for a dashboard. The result is a number in no currency at all — it adds euros to dollars and presents the total as though it means something.

Where a single headline figure is genuinely needed, convert explicitly at a stated rate and label it as converted. Where it is not needed, showing the largest balance with a note about the others is more honest and usually more useful.

Available versus pending

pending is money Stripe has collected but not yet released; available is money that can be paid out now. The gap between them is the settlement delay, which varies by country, account age, and risk profile.

For reconciliation purposes only available is actionable. A reversal draws against available balance, so a connected account with a large pending balance and nothing available cannot fund a clawback today — though they will be able to shortly.

That timing detail is worth surfacing when a recovery fails for insufficient funds. "Retry in two days" is a much better answer than "failed", and the pending balance is what tells you which one is true.