DEV Community

Veristria
Veristria

Posted on Originally published at feeguard.dev

Build your own fee-leakage detection with the Stripe API

Build your own fee-leakage detection with the Stripe API

Build your own fee-leakage detection with the Stripe API

You can detect Connect fee leakage yourself: list refunds and disputes, join them to charges, transfers, reversals, and application fees, apply two expectation formulas, and alert on the difference. This piece gives platform engineers the architecture, the exact API calls, and working code for continuous detection you own end to end.

Architecture in one diagram

Five components, one data flow, no money movement anywhere in the pipeline:

lister jobs ──▶ normalizer ──▶ expectation engine ──▶ findings store ──▶ alerter
     │             │                │                    │               │
     └─────────────┴───── read-only restricted key throughout ─────────┘
Enter fullscreen mode Exit fullscreen mode

Lister jobs pull new refunds and disputes over the API since the last cursor. The normalizer flattens them into rows carrying everything the math needs: parent charge, transfer amount, reversals already made, application fee and its refunded portion. The expectation engine applies two formulas — proportional reversal, proportional fee refund — and emits a finding whenever actual falls short of expected. The findings store persists diffs with identifiers and ages. The alerter turns stored findings into human-visible output.

Every component reads through the same restricted key. Stripe supports restricted API keys scoped per resource with read or write access (API keys), and an auditor process has no business holding write access at all — more on that next. If you eventually compare this stack against alternatives, the honest framing lives at homegrown scripts versus managed detection; here we build.

Scoping credentials safely

Create a restricted key in the Dashboard and grant read access to exactly six resources: Charges, Refunds, Transfers, Application Fees, Disputes, and Balance. Nothing else, and write access nowhere.

The reasoning is mechanical. This system's entire job is noticing that money went somewhere it should not have; a key that cannot move money cannot make any of its own failure modes into incidents. A leaked secret key drains balances; a leaked restricted read-only key leaks data — serious, but bounded, and revocable in one click. Auditors and security reviewers also get a cleaner story: the credential proves the pipeline is incapable of self-help, so every recovery decision necessarily routes through humans. Wire it from the environment, never from source:

import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_RESTRICTED_KEY);
Enter fullscreen mode Exit fullscreen mode

Pulling the corpus

The corpus starts from refunds, because every leak scenario begins with one. The snippet below autopaginates refunds created in a window, expands each parent charge inline, then fetches the three quantities the math depends on: the destination transfer's amount, the sum of reversals already made against it, and the application fee with its refunded portion.

interface CorpusRow {
  refund: Stripe.Refund;
  charge: Stripe.Charge;
  transferAmount: number | null;
  reversedCents: number;
  feeAmount: number | null;
  feeRefundedCents: number | null;
}

async function pullRefundRows(sinceUnix: number): Promise<CorpusRow[]> {
  const rows: CorpusRow[] = [];
  const refunds = stripe.refunds.list({
    created: { gte: sinceUnix },
    limit: 100,
    expand: ["data.charge"],
  });
  for await (const refund of refunds) {
    rows.push(await normalize(refund));
  }
  return rows;
}

async function normalize(refund: Stripe.Refund): Promise<CorpusRow> {
  const charge = refund.charge as Stripe.Charge;
  const row: CorpusRow = {
    refund,
    charge,
    transferAmount: null,
    reversedCents: 0,
    feeAmount: null,
    feeRefundedCents: null,
  };

  const transferId =
    typeof charge.transfer === "string" ? charge.transfer : (charge.transfer?.id ?? null);
  if (typeof transferId === "string") {
    const [transfer, reversals] = await Promise.all([
      stripe.transfers.retrieve(transferId),
      stripe.transfers.listReversals(transferId, { limit: 100 }),
    ]);
    row.transferAmount = transfer.amount;
    row.reversedCents = reversals.data.reduce((sum, rev) => sum + rev.amount, 0);
  }

  const feeId =
    typeof charge.application_fee === "string"
      ? charge.application_fee
      : (charge.application_fee?.id ?? null);
  if (typeof feeId === "string") {
    const fee = await stripe.applicationFees.retrieve(feeId);
    row.feeAmount = fee.amount;
    row.feeRefundedCents = fee.amount_refunded;
  }
  return row;
}
Enter fullscreen mode Exit fullscreen mode

Three details worth internalizing. First, the expand parameter saves a request per row; without it, refund.charge is just an ID. Second, reversals are summed rather than assumed singular, because a transfer may carry several partial reversals from earlier refunds. Third, amount_refunded on the ApplicationFee object already aggregates any separately issued fee refunds, so the fee side costs one retrieval, not a listing loop.

Direct-charge fleets. On direct charges, refunds live on the connected account, not your platform — the objects are created under the connected account's context (direct charges), so the platform-side list above never sees them. Iterate known account IDs and pass each through the header-scoped call:

async function pullForAccount(accountId: string, sinceUnix: number): Promise<CorpusRow[]> {
  const rows: CorpusRow[] = [];
  const refunds = stripe.refunds.list(
    { created: { gte: sinceUnix }, limit: 100 },
    { stripeAccount: accountId }
  );
  for await (const refund of refunds) {
    rows.push(await normalize(refund));
  }
  return rows;
}
Enter fullscreen mode Exit fullscreen mode

Keep the account-ID inventory in your own database, refreshed however you onboard and offboard sellers. Disputes join the corpus the same way — stripe.disputes.list with a created window, expanded charges included — feeding the covered-loss check described later.

Expectation functions

Two formulas carry the whole method. Both take integer cents and return integer cents; both assume nothing beyond the objects you already hold.

function expectedReversal(
  refundedCents: number,
  chargeCents: number,
  transferCents: number
): number {
  if (chargeCents <= 0 || transferCents <= 0) return 0;
  return Math.round((refundedCents / chargeCents) * transferCents);
}

function expectedFeeRefund(
  refundedCents: number,
  chargeCents: number,
  feeCents: number
): number {
  if (chargeCents <= 0 || feeCents <= 0) return 0;
  return Math.round((refundedCents / chargeCents) * feeCents);
}
Enter fullscreen mode Exit fullscreen mode

The proportional-reversal shape is not arbitrary: it mirrors what Stripe itself does when reverse_transfer=true rides on a partial refund — the transfer is reversed proportionally to the amount being refunded (Refunds API). The fee analog follows identically from full-with-full, partial-with-proportional semantics.

The critical discipline is what refundedCents means: the charge-level cumulative, not each refund's individual amount. Compute it from charge.amount_refunded, which the Charge object maintains for you. Aggregating this way defeats rounding drift. Watch the difference on a small case: charge 300¢, transfer 100¢, three sequential refunds of 100¢ each. Per-refund expectations round to 33¢ apiece, and 3 × 33 = 99¢ — a phantom 1¢ shortfall manufactured entirely by intermediate rounding. Charge-level: round((300 ÷ 300) × 100) = 100¢, compared once against the reversals actually on file. No phantom. The trade-off is philosophical and worth stating plainly: the cumulative model answers "is money missing right now?" rather than "which specific refund underpaid?" For leakage detection, the first question is the one with financial consequences.

Turning diffs into findings

Group corpus rows by charge, evaluate both expectations once per group, and subtract what actually happened:

interface Finding {
  charge_id: string;
  missing_transfer_cents: number;
  missing_fee_cents: number;
  currency: string;
  age_days: number;
}

function buildFindings(corpus: CorpusRow[], nowUnix: number): Finding[] {
  const byCharge = new Map<string, CorpusRow[]>();
  for (const row of corpus) {
    const bucket = byCharge.get(row.charge.id) ?? [];
    bucket.push(row);
    byCharge.set(row.charge.id, bucket);
  }

  const findings: Finding[] = [];
  for (const [chargeId, rows] of byCharge) {
    const first = rows[0];
    const refundedSoFar = first.charge.amount_refunded;
    let missingTransfer = 0;
    let missingFee = 0;

    if (typeof first.transferAmount === "number") {
      const expected =
        expectedReversal(refundedSoFar, first.charge.amount, first.transferAmount);
      missingTransfer = Math.max(expected - first.reversedCents, 0);
    }
    if (
      typeof first.feeAmount === "number" &&
      typeof first.feeRefundedCents === "number"
    ) {
      const expectedFee =
        expectedFeeRefund(refundedSoFar, first.charge.amount, first.feeAmount);
      missingFee = Math.max(expectedFee - first.feeRefundedCents, 0);
    }
    if (missingTransfer === 0 && missingFee === 0) continue;

    findings.push({
      charge_id: chargeId,
      missing_transfer_cents: missingTransfer,
      missing_fee_cents: missingFee,
      currency: first.refund.currency,
      age_days: Math.floor((nowUnix - first.charge.created) / 86400),
    });
  }
  return findings;
}
Enter fullscreen mode Exit fullscreen mode

Run it against a corpus containing the canonical scenarios — $100.00 charge, $10.00 application fee, $90.00 transfer, standard US card pricing — and the store fills with records shaped like this:

[
  {
    "charge_id": "ch_ExampleFullDefault",
    "missing_transfer_cents": 9000,
    "missing_fee_cents": 1000,
    "currency": "usd",
    "age_days": 41
  },
  {
    "charge_id": "ch_ExamplePartialFlag",
    "missing_transfer_cents": 0,
    "missing_fee_cents": 400,
    "currency": "usd",
    "age_days": 17
  },
  {
    "charge_id": "ch_ExampleFeeOnlyFlag",
    "missing_transfer_cents": 9000,
    "missing_fee_cents": 0,
    "currency": "usd",
    "age_days": 9
  }
]
Enter fullscreen mode Exit fullscreen mode

Reading the rows: the first is a full default refund — transfer untouched ($90.00 short), fee kept ($10.00 short). The second is a $40.00 partial refund issued with reverse_transfer=true; the proportional reversal landed (expected round((4000 ÷ 10000) × 9000) = 3600¢, actual 3600¢) but the proportional fee refund of 400¢ did not. The third had refund_application_fee=true alone: fee squared away, $90.00 of seller-held funds left unclaimed. Every row carries the evidence trail implicitly — charge ID plus amounts — so any human can pull the underlying objects and verify the arithmetic by hand.

Scheduling, cursors, idempotency

Detection is a loop, and loops need memory. Persist the last successful scan boundary and resume from it:

import { readFileSync, writeFileSync } from "node:fs";

const CURSOR_PATH = "./refund-cursor.json";
const DAY_SECONDS = 86400;

function loadCursor(): number {
  try {
    return JSON.parse(readFileSync(CURSOR_PATH, "utf8")).created_gt as number;
  } catch {
    return Math.floor(Date.now() / 1000) - 90 * DAY_SECONDS;
  }
}

function saveCursor(unix: number): void {
  writeFileSync(CURSOR_PATH, JSON.stringify({ created_gt: unix }));
}

async function runScheduledScan(): Promise<void> {
  const now = Math.floor(Date.now() / 1000);
  const cursor = loadCursor();
  const rows = await pullRefundRows(cursor);
  const findings = buildFindings(rows, now);
  await storeFindings(findings);
  saveCursor(now);
}
Enter fullscreen mode Exit fullscreen mode

A daily cron suits most platforms: refund-driven leaks do not decay faster than daily attention, and the 90-day lookback on first run — the catch branch seeds the cursor ninety days back — doubles as your initial historical sweep. Findings from that sweep will be old; route them to a batch review rather than paging anyone. Bulk-triaging aged findings is its own discipline (historical findings playbook).

One reservation: Idempotency-Key headers belong to the action phase, not this one. Detection writes nothing to Stripe, so it needs no idempotency keys. The moment any component graduates to taking actions — creating a reversal, refunding an application fee — every POST carries a deterministic key, because Stripe honors keys for 24 hours to make retries safe (idempotent requests):

await stripe.transfers.createReversal(
  transferId,
  { amount: finding.missing_transfer_cents },
  { idempotencyKey: `reverse-${finding.charge_id}-${policyVersion}` }
);
Enter fullscreen mode Exit fullscreen mode

Never act automatically on first detection. A finding is a hypothesis with numbers attached; confirmation, context (policy version, goodwill overrides, netting already in flight), and approval come before any money moves. The design patterns for safe reversal execution are catalogued separately (idempotency for reversals).

The event-driven variant

Batch scans bound staleness at the cron interval. If you need tighter latency, subscribe to webhook events and run the identical expectation logic incrementally. The relevant set covers the money movements themselves: refund.created, refund.updated, charge.refunded, application_fee.created, application_fee.refunded, transfer.created, transfer.reversed, plus the dispute events (charge.dispute.created, charge.dispute.updated, charge.dispute.closed). Verify signatures, respond quickly, process asynchronously — delivery is at-least-once with retries up to roughly three days, so handlers must be idempotent (webhooks).

import express from "express";

const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET as string;
const app = express();

app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = String(req.headers["stripe-signature"]);
    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(req.body, signature, endpointSecret);
    } catch {
      res.sendStatus(400);
      return;
    }
    if (
      event.type === "refund.updated" ||
      event.type === "charge.refunded" ||
      event.type === "application_fee.refunded" ||
      event.type === "transfer.reversed" ||
      event.type === "charge.dispute.closed"
    ) {
      void queue.enqueue(event);
    }
    res.sendStatus(200);
  }
);

app.listen(4242);

declare const queue: { enqueue: (event: Stripe.Event) => Promise<void> };
Enter fullscreen mode Exit fullscreen mode

Put a queue between receiver and worker even at modest volume: the receiver's only jobs are signature verification and fast 2xx responses, while the worker fetches the affected refund, normalizes it, runs the same buildFindings evaluation for that single charge, and updates the findings store. Because both paths share the expectation engine, batch and event results agree by construction — disagreements become your best regression tests. The wiring patterns for Express receivers and queued workers are documented step-by-step in the Node refund-handling guide and the queue-based processing guide; the general trade space between these approaches is mapped at building on webhooks yourself.

Maintenance reality

The skeleton above works. Keeping it working is the honest part of this piece, because a homegrown detector inherits maintenance obligations that never fully retire:

Schema and API drift. Object fields, pagination behavior, and SDK typings move with API versions. Every Stripe upgrade warrants a re-read of the release notes against your normalizer, because your expectations silently encode field assumptions like amount_refunded staying cumulative.

Multi-currency conversions. Cross-currency refunds convert at the live rate on refund day regardless of any quote locked at payment time, and Stripe does not return the original transaction's FX fee (FX on refunds). Your expectation math stays in the charge currency, but platform-level loss reporting needs conversion handling, and FX slippage becomes its own detection problem beside the transfer math. Zero-decimal currencies such as JPY store amounts in the unit itself — the formulas survive unchanged, but every display threshold and alert constant needs currency-awareness (currencies).

Source_transaction timing. Transfers created with source_transaction attach to a charge's availability schedule rather than paying out immediately. The amounts remain valid inputs to the formulas, but "the transfer exists" and "the funds moved" stop being simultaneous, and reconciliation windows must tolerate the gap.

Partial-refund sequences. Mixed flag choices across multiple refunds on one charge are the common case, not the edge. The cumulative model handles state correctly, but per-event attribution — who under-reversed which time — requires replaying the sequence in order, which is extra machinery you will eventually want.

Pending and failed refunds. A refund debited against an insufficient balance sits in pending and may lack a balance transaction until funded; failed refunds return funds within up to roughly thirty days and carry failure_balance_transaction and failure_reason (refunds). Scan loops must skip-and-recheck rather than treat either state as final.

Direct-charge fleets. Account inventories churn, closed accounts linger, rate limits arrive per-account, and a fleet spanning hundreds of accounts turns yesterday's simple loop into a scheduling problem.

Disputes as their own lane. On destination and separate charges the disputed amount and the dispute fee debit the platform balance, and recovering from a seller is manual (disputes on Connect). Extending the engine to covered-loss checks means modeling dispute outcomes, win rates, and re-transfer decisions — genuinely worth doing, genuinely separate work.

None of this is disqualifying; all of it is permanent. That is the actual decision in front of you: the detection arithmetic fits in a file, but the upkeep — version drift, currency edges, state machines, fleet mechanics — is an ongoing product you would now operate. Some platforms should operate it; the exercise above is the honest way to find out whether yours is one of them, because building even the skeleton surfaces every question a maintained system must answer forever.

Frequently asked questions

Why Math.round specifically?

Because a convention beats a debate. Rounding half-up matches the convention used throughout Stripe's proportional behavior as commonly modeled, and — more importantly — holding one convention permanently matters more than which one you pick. Switching conventions mid-stream manufactures phantom findings out of thin air, so write the choice down next to the policy version.

Do zero-decimal currencies break the formulas?

No. In zero-decimal currencies the amount is already expressed in the unit itself, so a JPY charge and its refund use identical units and the ratios come out clean. What breaks naively is presentation and thresholds: an alert constant tuned as "$10" reads absurdly in yen unless your code converts display units per currency.

Why not compute expectations inside Sigma?

Sigma queries recorded data read-only, and expectations are not recorded anywhere in the schema — there is no column holding what should have been reversed. You can export raw components from Sigma and feed them into these same formulas, which is a legitimate hybrid, but the arithmetic always happens outside Stripe.

How should the scanner treat pending refunds?

Skip them, deliberately. Track their status and revisit on the next cycle; a pending refund typically lacks the balance transaction your normalizer joins on, and treating absence-of-evidence as a finding floods the store with noise during balance shortages. Only finalized refunds produce stable expectations.

When exactly do I add Idempotency-Key headers?

Only when the system starts taking actions. Detection POSTs nothing, so it needs no keys. The first createReversal or fee-refund call should carry a deterministic key built from the charge ID and policy version, keeping 24-hour retries safe — and that transition is precisely where automatic action should start requiring human approval first.

Run the free 90-day audit

If you would rather see the findings before committing to operating the machinery, FeeGuard runs this exact expectation arithmetic — proportional reversals, application-fee refunds, dispute losses, FX slippage — over your last 90 days of Connect activity through a restricted, read-only API key, and reports every occurrence with the underlying Stripe evidence attached. You get the answer first; monitoring is optional afterward.

Run the free 90-day audit.

FeeGuard is an independent product and is not affiliated with, endorsed by, or sponsored by Stripe, Inc.

Top comments (0)