<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Veristria</title>
    <description>The latest articles on DEV Community by Veristria (@veristria).</description>
    <link>https://dev.to/veristria</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4088170%2Fe4ca5920-1f77-4f11-8ba5-b1b8b9364928.png</url>
      <title>DEV Community: Veristria</title>
      <link>https://dev.to/veristria</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/veristria"/>
    <language>en</language>
    <item>
      <title>Auditing a Supabase project in one afternoon</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Wed, 09 Sep 2026 16:38:52 +0000</pubDate>
      <link>https://dev.to/veristria/auditing-a-supabase-project-in-one-afternoon-1ia4</link>
      <guid>https://dev.to/veristria/auditing-a-supabase-project-in-one-afternoon-1ia4</guid>
      <description>&lt;h1&gt;
  
  
  Auditing a Supabase project in one afternoon
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;You don't need tooling to know where your Supabase security stands - you need four hours and the right questions in the right order. This is the complete manual audit: every catalog query, how to read each result, and the outside probes that turn findings into proof.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;RowShield exists because this audit is worth automating and monitoring. But the manual version matters for different reasons: it teaches you what the automation watches, it works today with zero setup, and when a scan does flag something, understanding the query behind the finding means understanding the fix. This article is the audit we run by hand — scheduled as an afternoon, structured so each hour's output feeds the next.&lt;/p&gt;

&lt;p&gt;Everything below reads or probes your own project using your own access. Every SQL statement runs against current Postgres; all were executed during this article's preparation. By the end you'll have a findings document with evidence attached — not a vibe about your security, a list with proof per line, sorted by what deserves fixing first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The afternoon plan
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Work&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hour 1&lt;/td&gt;
&lt;td&gt;Catalog inventory: tables, policies, grants, functions, views, buckets&lt;/td&gt;
&lt;td&gt;Protection inventory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hour 2&lt;/td&gt;
&lt;td&gt;Read the inventory: shapes, unions, gaps&lt;/td&gt;
&lt;td&gt;Findings list (structural)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hour 3&lt;/td&gt;
&lt;td&gt;Outside probes: anon surface + two-account battery&lt;/td&gt;
&lt;td&gt;Findings list (behavioral)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hour 4&lt;/td&gt;
&lt;td&gt;Storage, functions, keys; write up everything&lt;/td&gt;
&lt;td&gt;Prioritized remediation worklist&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Bring to the session: your project's SQL editor access, the public anon key, two test accounts if the app has authentication (create them through signup if not), and a document open for findings. Nothing else — no special tooling, no credentials beyond what you already hold.&lt;/p&gt;

&lt;p&gt;The order matters more than the clock. Inventory before reading prevents anchoring on whatever the app's UI shows; structural reading before probing tells you which probes matter most; probes last because they confirm rather than explore. Teams that start with probing often stop at the first scary result and never learn what else the catalog was trying to tell them.&lt;/p&gt;

&lt;p&gt;A word on scope before starting: this audit covers authorization posture — what's reachable, by whom, under what rules — rather than code vulnerabilities, dependency patching, or infrastructure hardening. Those belong to other checklists. The authorization layer earns its own audit because it changes on every deploy and fails in ways nothing else surfaces; treat this afternoon as the recurring core, with other security reviews layered around it at their own cadences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hour one: inventory the protection surface
&lt;/h2&gt;

&lt;p&gt;Open the SQL editor and run five queries. First, tables and their RLS flags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relrowsecurity&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;rls_enabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relforcerowsecurity&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;force_rls&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, every policy in full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;permissive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Third, grants — which client-facing roles can do what:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;grantee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;privilege_type&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role_table_grants&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;grantee&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'authenticated'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;grantee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;privilege_type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fourth, privilege-elevating functions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proconfig&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_proc&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pronamespace&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prosecdef&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifth, views and their invocation semantics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reloptions&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'v'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the project uses Storage, add its buckets and object policies to the pile (&lt;code&gt;storage.buckets&lt;/code&gt;, then &lt;code&gt;pg_policies&lt;/code&gt; filtered to &lt;code&gt;schemaname = 'storage'&lt;/code&gt;). Export all of it — copy to a document, literally. Hour two reads from this artifact, and hour four's write-up cites it as evidence.&lt;/p&gt;

&lt;p&gt;To make the reading concrete, here is the first query's output from the fabricated example project used across RowShield articles, annotated the way you should annotate your own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;      relname      | rls_enabled | force_rls
-------------------+-------------+-----------
 documents         | t           | f
 notifications     | f           | f      &amp;lt;-- FINDING: open table
 workspace_members | t           | f
 workspaces        | t           | f
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every column earns its keep. &lt;code&gt;rls_enabled = false&lt;/code&gt; means the API serves that table to anyone holding your public key — no policy discussion needed, exposure exists today. &lt;code&gt;force_rls = false&lt;/code&gt; is normal (owner access for migrations) but worth knowing per table: it names which tables would still bypass policies if someone connects as the owner. Annotate every anomaly inline as you go; memory doesn't survive to hour two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hour two: read what you found
&lt;/h2&gt;

&lt;p&gt;The inventory answers six questions, each mapped to a verdict:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Where to look&lt;/th&gt;
&lt;th&gt;Bad answer looks like&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Which tables are open?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;rls_enabled&lt;/code&gt; column&lt;/td&gt;
&lt;td&gt;Any &lt;code&gt;false&lt;/code&gt; on a non-public-by-design table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Which writes lack constraints?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;with_check&lt;/code&gt; column&lt;/td&gt;
&lt;td&gt;NULL under INSERT/UPDATE/ALL policies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Which tables carry tautologies?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;qual&lt;/code&gt; column&lt;/td&gt;
&lt;td&gt;Constant &lt;code&gt;true&lt;/code&gt; expressions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where do unions widen?&lt;/td&gt;
&lt;td&gt;Same table+cmd grouping&lt;/td&gt;
&lt;td&gt;Three-plus PERMISSIVE policies per command&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What bypasses row security?&lt;/td&gt;
&lt;td&gt;Function list&lt;/td&gt;
&lt;td&gt;Definer functions without pinned search_path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What elevates indirectly?&lt;/td&gt;
&lt;td&gt;Views' reloptions&lt;/td&gt;
&lt;td&gt;Views missing &lt;code&gt;security_invoker = true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reading technique for the policy dump: group rows mentally by &lt;code&gt;(tablename, cmd)&lt;/code&gt; and read each group as one OR expression, since &lt;a href="https://rowshield.dev/solutions/multiple-policies-permissive-union" rel="noopener noreferrer"&gt;permissive policies combine&lt;/a&gt; by union. A group whose members disagree about scope isn't contradictory — it's as wide as its widest member. Note any identity comparison that references something other than &lt;code&gt;(select auth.uid())&lt;/code&gt;: request parameters and client-shaped columns don't count as verified identity.&lt;/p&gt;

&lt;p&gt;A worked mini-read shows the method. Suppose some project's dump contains, for one table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;policyname            | permissive | cmd    | qual                          | with_check
----------------------+------------+--------+-------------------------------+-----------
invoices_select_owner | PERMISSIVE | SELECT | uid() = owner_id              |
invoices_select_any   | PERMISSIVE | SELECT | true                          |
invoices_insert_own   | PERMISSIVE | INSERT |                               | false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines, three verdicts. The &lt;code&gt;any&lt;/code&gt; tautology makes the owner policy redundant for reads — every authenticated user matches every row already, so the careful clause contributes nothing (and its presence may mislead reviewers into thinking reads are scoped). The insert policy's check of literal &lt;code&gt;false&lt;/code&gt; means inserts always fail — likely a debugging leftover that broke a feature someone "fixed" elsewhere, worth investigating before deleting. Two findings from three rows, both with evidence attached, neither requiring any judgment beyond the reading rules above.&lt;/p&gt;

&lt;p&gt;Mark each finding high/medium/low as you go — high for anything exposing data now (open tables, unconstrained writes), medium for structure that will misbehave under pressure (tautologies, unhygienic definer functions), low for hygiene (naming, comments, bare auth calls). Severity triage during reading beats severity debate later.&lt;/p&gt;

&lt;p&gt;Also record the &lt;em&gt;absence&lt;/em&gt; findings while reading: tables with zero policies despite being enabled, buckets with no policies at all, views nobody can explain. Absence findings age differently from defect findings — they're often intentional interim states that quietly became permanent — and they're precisely what a fresh reader catches that the schema's author no longer sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hour three: probe from outside
&lt;/h2&gt;

&lt;p&gt;Structural findings predict exposure; probes prove it. Two batteries, both framed entirely within your own project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The anonymous sweep.&lt;/strong&gt; With only your public key, GET every exposed table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://YOUR-PROJECT.supabase.co/rest/v1/TABLE_NAME"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: &lt;/span&gt;&lt;span class="nv"&gt;$ANON_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty array: pass. Rows: a finding, already half-documented from hour two. Also try one insert attempt per sensitive table expecting rejection — anonymous &lt;em&gt;writes&lt;/em&gt; are rarer than reads but strictly worse. If your model includes deliberate public reads (published listings and the like), verify their filters instead: draft rows must stay absent, internal columns must stay out of responses.&lt;/p&gt;

&lt;p&gt;Interpreting responses is mechanical once you've seen each shape:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Response&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Protected — or genuinely empty; note which and confirm later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rows returned&lt;/td&gt;
&lt;td&gt;Open window, live now; capture the response as evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;404 / "relation not found" error&lt;/td&gt;
&lt;td&gt;Table not API-exposed; fine for private tables, check why if public&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;401/403 on an attempted anonymous insert&lt;/td&gt;
&lt;td&gt;Pass — write path rejects unauthenticated callers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two practical notes. First, keep requests modest — a handful of rows per table proves exposure without bulk-downloading anything, which matters both ethically and for your own logs. Second, run probes against production, not staging: staging answers whether your pipeline produces protection, production answers whether protection currently exists, and they differ more often than teams expect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The two-account battery.&lt;/strong&gt; With test accounts Alice and Bob, run the five adversarial probes — anonymous read, forged write, cross-tenant read, ownership transfer, silent delete — exactly as specified in &lt;a href="https://rowshield.dev/blog/three-rls-bugs-that-look-like-one" rel="noopener noreferrer"&gt;the tenant-isolation playbook&lt;/a&gt;. Each probe has a binary expected outcome; record actual outcomes beside expectations. Where hour two predicted defects, these probes supply behavioral proof; where probes fail unexpectedly, hour two's inventory explains why. The two halves of the audit corroborate each other, and disagreements between them are themselves findings — they mean something mediates access outside the obvious path.&lt;/p&gt;

&lt;p&gt;Budget roughly twenty minutes per sensitive table for the full battery, less once practiced. Prioritize tables by what a breach of them means — user credentials and personal data outrank configuration tables — so if the afternoon compresses, the most important tables already ran their probes. The battery is also the audit's most transferable artifact: the same five requests run against any future project, unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hour four: storage, functions, keys
&lt;/h2&gt;

&lt;p&gt;Three remaining surfaces, each with its own quick check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buckets:&lt;/strong&gt; list them with visibility flags. Public buckets get reviewed as published content — is everything inside genuinely meant to be URL-accessible? Private buckets get their own policy review, path-scoping patterns especially (&lt;a href="https://rowshield.dev/docs/rules/public-bucket-exposure" rel="noopener noreferrer"&gt;the own-folder pattern&lt;/a&gt; and its neighbors). Check for the orphan case too: buckets with no policies at all, which behave as write-voids — safe from anonymous access but unusable by legitimate users, usually signaling an abandoned feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Functions:&lt;/strong&gt; for each definer function from hour one, call it as a restricted user and compare results to what that user could derive through policies alone. Pin &lt;code&gt;search_path&lt;/code&gt; wherever missing. Confirm execute privileges match intent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keys:&lt;/strong&gt; grep built assets and environment files for service-grade material, then fetch your deployed site's JavaScript and search it too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"sb_secret_"&lt;/span&gt; dist/ .next/static/ build/ 2&amp;gt;/dev/null
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-ri&lt;/span&gt; &lt;span class="s2"&gt;"service_role"&lt;/span&gt; .env&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt; 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key material anywhere browser-reachable converts this afternoon into an incident-response morning — rotate first, investigate after (&lt;a href="https://rowshield.dev/solutions/leaked-service-role-rotation" rel="noopener noreferrer"&gt;rotation guidance&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Then spend the last thirty minutes writing. Per finding: what's exposed, catalog or probe evidence, proposed fix as concrete SQL, affected features for QA awareness. Sort high-to-low. That document is the audit's deliverable — and next quarter's baseline. It is also the step most easily dropped once the scary finding is fixed, which is how a repeatable program degrades back into an ad-hoc scramble with nothing to compare against.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where manual ends and monitoring begins
&lt;/h2&gt;

&lt;p&gt;An afternoon audit is a snapshot; snapshots age. Migrations ship weekly, dashboards invite hotfixes, restores replace state silently — every channel that makes audits necessary also makes them stale. Re-running this whole sequence monthly is realistic; after every deploy is not, which is precisely the gap automation fills.&lt;/p&gt;

&lt;p&gt;The mapping between this article's steps and continuous coverage is direct: hour one's inventory becomes CI assertions; hour three's probes become scheduled external scans; hour four's key checks become build-time scanning. RowShield automates exactly those translations — &lt;a href="https://rowshield.dev/solutions/free-supabase-rls-audit" rel="noopener noreferrer"&gt;the free scan&lt;/a&gt; covers the outside-probe layer immediately, catalog monitoring extends it — while the judgment calls (is this public bucket intentional? should editors see drafts?) remain yours regardless of tooling.&lt;/p&gt;

&lt;p&gt;Run this audit manually once and you'll understand every automated finding forever after. That's not a consolation prize for lacking tooling — it's the reason the tooling's findings deserve trust. And when the next audit comes around, compare against the last one's document: deltas between snapshots are the drift narrative in miniature, each line either a deliberate change you can name or a finding that arrived uninvited.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is one afternoon really enough?
&lt;/h3&gt;

&lt;p&gt;For small-to-medium projects — say, up to forty tables — yes, comfortably: most time goes to reading and writing up, not querying. Larger schemas split naturally across days by schema area, with the same per-area flow. What doesn't fit in an afternoon is fixing; this produces the prioritized list, and fixes schedule from there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need production access, or is staging enough?
&lt;/h3&gt;

&lt;p&gt;Both, ideally — but production is where truth lives. Staging validates upcoming changes; production holds the accumulated drift of every manual edit and restore since. If forced to choose one, audit production first; staging diverges from it in ways that matter less than the reverse.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if I find something alarming mid-afternoon?
&lt;/h3&gt;

&lt;p&gt;Handle by class. Exposure-that-is-happening-now (open tables, leaked keys): pause the audit, contain, resume — the containment patterns are short and this document's earlier sections link them. Everything else waits for the write-up; alarming-but-contained findings lose nothing from a day's delay in fixing.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I audit a project I can't run locally?
&lt;/h3&gt;

&lt;p&gt;Everything in hours one and two needs only SQL editor access to the live project — read-only catalog queries, safe on production. Hour three's probes hit public endpoints by design. Only fixes require the usual deploy discipline; an audit itself never modifies state, which is worth stating explicitly when requesting access to someone else's project.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should the audit include checking my auth configuration too?
&lt;/h3&gt;

&lt;p&gt;Yes as a fifth quarter-hour: confirm email confirmation requirements, redirect allowlists, and whether unused OAuth providers are disabled. This article scopes to the database and surfaces because that's RowShield's home turf, but an afternoon audit that touches keys might as well confirm the identity layer's basic posture — most of it is reading settings screens.&lt;/p&gt;




&lt;p&gt;Prefer the automated starting point? &lt;a href="https://rowshield.dev/audit" rel="noopener noreferrer"&gt;Run the free scan&lt;/a&gt; — paste your app URL for the outside-probe layer of this audit instantly, then bring the results to your manual session.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;RowShield is an independent product and is not affiliated with, endorsed by, or sponsored by Supabase, Inc.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>vibeguard</category>
      <category>auditing</category>
      <category>a</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Supabase RLS: A 200 Response Does Not Prove Data Exposure</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Tue, 08 Sep 2026 01:55:01 +0000</pubDate>
      <link>https://dev.to/veristria/supabase-rls-a-200-response-does-not-prove-data-exposure-4ce6</link>
      <guid>https://dev.to/veristria/supabase-rls-a-200-response-does-not-prove-data-exposure-4ce6</guid>
      <description>&lt;p&gt;A public table endpoint can return HTTP 200 with an empty JSON array. That proves reachability, not data exposure.&lt;/p&gt;

&lt;p&gt;The useful question is whether the policies return the right rows for each identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small verification matrix
&lt;/h2&gt;

&lt;p&gt;Test the same read-only query as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an anonymous visitor&lt;/li&gt;
&lt;li&gt;the row owner&lt;/li&gt;
&lt;li&gt;an authenticated non-owner&lt;/li&gt;
&lt;li&gt;a service role, kept strictly server-side&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For every path, record the status, row count, and whether returned records belong to the expected tenant. Repeat the matrix after each migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the result means
&lt;/h2&gt;

&lt;p&gt;An empty anonymous result can be exactly correct. A non-owner receiving another tenant's row is evidence of a policy problem. Treat those as different findings.&lt;/p&gt;

&lt;p&gt;Do not use destructive test queries against production. Start with read-only checks in a controlled environment and document what was tested.&lt;/p&gt;

&lt;p&gt;You can run a free, read-only RLS check at &lt;a href="https://rowshield.dev/audit" rel="noopener noreferrer"&gt;https://rowshield.dev/audit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The check is evidence, not a guarantee: application logic, privileged server paths, and changes made after the test still need review.&lt;/p&gt;

</description>
      <category>supabase</category>
    </item>
    <item>
      <title>RLS policies do not fail. They drift.</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Tue, 01 Sep 2026 18:56:17 +0000</pubDate>
      <link>https://dev.to/veristria/rls-policies-do-not-fail-they-drift-34ae</link>
      <guid>https://dev.to/veristria/rls-policies-do-not-fail-they-drift-34ae</guid>
      <description>&lt;h1&gt;
  
  
  RLS policies do not fail. They drift.
&lt;/h1&gt;

&lt;p&gt;Row-level security has an unusual failure mode. A policy does not break in the way code breaks. It does not throw, it does not fail a test, it does not appear in an error log. It keeps running exactly as written, and at some point what it was written for stops being what it does.&lt;/p&gt;

&lt;p&gt;The schema moved. The policy did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the drift
&lt;/h2&gt;

&lt;p&gt;Here is a policy that is completely correct on the day it is written.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"own_documents"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every row in &lt;code&gt;documents&lt;/code&gt; has an owner. A user reads their own. Nothing else. Correct.&lt;/p&gt;

&lt;p&gt;Three months later the product grows a sharing feature. A &lt;code&gt;document_collaborators&lt;/code&gt; table appears, and a new query joins it. The policy above is still there, still parses, still runs — and now the team writes a second policy to cover the new access path, because the first one does not.&lt;/p&gt;

&lt;p&gt;Two months after that, someone adds an &lt;code&gt;organisation_id&lt;/code&gt; column to &lt;code&gt;documents&lt;/code&gt; and a dashboard that lists documents by organisation. The dashboard returns nothing, because &lt;code&gt;own_documents&lt;/code&gt; filters by &lt;code&gt;owner_id&lt;/code&gt;. So a third policy is added for organisation members.&lt;/p&gt;

&lt;p&gt;Nothing here is negligent. Every step was a reasonable response to a real requirement. But &lt;code&gt;documents&lt;/code&gt; now has three overlapping &lt;code&gt;select&lt;/code&gt; policies, and Postgres combines multiple permissive policies with &lt;code&gt;OR&lt;/code&gt;. The effective access rule is the &lt;strong&gt;union&lt;/strong&gt; of all three — which is not what any of them says on its own, and is not something anybody has written down.&lt;/p&gt;

&lt;p&gt;That is drift. Not a broken policy: a set of individually correct policies whose combination nobody has evaluated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four ways it happens
&lt;/h2&gt;

&lt;p&gt;In practice, almost all of it comes from four events.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A new table ships with no policy at all.&lt;/strong&gt; If RLS is not enabled on a table, the anon key reads it. This is the loudest version of the problem and still the most common, because enabling RLS is a separate step from creating the table, and a table created by a migration written at speed does not always get both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A column widens what an existing policy exposes.&lt;/strong&gt; The policy filters rows, not columns. Adding a column to a table that is already readable makes that column readable too — including the one holding an email address, an internal note or a partially masked identifier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A new access path routes around the policy.&lt;/strong&gt; A view, an RPC or a security-definer function that queries the table on the caller's behalf. Postgres views do not inherit the underlying table's RLS unless they are explicitly set up to; a security-definer function bypasses it by design. Both are legitimate tools, and both are ways for a row to arrive somewhere the policy never approved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Policies accumulate.&lt;/strong&gt; As above. Each addition is safe in isolation and the union is never reviewed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why reading the migrations does not catch it
&lt;/h2&gt;

&lt;p&gt;The instinct is to review this statically: read the policy files, reason about the schema, check the logic. It does not work well, for the same reason reading code is a weak substitute for running it.&lt;/p&gt;

&lt;p&gt;A policy's behaviour depends on the current schema, the current set of other policies on the same table, the current role, and the current contents of &lt;code&gt;auth.uid()&lt;/code&gt;. Reasoning about the union of three policies against a schema that has changed twice since they were written is exactly the kind of thing humans get wrong — and it has to be redone every time any of those inputs changes.&lt;/p&gt;

&lt;p&gt;The alternative is to ask the database. Not "what does this policy say", but "as this role, with this token, can I read this row". That question has a definite answer, it is cheap to ask, and it is correct by construction, because it is the same code path a real request takes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check, in order
&lt;/h2&gt;

&lt;p&gt;If you want to do this by hand today, this is the order that finds the most in the least time.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every table with RLS disabled.&lt;/strong&gt; Query &lt;code&gt;pg_tables&lt;/code&gt; for &lt;code&gt;rowsecurity = false&lt;/code&gt; in your public schema. Each one is readable by anyone holding the anon key, which is in your frontend bundle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every table with RLS enabled and no policies.&lt;/strong&gt; This is the reverse failure: RLS on, no policy, everything denied. Usually it produces a bug report rather than a breach, but it tells you the table was set up in a hurry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tables with more than one permissive policy for the same operation.&lt;/strong&gt; These are the union cases. Write down what the combination actually permits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Views and security-definer functions that touch protected tables.&lt;/strong&gt; Check each one for whether it is enforcing RLS or bypassing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then test it.&lt;/strong&gt; For each protected table, hold an anon token and a token for a user who should not have access, and try to read a row. The result is the only answer that counts.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The point
&lt;/h2&gt;

&lt;p&gt;RLS is a good mechanism. It is enforced in the right place, it is hard to bypass by accident from application code, and it survives a careless client.&lt;/p&gt;

&lt;p&gt;What it does not do is notice when the world around it changes. The policy you wrote in April is still doing precisely what you asked it to in April. Whether that is still the right thing is a question somebody has to keep asking — and the honest way to ask it is to run the query and look at what comes back.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://rowshield.dev/audit" rel="noopener noreferrer"&gt;Run a free audit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;RowShield is an independent product with no affiliation to, or endorsement from, Supabase.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>rls</category>
    </item>
    <item>
      <title>How to audit 90 days of platform refunds by hand</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Tue, 01 Sep 2026 13:50:02 +0000</pubDate>
      <link>https://dev.to/veristria/how-to-audit-90-days-of-platform-refunds-by-hand-5526</link>
      <guid>https://dev.to/veristria/how-to-audit-90-days-of-platform-refunds-by-hand-5526</guid>
      <description>&lt;h1&gt;
  
  
  How to audit 90 days of platform refunds by hand
&lt;/h1&gt;

&lt;h1&gt;
  
  
  How to audit 90 days of platform refunds by hand
&lt;/h1&gt;

&lt;p&gt;How much money did the last 90 days of refunds fail to return — to your platform, or to the sellers on it? This guide walks through the complete manual answer: the API queries, the spreadsheet, the two formulas, and a disputes pass. Everything runs on a restricted read-only key. No product is required at any step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope and inputs
&lt;/h2&gt;

&lt;p&gt;The audit asks two questions of every refund issued in the trailing 90 days. Did the transfer get reversed when your refund policy says it should have been? And did the application fee come back when policy says it should? Two formulas answer both, and the audit's output is one number per gap class, in cents, ready to sum.&lt;/p&gt;

&lt;p&gt;Anchor the window at run time. Ninety days is 7,776,000 seconds, so the lower bound in Unix seconds is &lt;code&gt;now − 7,776,000&lt;/code&gt;. Treat the window as half-open — everything with &lt;code&gt;created &amp;gt;= start&lt;/code&gt; and &lt;code&gt;created &amp;lt; end&lt;/code&gt; — so reruns on consecutive days never double-count a boundary refund.&lt;/p&gt;

&lt;p&gt;Two extraction routes exist. The Dashboard route exports payments filtered to refunded status and joins everything in a spreadsheet. The API route lists refunds directly. Prefer the API route: the joins this audit needs run on object IDs — &lt;code&gt;transfer&lt;/code&gt;, &lt;code&gt;application_fee&lt;/code&gt;, reversal records — and exports make those joins painful in ways the API makes trivial.&lt;/p&gt;

&lt;p&gt;Access needs less privilege than the task sounds like it should. Stripe supports restricted API keys scoped per resource, with read or write access chosen per resource, so a read-only key over the relevant families audits your money while holding no money-movement rights at all (&lt;a href="https://docs.stripe.com/keys" rel="noopener noreferrer"&gt;keys&lt;/a&gt;). Never paste a live secret key into a script; read it from an environment variable even for a one-off.&lt;/p&gt;

&lt;p&gt;Five object families feed the audit; the table shows what each contributes and the minimum access it needs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Object family&lt;/th&gt;
&lt;th&gt;What it contributes&lt;/th&gt;
&lt;th&gt;Minimum access&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Refunds (&lt;code&gt;/v1/refunds&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;The refund rows themselves: amount, currency, dates&lt;/td&gt;
&lt;td&gt;read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Charges (&lt;code&gt;/v1/charges&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Parent amount, &lt;code&gt;transfer&lt;/code&gt; ID, &lt;code&gt;application_fee&lt;/code&gt; ID&lt;/td&gt;
&lt;td&gt;read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transfers and their reversals&lt;/td&gt;
&lt;td&gt;What was pulled back from sellers, and when&lt;/td&gt;
&lt;td&gt;read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application fees and their refunds&lt;/td&gt;
&lt;td&gt;Whether the commission came back&lt;/td&gt;
&lt;td&gt;read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disputes&lt;/td&gt;
&lt;td&gt;Lost cases, which need their own pass below&lt;/td&gt;
&lt;td&gt;read&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you want this same procedure formatted as a step-by-step companion, the &lt;a href="https://feeguard.dev/support/su-03-running-your-historical-scan-90-day-lookback" rel="noopener noreferrer"&gt;90-day lookback walkthrough&lt;/a&gt; follows the identical order: extract, transform, score, sum.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extract: the queries
&lt;/h2&gt;

&lt;p&gt;Start by listing refunds created inside the window. The &lt;code&gt;created&lt;/code&gt; filter takes Unix seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://api.stripe.com/v1/refunds?created[gte]=1771977600&amp;amp;created[lt]=1779753600&amp;amp;limit=100"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="s2"&gt;:"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The example bounds cover 2026-02-25 through 2026-05-26 UTC — exactly 90 days, half-open. Swap in your own dates. &lt;code&gt;limit=100&lt;/code&gt; is the page size; walk the remaining pages with the follow-up cursor automatically, as documented under &lt;a href="https://docs.stripe.com/api/pagination/auto" rel="noopener noreferrer"&gt;auto-pagination&lt;/a&gt;. In TypeScript with the official SDK, the paginator does that walking for you, and expanding the parent charge inline saves one request per refund:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STRIPE_SECRET_KEY is not set&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;windowStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;refund_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;charge_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;charge_amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;amount_refunded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;transfer_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;application_fee_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;windowStart&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;expand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data.charge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Charge&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;refund_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;charge_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;charge_amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;amount_refunded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;transfer_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;application_fee_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application_fee&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application_fee&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; refunds pulled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each refund reaches its parents through the expanded charge: the charge carries &lt;code&gt;amount&lt;/code&gt;, the &lt;code&gt;transfer&lt;/code&gt; ID on destination charges, and the &lt;code&gt;application_fee&lt;/code&gt; ID wherever fees were collected. On a direct-charge fleet, none of those IDs sit on the platform side — the objects live on each connected account, so query account by account by passing the account in request options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;perAccount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;windowStart&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stripeAccount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;acct_PLACEHOLDER&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loop that over your connected accounts and merge the rows; the rest of the audit is identical from there. Key the merge on charge ID plus refund ID, since both survive the trip across account boundaries unchanged.&lt;/p&gt;

&lt;p&gt;With rows in hand, enrich each one: fetch the transfer with its reversals expanded, fetch the application fee with its refunds expanded, and compute the two scores the next sections derive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer_id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application_fee_id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transfer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;expand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reversals&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reversalsSum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reversals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expectedReversal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_refunded&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge_amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;applicationFees&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application_fee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;expand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;refunds&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;feeRefundedSum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expectedFeeRefund&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_refunded&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge_amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refund_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expectedReversal&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;reversalsSum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expectedFeeRefund&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;feeRefundedSum&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same logic translates directly to other languages; if your team maintains Python tooling, the &lt;a href="https://feeguard.dev/integrations/python-reconciliation-scripts" rel="noopener noreferrer"&gt;Python reconciliation scripts&lt;/a&gt; page shows the equivalent requests end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transform: build the sheet
&lt;/h2&gt;

&lt;p&gt;One row per refund. The input columns come straight off the objects; the table maps each column to its source.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Pulled from&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;refund_id&lt;/code&gt;, &lt;code&gt;currency&lt;/code&gt;, &lt;code&gt;refund_created&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Refund&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;charge_id&lt;/code&gt;, &lt;code&gt;charge_created&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Parent charge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;charge_amount_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;charge.amount&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;amount_refunded_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;refund.amount&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transfer_amount_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;transfer.amount&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reversals_sum_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sum of the transfer's reversal amounts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app_fee_amount_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;application_fee.amount&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fee_refunded_sum_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sum of the application fee's refund amounts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four more columns are computed, not fetched; the table defines each rule, and the next section grounds the two that matter.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Computed column&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expected_reversal_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;round(amount_refunded_cents / charge_amount_cents * transfer_amount_cents)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;missing_transfer_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;expected_reversal_cents - reversals_sum_cents&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expected_fee_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;round(amount_refunded_cents / charge_amount_cents * app_fee_amount_cents)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;missing_fee_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;expected_fee_cents - fee_refunded_sum_cents&lt;/code&gt;, scored only where refund policy returns fees&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three housekeeping rules keep the sheet honest. Keep one sheet per currency and never sum cents across currencies. Store every amount as an integer in the smallest currency unit and convert to dollars only when printing. And format ID columns as text before pasting, because spreadsheets quietly mangle long alphanumeric tokens into something that no longer joins. Charges refunded in several installments simply occupy several rows; the formulas score each independently, and the cumulative check in the next section catches any drift between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two formulas
&lt;/h2&gt;

&lt;p&gt;Everything reduces to proportionality. Write the rules once, in cents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;expected_reversal = round((amount_refunded / charge_amount) * transfer_amount)
missing_transfer  = expected_reversal - reversals_sum
expected_fee      = round((amount_refunded / charge_amount) * app_fee_amount)
missing_fee       = expected_fee - fee_refunded_sum     (scored only if policy returns the fee)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proportionality is not a modeling choice — it mirrors Stripe's own behavior. Full refunds of destination charges reverse the entire transfer when &lt;code&gt;reverse_transfer=true&lt;/code&gt;, and partial refunds reverse a proportional amount (&lt;a href="https://docs.stripe.com/connect/destination-charges" rel="noopener noreferrer"&gt;destination charges&lt;/a&gt;). The same parameter is documented the same way at the API level: the transfer is reversed proportionally to the amount being refunded (&lt;a href="https://docs.stripe.com/api/refunds/create" rel="noopener noreferrer"&gt;refunds create&lt;/a&gt;). Application fees behave symmetrically: refunded in full on a full refund, proportionally on a partial one, when &lt;code&gt;refund_application_fee&lt;/code&gt; is set.&lt;/p&gt;

&lt;p&gt;Separate charges and transfers break the symmetry, and the formulas expose it: refunding the charge has no impact on any associated transfers, so &lt;code&gt;reversals_sum_cents&lt;/code&gt; stays zero no matter how diligent you were at refund time (&lt;a href="https://docs.stripe.com/connect/separate-charges-and-transfers" rel="noopener noreferrer"&gt;separate charges and transfers&lt;/a&gt;). Those rows score against whatever reversal you performed manually — often none.&lt;/p&gt;

&lt;p&gt;One caveat on rounding. When several partial refunds hit one charge, each row rounds independently, and independent rounding can drift a cent or two from the cumulative truth. Score per charge, cumulatively, before believing any single row: if the sum of &lt;code&gt;amount_refunded&lt;/code&gt; equals the charge amount, the expected cumulative reversal is exactly the whole transfer; between boundaries, accept dust of a cent or less per charge and investigate anything larger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worked rows
&lt;/h2&gt;

&lt;p&gt;Three synthetic rows exercise every branch of the sheet. Assumptions, labeled as assumptions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;US platform, USD, Stripe's standard US card pricing of 2.9% + $0.30 (&lt;a href="https://stripe.com/pricing" rel="noopener noreferrer"&gt;published pricing&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Charge of $100.00 = 10000¢, application fee $10.00 = 1000¢, Stripe processing fee $3.20 = 320¢.&lt;/li&gt;
&lt;li&gt;Destination-charge transfer $90.00 = 9000¢; separate-charge transfer $70.00 = 7000¢.&lt;/li&gt;
&lt;li&gt;Row B is a 40% partial refund issued with both &lt;code&gt;reverse_transfer=true&lt;/code&gt; and &lt;code&gt;refund_application_fee=true&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All amounts in cents. The table shows inputs left of &lt;code&gt;expected_reversal&lt;/code&gt; and scores right of it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Row&lt;/th&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;charge_amount&lt;/th&gt;
&lt;th&gt;amount_refunded&lt;/th&gt;
&lt;th&gt;transfer_amount&lt;/th&gt;
&lt;th&gt;Σ reversals&lt;/th&gt;
&lt;th&gt;Σ fee refunds&lt;/th&gt;
&lt;th&gt;expected_reversal&lt;/th&gt;
&lt;th&gt;missing_transfer&lt;/th&gt;
&lt;th&gt;missing_fee&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;Destination, full refund, no flags&lt;/td&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;9000&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;9000&lt;/td&gt;
&lt;td&gt;9000&lt;/td&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;Destination, 40% refund, both flags&lt;/td&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;4000&lt;/td&gt;
&lt;td&gt;9000&lt;/td&gt;
&lt;td&gt;3600&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;3600&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;Separate, full refund&lt;/td&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;7000&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;7000&lt;/td&gt;
&lt;td&gt;7000&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SUM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1000&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The arithmetic, line by line:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Row A: &lt;code&gt;round(10000 / 10000 * 9000) = 9000&lt;/code&gt;, so missing = 9000 − 0 = 9000, which is &lt;strong&gt;$90.00&lt;/strong&gt; of transfer that stayed with the seller. The fee: &lt;code&gt;round(1.0 * 1000) = 1000&lt;/code&gt; against 0 refunded, so &lt;strong&gt;$10.00&lt;/strong&gt; kept — the default on destination charges unless the platform acts.&lt;/li&gt;
&lt;li&gt;Row B: &lt;code&gt;round(4000 / 10000 * 9000) = round(3600) = 3600&lt;/code&gt;, so missing = 3600 − 3600 = &lt;strong&gt;0&lt;/strong&gt;. The fee: &lt;code&gt;round(4000 / 10000 * 1000) = 400&lt;/code&gt; against 400 refunded, also &lt;strong&gt;0&lt;/strong&gt;. A correctly flagged partial refund scores clean.&lt;/li&gt;
&lt;li&gt;Row C: &lt;code&gt;round(10000 / 10000 * 7000) = 7000&lt;/code&gt;, so missing = 7000 − 0 = &lt;strong&gt;$70.00&lt;/strong&gt; — the full transfer, outstanding until someone reverses it manually, which itself succeeds only if the seller's available balance covers it (&lt;a href="https://feeguard.dev/recover/separate-charges-transfers-recovery" rel="noopener noreferrer"&gt;separate charges and transfers&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Totals: 9000 + 0 + 7000 = 16000¢ = &lt;strong&gt;$160.00&lt;/strong&gt; of unreversed transfer value, plus 1000¢ = &lt;strong&gt;$10.00&lt;/strong&gt; of retained fee, across three toy refunds. On real data these columns are sums, and the SUM row is the number that goes in front of decision makers. Keep the &lt;a href="https://feeguard.dev/calculator/checklist-refund-path-audit-download" rel="noopener noreferrer"&gt;refund-path audit checklist&lt;/a&gt; beside the sheet so each row's path classification stays consistent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The disputes pass
&lt;/h2&gt;

&lt;p&gt;Refunds are half the exposure. Lost disputes debit the platform on destination and separate charges — disputed amount plus dispute fee — and recovering from the seller is a manual transfer reversal, exactly like a refund that was issued without its flags (&lt;a href="https://docs.stripe.com/connect/disputes" rel="noopener noreferrer"&gt;Connect disputes&lt;/a&gt;). So the audit repeats itself over disputes closed as lost inside the window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dispute&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disputes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;windowStart&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;expand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data.charge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data.charge.transfer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dispute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lost&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dispute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Charge&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dispute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dispute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://feeguard.dev/docs/charge.dispute.closed" rel="noopener noreferrer"&gt;charge.dispute.closed&lt;/a&gt; explainer covers the event side if you would rather catch these as they happen. For each lost dispute, ask the sheet the same question: was the transfer reversed around the loss?&lt;/p&gt;

&lt;p&gt;One worked row, assumptions labeled: destination charge of $100.00, transfer $90.00, dispute fee $15.00 at standard US pricing (&lt;a href="https://stripe.com/pricing" rel="noopener noreferrer"&gt;published pricing&lt;/a&gt;), no reversal ever made.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dispute&lt;/th&gt;
&lt;th&gt;disputed&lt;/th&gt;
&lt;th&gt;fee&lt;/th&gt;
&lt;th&gt;transfer_amount&lt;/th&gt;
&lt;th&gt;Σ reversals&lt;/th&gt;
&lt;th&gt;uncovered_seller_side&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;dp_1&lt;/td&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;1500&lt;/td&gt;
&lt;td&gt;9000&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;9000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Arithmetic: uncovered = 9000 − 0 = 9000, so &lt;strong&gt;$90.00&lt;/strong&gt; could still be reclaimed from the seller, while the $15.00 fee has no reversal to ride on and is gone regardless.&lt;/p&gt;

&lt;p&gt;Fold the pass into the total: 16000 + 9000 = 25000¢ of transfer-class gaps, plus the 1000¢ fee-class gap — &lt;strong&gt;$260.00&lt;/strong&gt; identified across five synthetic rows, every cent of it traced to a specific object ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you will find
&lt;/h2&gt;

&lt;p&gt;Expect shape, not scatter. The findings below are qualitative patterns this audit reliably surfaces; your sheet supplies the magnitudes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero-reversal clusters concentrate.&lt;/strong&gt; Sort &lt;code&gt;missing_transfer_cents&lt;/code&gt; by the code path or dashboard origin that issued each refund and specific branches dominate — the places where refund calls went out without &lt;code&gt;reverse_transfer&lt;/code&gt;, or fee refunds were never chained. Defaults leak where they were coded in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Age destroys recoverability.&lt;/strong&gt; Recent gaps can often still be reclaimed. Old ones frequently cannot, in place: the seller's balance was paid out long ago, so the money exists only as a claim, not a balance. Those rows migrate from "reverse it" to "net it or negotiate it."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rounding dust accumulates.&lt;/strong&gt; Rows scoring ±1¢ from multi-partial charges are noise; let them cancel rather than chasing them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pending stragglers linger.&lt;/strong&gt; Refunds sitting in &lt;code&gt;pending&lt;/code&gt; on underfunded connected accounts look like leaks in the raw data but have simply not executed yet; park them on a separate tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate-charge orphans surface.&lt;/strong&gt; Transfers nobody associated with the refunds they survived, because nothing in the object graph connects them — only your records do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The distribution is the diagnostic. Uniform spread suggests nothing actionable; concentration points at a fixable call site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one pass is not enough
&lt;/h2&gt;

&lt;p&gt;A snapshot ages instantly. Tomorrow's refunds repeat today's pattern, so a quarterly habit means quarters of accumulation between passes, and the oldest rows rot from "recoverable" to "write-off" while they sit. If the SUM row justifies acting at all, it eventually justifies running the identical queries continuously — the two formulas do not change week to week; only the data does. Remediation of what you find has its own procedures, from the &lt;a href="https://feeguard.dev/recover/bulk-reversal-of-historical-findings" rel="noopener noreferrer"&gt;bulk reversal of historical findings&lt;/a&gt; playbook onward, and continuous versions of this same arithmetic are precisely what ongoing monitoring automates, FeeGuard included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does this audit need write access to my Stripe account?
&lt;/h3&gt;

&lt;p&gt;No. Restricted keys scope access per resource with read or write chosen separately, so read-only over refunds, charges, transfers, application fees, and disputes covers the entire procedure (&lt;a href="https://docs.stripe.com/keys" rel="noopener noreferrer"&gt;keys&lt;/a&gt;). The key never moves money, and it should still live in an environment variable rather than in the script.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I do all of this from Dashboard CSV exports?
&lt;/h3&gt;

&lt;p&gt;Partially. The payments export gives you refunds and their charges, but reversals and application-fee refunds live in separate exports, and joining them means manual key matching in a spreadsheet. It works; it is slower and frailer. The &lt;a href="https://feeguard.dev/recover/recover-from-excel-export" rel="noopener noreferrer"&gt;Excel-export recovery workflow&lt;/a&gt; walks the export-based variant if you cannot use the API.&lt;/p&gt;

&lt;h3&gt;
  
  
  We settle in multiple currencies — what changes?
&lt;/h3&gt;

&lt;p&gt;Only discipline, not method. Run one sheet per currency, convert nothing until the final report, and remember that in zero-decimal currencies like JPY the integer amounts are already the major unit, so "cents" columns become "yen" columns (&lt;a href="https://docs.stripe.com/currencies" rel="noopener noreferrer"&gt;supported currencies&lt;/a&gt;). Never sum a USD column onto a EUR column.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about refunds still showing as &lt;code&gt;pending&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;Score them, but on their own tab. A pending refund has not moved money yet — on direct charges it waits for the connected account's balance to fund it, then processes automatically (&lt;a href="https://docs.stripe.com/connect/charges" rel="noopener noreferrer"&gt;connect charges&lt;/a&gt;). Recheck them before you publish totals, because some will resolve themselves and some will reveal a seller whose balance never recovers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the 90-day audit
&lt;/h2&gt;

&lt;p&gt;Nothing above requires anything but a spreadsheet and an afternoon, and rerunning it weekly is exactly as tedious as it sounds. FeeGuard exists because this arithmetic runs silently on every refund your platform issues, and the running total almost never appears anywhere. The free audit reads your last 90 days of Connect activity through a restricted, read-only API key and reports every unreversed transfer, unreturned application fee, and uncovered dispute loss with the amounts attached. You get the answer first; ongoing monitoring stays optional afterward.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://feeguard.dev/audit" rel="noopener noreferrer"&gt;Run the free 90-day audit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;FeeGuard is an independent product and is not affiliated with, endorsed by, or sponsored by Stripe, Inc.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>feeguard</category>
      <category>audit</category>
      <category>90</category>
      <category>days</category>
    </item>
    <item>
      <title>Build your own fee-leakage detection with the Stripe API</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Mon, 31 Aug 2026 13:30:15 +0000</pubDate>
      <link>https://dev.to/veristria/build-your-own-fee-leakage-detection-with-the-stripe-api-48ch</link>
      <guid>https://dev.to/veristria/build-your-own-fee-leakage-detection-with-the-stripe-api-48ch</guid>
      <description>&lt;h1&gt;
  
  
  Build your own fee-leakage detection with the Stripe API
&lt;/h1&gt;

&lt;h1&gt;
  
  
  Build your own fee-leakage detection with the Stripe API
&lt;/h1&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture in one diagram
&lt;/h2&gt;

&lt;p&gt;Five components, one data flow, no money movement anywhere in the pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lister jobs ──▶ normalizer ──▶ expectation engine ──▶ findings store ──▶ alerter
     │             │                │                    │               │
     └─────────────┴───── read-only restricted key throughout ─────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Every component reads through the same restricted key. Stripe supports restricted API keys scoped per resource with read or write access (&lt;a href="https://docs.stripe.com/keys" rel="noopener noreferrer"&gt;API keys&lt;/a&gt;), 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 &lt;a href="https://feeguard.dev/vs/homegrown-scripts" rel="noopener noreferrer"&gt;homegrown scripts versus managed detection&lt;/a&gt;; here we build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoping credentials safely
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_RESTRICTED_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pulling the corpus
&lt;/h2&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CorpusRow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Refund&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Charge&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;transferAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;reversedCents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;feeAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;feeRefundedCents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pullRefundRows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sinceUnix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CorpusRow&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CorpusRow&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refunds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sinceUnix&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;expand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data.charge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Refund&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CorpusRow&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Charge&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CorpusRow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;transferAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;reversedCents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;feeAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;feeRefundedCents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transferId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;transferId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reversals&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
      &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transferId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listReversals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transferId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transferAmount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reversedCents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reversals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;rev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;feeId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application_fee&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application_fee&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application_fee&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;feeId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;applicationFees&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;feeId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feeAmount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feeRefundedCents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_refunded&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;Direct-charge fleets.&lt;/strong&gt; On direct charges, refunds live on the connected account, not your platform — the objects are created under the connected account's context (&lt;a href="https://docs.stripe.com/connect/direct-charges" rel="noopener noreferrer"&gt;direct charges&lt;/a&gt;), so the platform-side list above never sees them. Iterate known account IDs and pass each through the header-scoped call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pullForAccount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sinceUnix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CorpusRow&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CorpusRow&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refunds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sinceUnix&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stripeAccount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;accountId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  Expectation functions
&lt;/h2&gt;

&lt;p&gt;Two formulas carry the whole method. Both take integer cents and return integer cents; both assume nothing beyond the objects you already hold.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;expectedReversal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;refundedCents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;chargeCents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;transferCents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chargeCents&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;transferCents&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;refundedCents&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;chargeCents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;transferCents&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;expectedFeeRefund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;refundedCents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;chargeCents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;feeCents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chargeCents&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;feeCents&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;refundedCents&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;chargeCents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;feeCents&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proportional-reversal shape is not arbitrary: it mirrors what Stripe itself does when &lt;code&gt;reverse_transfer=true&lt;/code&gt; rides on a partial refund — the transfer is reversed proportionally to the amount being refunded (&lt;a href="https://docs.stripe.com/api/refunds/create" rel="noopener noreferrer"&gt;Refunds API&lt;/a&gt;). The fee analog follows identically from full-with-full, partial-with-proportional semantics.&lt;/p&gt;

&lt;p&gt;The critical discipline is what &lt;code&gt;refundedCents&lt;/code&gt; means: &lt;strong&gt;the charge-level cumulative&lt;/strong&gt;, not each refund's individual amount. Compute it from &lt;code&gt;charge.amount_refunded&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning diffs into findings
&lt;/h2&gt;

&lt;p&gt;Group corpus rows by charge, evaluate both expectations once per group, and subtract what actually happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Finding&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;charge_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;missing_transfer_cents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;missing_fee_cents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;age_days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildFindings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;corpus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CorpusRow&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;nowUnix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Finding&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;byCharge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;CorpusRow&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;corpus&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;byCharge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;byCharge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Finding&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;chargeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;byCharge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refundedSoFar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_refunded&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;missingTransfer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;missingFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transferAmount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;expectedReversal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refundedSoFar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transferAmount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;missingTransfer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reversedCents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feeAmount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feeRefundedCents&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expectedFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;expectedFeeRefund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refundedSoFar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feeAmount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;missingFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expectedFee&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feeRefundedCents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;missingTransfer&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;missingFee&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;charge_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chargeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;missing_transfer_cents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;missingTransfer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;missing_fee_cents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;missingFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;age_days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;nowUnix&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"charge_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ch_ExampleFullDefault"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"missing_transfer_cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"missing_fee_cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"usd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"age_days"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;41&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"charge_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ch_ExamplePartialFlag"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"missing_transfer_cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"missing_fee_cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"usd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"age_days"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"charge_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ch_ExampleFeeOnlyFlag"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"missing_transfer_cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"missing_fee_cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"usd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"age_days"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;reverse_transfer=true&lt;/code&gt;; the proportional reversal landed (expected round((4000 ÷ 10000) × 9000) = 3600¢, actual 3600¢) but the proportional fee refund of 400¢ did not. The third had &lt;code&gt;refund_application_fee=true&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scheduling, cursors, idempotency
&lt;/h2&gt;

&lt;p&gt;Detection is a loop, and loops need memory. Persist the last successful scan boundary and resume from it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;writeFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CURSOR_PATH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./refund-cursor.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DAY_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadCursor&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CURSOR_PATH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;created_gt&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;DAY_SECONDS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;saveCursor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CURSOR_PATH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;created_gt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unix&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runScheduledScan&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;loadCursor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pullRefundRows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;findings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildFindings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;storeFindings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;saveCursor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;catch&lt;/code&gt; 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 (&lt;a href="https://feeguard.dev/recover/bulk-reversal-of-historical-findings" rel="noopener noreferrer"&gt;historical findings playbook&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;One reservation: &lt;code&gt;Idempotency-Key&lt;/code&gt; headers belong to the &lt;em&gt;action&lt;/em&gt; 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 (&lt;a href="https://docs.stripe.com/api/idempotency" rel="noopener noreferrer"&gt;idempotent requests&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createReversal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;transferId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;finding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;missing_transfer_cents&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`reverse-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;finding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;policyVersion&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 (&lt;a href="https://feeguard.dev/recover/idempotency-for-reversals" rel="noopener noreferrer"&gt;idempotency for reversals&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The event-driven variant
&lt;/h2&gt;

&lt;p&gt;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: &lt;code&gt;refund.created&lt;/code&gt;, &lt;code&gt;refund.updated&lt;/code&gt;, &lt;code&gt;charge.refunded&lt;/code&gt;, &lt;code&gt;application_fee.created&lt;/code&gt;, &lt;code&gt;application_fee.refunded&lt;/code&gt;, &lt;code&gt;transfer.created&lt;/code&gt;, &lt;code&gt;transfer.reversed&lt;/code&gt;, plus the dispute events (&lt;code&gt;charge.dispute.created&lt;/code&gt;, &lt;code&gt;charge.dispute.updated&lt;/code&gt;, &lt;code&gt;charge.dispute.closed&lt;/code&gt;). Verify signatures, respond quickly, process asynchronously — delivery is at-least-once with retries up to roughly three days, so handlers must be idempotent (&lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;webhooks&lt;/a&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;endpointSecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endpointSecret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;refund.updated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;charge.refunded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application_fee.refunded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transfer.reversed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;charge.dispute.closed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4242&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;buildFindings&lt;/code&gt; 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 &lt;a href="https://feeguard.dev/integrations/node-express-refund-handling" rel="noopener noreferrer"&gt;Node refund-handling guide&lt;/a&gt; and the &lt;a href="https://feeguard.dev/integrations/queue-based-webhook-processing" rel="noopener noreferrer"&gt;queue-based processing guide&lt;/a&gt;; the general trade space between these approaches is mapped at &lt;a href="https://feeguard.dev/vs/building-on-webhooks-yourself" rel="noopener noreferrer"&gt;building on webhooks yourself&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Maintenance reality
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema and API drift.&lt;/strong&gt; 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 &lt;code&gt;amount_refunded&lt;/code&gt; staying cumulative.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-currency conversions.&lt;/strong&gt; 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 (&lt;a href="https://docs.stripe.com/connect/currencies/fx-quotes-api" rel="noopener noreferrer"&gt;FX on refunds&lt;/a&gt;). 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 (&lt;a href="https://docs.stripe.com/currencies#zero-decimal" rel="noopener noreferrer"&gt;currencies&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source_transaction timing.&lt;/strong&gt; Transfers created with &lt;code&gt;source_transaction&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partial-refund sequences.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pending and failed refunds.&lt;/strong&gt; A refund debited against an insufficient balance sits in &lt;code&gt;pending&lt;/code&gt; and may lack a balance transaction until funded; failed refunds return funds within up to roughly thirty days and carry &lt;code&gt;failure_balance_transaction&lt;/code&gt; and &lt;code&gt;failure_reason&lt;/code&gt; (&lt;a href="https://docs.stripe.com/refunds" rel="noopener noreferrer"&gt;refunds&lt;/a&gt;). Scan loops must skip-and-recheck rather than treat either state as final.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direct-charge fleets.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disputes as their own lane.&lt;/strong&gt; On destination and separate charges the disputed amount and the dispute fee debit the platform balance, and recovering from a seller is manual (&lt;a href="https://docs.stripe.com/connect/disputes" rel="noopener noreferrer"&gt;disputes on Connect&lt;/a&gt;). Extending the engine to covered-loss checks means modeling dispute outcomes, win rates, and re-transfer decisions — genuinely worth doing, genuinely separate work.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why Math.round specifically?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do zero-decimal currencies break the formulas?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not compute expectations inside Sigma?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  How should the scanner treat pending refunds?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  When exactly do I add Idempotency-Key headers?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the free 90-day audit
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://feeguard.dev/audit" rel="noopener noreferrer"&gt;Run the free 90-day audit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;FeeGuard is an independent product and is not affiliated with, endorsed by, or sponsored by Stripe, Inc.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>feeguard</category>
      <category>build</category>
      <category>your</category>
      <category>own</category>
    </item>
    <item>
      <title>Your NEXT_PUBLIC secret is already in the browser bundle</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Fri, 28 Aug 2026 20:59:58 +0000</pubDate>
      <link>https://dev.to/veristria/your-nextpublic-secret-is-already-in-the-browser-bundle-2ige</link>
      <guid>https://dev.to/veristria/your-nextpublic-secret-is-already-in-the-browser-bundle-2ige</guid>
      <description>&lt;h2&gt;
  
  
  Problem
&lt;/h2&gt;

&lt;p&gt;In a Next.js app it’s easy to slip a secret (e.g., &lt;code&gt;STRIPE_SECRET_KEY&lt;/code&gt;, OpenAI API key, Supabase JWT) into a client‑side bundle. When a server component reads &lt;code&gt;process.env.STRIPE_SECRET_KEY&lt;/code&gt; it stays on the server, but copying that line into a client component causes the build to fail to resolve the variable. The common “quick fix”—renaming the variable with the &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix—makes the value part of the JavaScript that every visitor downloads, turning a server‑only secret into a public leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works (mechanism)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Next.js environment variable scoping&lt;/strong&gt;  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Variables &lt;strong&gt;without&lt;/strong&gt; the &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix are stripped from the client bundle at build time. They are only available in server‑side code (&lt;code&gt;pages/api/*&lt;/code&gt;, server components, &lt;code&gt;getServerSideProps&lt;/code&gt;, etc.).
&lt;/li&gt;
&lt;li&gt;Variables &lt;strong&gt;with&lt;/strong&gt; the &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix are injected into the client bundle and can be read from &lt;code&gt;process.env&lt;/code&gt; in any browser‑executed code.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Accidental exposure&lt;/strong&gt;  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A developer moves a line such as &lt;code&gt;const stripeKey = process.env.STRIPE_SECRET_KEY;&lt;/code&gt; from a server component to a client component (or a shared utility imported by both).
&lt;/li&gt;
&lt;li&gt;The build fails because &lt;code&gt;STRIPE_SECRET_KEY&lt;/code&gt; is undefined on the client.
&lt;/li&gt;
&lt;li&gt;To silence the error they rename the variable to &lt;code&gt;NEXT_PUBLIC_STRIPE_SECRET_KEY&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;The renamed variable is now inlined into the bundle, exposing the secret to anyone who can view the page source or network traffic.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Why the leak is critical&lt;/strong&gt;  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Secrets like Stripe secret keys, OpenAI API keys, Supabase JWTs, AWS access keys, and webhook signing secrets grant full access to the respective services. Once they appear in a public bundle they can be harvested by bots or malicious actors.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Detection (KeyDrift)
&lt;/h2&gt;

&lt;p&gt;KeyDrift performs a &lt;strong&gt;read‑only&lt;/strong&gt; scan of your client bundle—no credentials are required—to locate hard‑coded secrets and environment variables that have been inlined. The scan cross‑references each detected credential with the tool that introduced it (e.g., Next.js, Replit, Cursor).  &lt;/p&gt;

&lt;p&gt;Typical output for a Next.js leak looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Critical] STRIPE_SECRET_KEY found in client bundle (tool: Next.js)
Location: static/chunks/pages/_app.js:1234
Recommendation: Move usage to a server component or API route.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;KeyDrift also flags variables that have been renamed with the &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix and marks them as &lt;strong&gt;high&lt;/strong&gt; or &lt;strong&gt;critical&lt;/strong&gt; depending on the credential type (e.g., Stripe secret key → critical).&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Keep the secret on the server
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/stripe/checkout/route.ts (server‑only)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2023-10-16&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// server‑side logic only&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Call the server from the client
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/components/CheckoutButton.tsx (client component)&lt;/span&gt;
&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CheckoutButton&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startCheckout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/stripe/checkout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="c1"&gt;// handle response...&lt;/span&gt;
    &lt;span class="nf"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;startCheckout&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Buy&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Remove &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefixes for real secrets
&lt;/h3&gt;

&lt;p&gt;If you have already renamed a secret, revert the name in the source and run a clean build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- const stripeKey = process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY;
&lt;/span&gt;&lt;span class="gi"&gt;+ const stripeKey = process.env.STRIPE_SECRET_KEY; // server‑only
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Verify with KeyDrift
&lt;/h3&gt;

&lt;p&gt;Run a free KeyDrift scan (read‑only, no credentials) after the change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx keydrift scan &lt;span class="nt"&gt;--path&lt;/span&gt; ./out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scan should no longer report the secret in the client bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Caveat&lt;/th&gt;
&lt;th&gt;Details&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Environment variable duplication&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;If you need a value both on server and client (e.g., a public API key), store it separately as &lt;code&gt;NEXT_PUBLIC_...&lt;/code&gt; and keep the secret version (&lt;code&gt;..._SECRET&lt;/code&gt;) only on the server.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Third‑party libraries&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Some libraries (e.g., Stripe.js) expect a public key (&lt;code&gt;pk_test_...&lt;/code&gt;). Ensure you are not accidentally passing a secret key to such libraries.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Build caching&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;After renaming variables, clear &lt;code&gt;.next&lt;/code&gt; or run &lt;code&gt;next build --no-cache&lt;/code&gt; to avoid stale bundles that still contain the leaked value.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Server‑side rendering (SSR) vs. static generation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;In &lt;code&gt;getStaticProps&lt;/code&gt; the code runs at build time on the server, so secrets are safe there. However, any data returned to the page becomes part of the HTML and can be inspected, so avoid embedding raw secrets in the returned props.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dynamic imports&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Importing a module that reads a secret inside a client component will cause the same leak. Keep such imports confined to server‑only modules.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;&lt;strong&gt;KeyDrift&lt;/strong&gt; provides a concrete, read‑only audit that surfaces these leaks before they reach production. By moving secret usage back to server‑only code and avoiding the &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix for real credentials, you eliminate the most common source of client‑bundle secret exposure in Next.js projects.  &lt;/p&gt;

&lt;p&gt;&lt;em&gt;For more detailed guidance see the KeyDrift Fix Guides on “exposed keys by tool and credential.”&lt;/em&gt;&lt;/p&gt;

</description>
      <category>keydrift</category>
      <category>nextjs</category>
      <category>environment</category>
      <category>variable</category>
    </item>
    <item>
      <title>Your Supabase Storage bucket is public - signed URLs will not save you</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Fri, 28 Aug 2026 20:59:51 +0000</pubDate>
      <link>https://dev.to/veristria/your-supabase-storage-bucket-is-public-signed-urls-will-not-save-you-519a</link>
      <guid>https://dev.to/veristria/your-supabase-storage-bucket-is-public-signed-urls-will-not-save-you-519a</guid>
      <description>&lt;p&gt;&lt;strong&gt;Problem&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
You are generating private B2B images with Sharp in a Next.js API route and storing the derivatives in Supabase Storage. The bucket that holds those images is currently &lt;strong&gt;public&lt;/strong&gt;, meaning anyone who can guess or discover the object URL can download the original or any derivative without authentication. This defeats the “private‑only” intent of your workflow and can expose sensitive business assets (e.g., brand assets, customer logos, confidential diagrams).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mechanism&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Supabase Storage buckets have a &lt;code&gt;public&lt;/code&gt; flag. When &lt;code&gt;public = true&lt;/code&gt; the bucket’s objects are served directly via a public URL (&lt;code&gt;https://&amp;lt;project&amp;gt;.supabase.co/storage/v1/object/public/&amp;lt;bucket&amp;gt;/&amp;lt;path&amp;gt;&lt;/code&gt;). No auth check is performed, and the URL is stable as long as the object exists.  &lt;/p&gt;

&lt;p&gt;When you later generate presigned URLs (e.g., &lt;code&gt;storage.from('avatars').createSignedUrl(...)&lt;/code&gt;) you rely on the bucket being private; the signed URL adds a time‑limited token that Supabase validates before serving the object. If the bucket is public, the signed URL is unnecessary and the underlying object can be fetched without it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detection&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
A quick read‑only audit (no credentials required) can confirm the bucket’s exposure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- RowShield‑style detection query&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buckets&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'avatars'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- replace with your bucket name&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the result shows &lt;code&gt;public = true&lt;/code&gt;, the bucket is publicly readable.&lt;/p&gt;

&lt;p&gt;You can also verify manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="s2"&gt;"https://&amp;lt;project&amp;gt;.supabase.co/storage/v1/object/public/avatars/example.jpg"&lt;/span&gt;
&lt;span class="c"&gt;# Expect HTTP 200 even without auth → bucket is public&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;  &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Make the bucket private&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Turn off public access&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buckets&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'avatars'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- replace with your bucket name&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Add a row‑level security (RLS) policy&lt;/strong&gt; that limits reads to the owning user (or to a service account you control). Example for per‑user folders:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Allow authenticated users to read only objects in their own folder&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;avatars_read_own&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
&lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;bucket_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'avatars'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;foldername&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Generate expiring signed URLs&lt;/strong&gt; in your Next.js API route instead of returning raw URLs:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// pages/api/image.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@supabase/supabase-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextApiRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NextApiResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_SUPABASE_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;   &lt;span class="c1"&gt;// server‑side only&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextApiRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextApiResponse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expiresIn&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;expiresIn&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="c1"&gt;// Validate request (e.g., check auth, ownership, etc.)&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createSignedUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expiresIn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// seconds&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;signedUrl&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Update your client&lt;/strong&gt; to request the signed URL from the API route and use that URL for &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tags or downloads.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Optional: Queue heavy Sharp processing&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
If derivative generation is CPU‑intensive, offload it to a background worker (e.g., Supabase Edge Functions, a separate Node worker, or a cloud queue). Store the resulting files in the now‑private bucket, then serve them via the signed‑URL endpoint above.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Caveats&lt;/strong&gt;  &lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Issue&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;th&gt;Mitigation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Service‑role key exposure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;If the key leaks, anyone can bypass RLS.&lt;/td&gt;
&lt;td&gt;Store it only on the server (e.g., Vercel/Netlify serverless functions). Never bundle it in client code.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Policy granularity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The example policy assumes a flat &lt;code&gt;userId/filename&lt;/code&gt; layout.&lt;/td&gt;
&lt;td&gt;Adjust the &lt;code&gt;USING&lt;/code&gt; clause to match your folder structure (e.g., &lt;code&gt;projectId/userId/...&lt;/code&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Signed‑URL TTL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Too short → frequent re‑fetches; too long → larger attack window.&lt;/td&gt;
&lt;td&gt;Choose a TTL that balances UX and security (commonly 60–300 seconds).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bucket‑wide public flag&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Changing &lt;code&gt;public&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt; affects &lt;em&gt;all&lt;/em&gt; objects, including any that truly need public access (e.g., marketing assets).&lt;/td&gt;
&lt;td&gt;Split assets into separate buckets: one private for B2B images, one public for truly public content.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cache invalidation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Browsers may cache a signed URL longer than its TTL if &lt;code&gt;Cache-Control&lt;/code&gt; headers aren’t set.&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;Cache-Control: private, max-age=0&lt;/code&gt; on the response or on the object metadata.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Next steps&lt;/strong&gt;  &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run the read‑only audit (&lt;code&gt;SELECT id, public FROM storage.buckets&lt;/code&gt;) to confirm the bucket’s current state.
&lt;/li&gt;
&lt;li&gt;Apply the &lt;code&gt;UPDATE&lt;/code&gt; statement to make the bucket private.
&lt;/li&gt;
&lt;li&gt;Deploy the RLS policy that matches your ownership model.
&lt;/li&gt;
&lt;li&gt;Update your Next.js API route to issue signed URLs and replace any direct object URLs in the UI.
&lt;/li&gt;
&lt;li&gt;Test end‑to‑end: unauthenticated request → 403, authenticated request → signed URL → successful image load.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By tightening the bucket’s visibility and gating access through signed URLs generated in a server‑side Next.js route, you close the primary exposure vector while preserving the ability to serve private B2B images efficiently.  &lt;/p&gt;

&lt;p&gt;&lt;em&gt;All audit queries are read‑only and require no credentials.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>vibeguard</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Stripe Connect refunds: the two flags that silently cost platforms</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Fri, 28 Aug 2026 20:59:39 +0000</pubDate>
      <link>https://dev.to/veristria/stripe-connect-refunds-the-two-flags-that-silently-cost-platforms-3015</link>
      <guid>https://dev.to/veristria/stripe-connect-refunds-the-two-flags-that-silently-cost-platforms-3015</guid>
      <description>&lt;p&gt;&lt;strong&gt;Problem&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
When a Stripe Connect platform refunds a charge, the platform often assumes that the original transfer and the application fee are automatically undone. In practice, two boolean flags—&lt;code&gt;refund_application_fee&lt;/code&gt; and &lt;code&gt;reverse_transfer&lt;/code&gt;—control whether the fee and the transfer are reversed. Their defaults are non‑obvious (&lt;code&gt;false&lt;/code&gt; for both), so a refund can leave the platform with a net loss that never appears in Stripe’s error logs. The loss shows up only as a discrepancy between the amount refunded to the customer and the amount that actually returns to the platform’s balance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mechanism&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
A Connect refund touches several objects:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Object&lt;/th&gt;
&lt;th&gt;Role in a refund&lt;/th&gt;
&lt;th&gt;Default behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Charge&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Original payment&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Transfer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Moves funds from platform to connected account&lt;/td&gt;
&lt;td&gt;Not reversed unless &lt;code&gt;reverse_transfer: true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ApplicationFee&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Platform’s fee on the charge&lt;/td&gt;
&lt;td&gt;Not refunded unless &lt;code&gt;refund_application_fee: true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Refund&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Credits the customer&lt;/td&gt;
&lt;td&gt;Always creates a credit on the original charge&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two flags are independent:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;refund_application_fee&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;reverse_transfer&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Platform net outcome&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;Platform keeps the fee and the transfer stays settled → net loss = fee + any FX spread&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;Transfer is reversed but fee stays → net loss = fee&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;Fee is refunded but transfer stays → net loss = transferred amount (minus fee)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;Both fee and transfer are undone → net zero (aside from timing/FX)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Because the defaults are &lt;code&gt;false&lt;/code&gt;, a typical “refund” only credits the customer; the platform’s money remains locked in the connected account and the fee stays on the platform’s ledger. This silent leak is why many platforms see “missing” revenue after a batch of refunds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detection&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
FeeGuard can audit the last 90 days (or any custom window) without any credentials—just a read‑only API token or a CSV export. The audit consists of three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Export refunds&lt;/strong&gt; – Pull all &lt;code&gt;Refund&lt;/code&gt; objects for the target date range.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Join to transfers and fees&lt;/strong&gt; – For each refund, locate the associated &lt;code&gt;Transfer&lt;/code&gt; (&lt;code&gt;transfer&lt;/code&gt; field on the original &lt;code&gt;Charge&lt;/code&gt;) and &lt;code&gt;ApplicationFee&lt;/code&gt; (&lt;code&gt;application_fee&lt;/code&gt; field).
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply the truth table&lt;/strong&gt; – Compute the expected platform balance change based on the two flags. Compare it to the actual balance delta reported by Stripe.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Python example (requires only read‑only Stripe secret)
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;

&lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sk_test_readonly_...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_refunds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;refunds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gte&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;auto_paging_iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;total_gap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;fetch_refunds&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;transfer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Transfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transfer&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="n"&gt;fee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApplicationFee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;application_fee&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;application_fee&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

        &lt;span class="c1"&gt;# Flags (defaults are false if not present)
&lt;/span&gt;        &lt;span class="n"&gt;rev_fee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refund_application_fee&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
        &lt;span class="n"&gt;rev_transfer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reverse_transfer&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

        &lt;span class="c1"&gt;# Expected platform delta
&lt;/span&gt;        &lt;span class="n"&gt;fee_amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fee&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="n"&gt;transfer_amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;transfer&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

        &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;rev_fee&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;fee_amount&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;rev_transfer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;transfer_amount&lt;/span&gt;

        &lt;span class="c1"&gt;# Actual delta is the amount Stripe reports as returned to the platform
&lt;/span&gt;        &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;  &lt;span class="c1"&gt;# amount refunded to customer (negative for platform)
&lt;/span&gt;
        &lt;span class="n"&gt;gap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt;
        &lt;span class="n"&gt;total_gap&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;gap&lt;/span&gt;

    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Total unrecovered loss: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;total_gap&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; USD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running the script (or using FeeGuard’s UI) yields the exact dollar amount that never made it back to the platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
There are two complementary approaches:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Preventive configuration&lt;/strong&gt; – When creating a refund via the API, explicitly set the flags to &lt;code&gt;true&lt;/code&gt; if you want the fee and transfer reversed:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.stripe.com/v1/refunds &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; sk_test_readonly_: &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ch_1ABC... &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;refund_application_fee&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;reverse_transfer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Tip&lt;/em&gt;: Wrap this call in a helper function in your backend so every refund uses the same parameters.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Post‑refund recovery&lt;/strong&gt; – If a batch of refunds has already been processed without the flags, use FeeGuard’s recovery flow:&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;Run a read‑only audit to quantify the loss.
&lt;/li&gt;
&lt;li&gt;For each affected refund, issue a separate “fee reversal” and “transfer reversal” using the &lt;code&gt;ApplicationFeeRefund&lt;/code&gt; and &lt;code&gt;TransferReversal&lt;/code&gt; endpoints. Both are independent operations and can be performed after the original refund.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Reverse fee
&lt;/span&gt;&lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApplicationFeeRefund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;application_fee&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Reverse transfer
&lt;/span&gt;&lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TransferReversal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;transfer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because FeeGuard’s pricing is “pay‑only‑on‑recovery,” you only incur cost when the above steps actually return money to the platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caveats&lt;/strong&gt;  &lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Issue&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;th&gt;Mitigation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Timing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Transfer reversals can take up to a few business days to settle.&lt;/td&gt;
&lt;td&gt;Track reversal status via &lt;code&gt;TransferReversal&lt;/code&gt; objects.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FX exposure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;If the original transfer involved currency conversion, the reversal may settle at a different rate, leaving a small residual.&lt;/td&gt;
&lt;td&gt;Include FX variance in the audit’s “gap” calculation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Partial refunds&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;When only part of a charge is refunded, you must decide whether to reverse the proportional fee/transfer or keep the remainder.&lt;/td&gt;
&lt;td&gt;Use the same proportion (&lt;code&gt;refund.amount / charge.amount&lt;/code&gt;) when creating &lt;code&gt;ApplicationFeeRefund&lt;/code&gt; and &lt;code&gt;TransferReversal&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Disconnected accounts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Some connected accounts may have custom payout schedules that block immediate reversals.&lt;/td&gt;
&lt;td&gt;Verify the account’s &lt;code&gt;payout_schedule&lt;/code&gt; before attempting a reversal; fallback to manual payout if needed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Read‑only audit limits&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The audit does not require any write credentials, but it cannot detect unreversed transfers that were never created (e.g., a missing &lt;code&gt;transfer&lt;/code&gt; field).&lt;/td&gt;
&lt;td&gt;Ensure your platform always creates a &lt;code&gt;Transfer&lt;/code&gt; for every Connect charge; otherwise, the audit will flag the missing link.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Summary&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Stripe Connect refunds can silently leak platform revenue because &lt;code&gt;refund_application_fee&lt;/code&gt; and &lt;code&gt;reverse_transfer&lt;/code&gt; default to &lt;code&gt;false&lt;/code&gt;. By systematically joining refunds to their associated transfers and fees, applying the truth table, and either configuring refunds correctly up front or reversing the missed amounts afterward, a platform can recover the hidden loss. FeeGuard’s read‑only audit makes the detection step trivial, and its pay‑only‑on‑recovery model ensures you only pay when money is actually returned.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;For a deeper dive, see the full blog post on the FeeGuard site.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>feeguard</category>
      <category>stripe</category>
      <category>connect</category>
      <category>refund</category>
    </item>
    <item>
      <title>What a lost dispute actually costs a Connect platform</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Fri, 21 Aug 2026 13:40:43 +0000</pubDate>
      <link>https://dev.to/veristria/what-a-lost-dispute-actually-costs-a-connect-platform-5gh2</link>
      <guid>https://dev.to/veristria/what-a-lost-dispute-actually-costs-a-connect-platform-5gh2</guid>
      <description>&lt;p&gt;Refunds get the attention. Disputes are worse, and the reason is structural rather than a matter of degree.&lt;/p&gt;

&lt;p&gt;On a refund you at least have a parameter. &lt;code&gt;reverse_transfer: true&lt;/code&gt; exists, you can pass it, and if you forget there is a boolean to point at afterwards. &lt;strong&gt;On a dispute there is no such parameter, because there is no such option.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Stripe does when you lose
&lt;/h2&gt;

&lt;p&gt;Stripe debits the platform for the disputed amount plus the dispute fee. That is it. That is the whole automatic behaviour.&lt;/p&gt;

&lt;p&gt;The connected account that received the original transfer is not touched. Not reduced, not notified, not flagged. From that seller's perspective nothing happened at all — the money arrived weeks ago and is still theirs.&lt;/p&gt;

&lt;p&gt;So the platform's position after losing a dispute on a destination charge is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- disputed amount   (debited from platform balance)
- dispute fee       (debited from platform balance, typically ~15 USD)
&lt;/span&gt;&lt;span class="gi"&gt;+ nothing            (the transfer is untouched)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The seller keeps the funds. You paid the customer back and paid a fee for the privilege.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is not symmetrical with refunds
&lt;/h2&gt;

&lt;p&gt;A refund is initiated by you, so Stripe can offer a parameter at the moment of initiation. A dispute is initiated by the cardholder's bank, days or weeks after the charge, through a process you are not in. There is no call of yours to attach a flag to.&lt;/p&gt;

&lt;p&gt;The reversal, if you want one, has to be a separate action you take afterwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// on charge.dispute.closed with status 'lost'&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createReversal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transferId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;disputedAmount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// not necessarily the full transfer&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`dispute &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;disputeId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing calls that for you. If your webhook handler for &lt;code&gt;charge.dispute.closed&lt;/code&gt; only writes a row and updates a status, the clawback never happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that make this accumulate quietly
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The timing hides it.&lt;/strong&gt; A dispute closes 30 to 90 days after the charge. By then the transfer is in a different payout, a different month, and usually a different reconciliation report. The debit and the un-reversed transfer are never adjacent in any view you look at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The connected balance may not cover it.&lt;/strong&gt; Even when you do reverse, the seller may have already paid out. The reversal leaves the account negative, which surfaces later as a failed payout — a third event, in a fourth place, with no obvious link back to a dispute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Winning still costs.&lt;/strong&gt; The dispute fee is generally not returned on a win in most regions. A platform with a good win rate still bleeds fees, and those fees are charged to the platform rather than to the seller whose transaction caused them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reconciliation query
&lt;/h2&gt;

&lt;p&gt;The question is the same shape as the refund one, and it is worth running over full history rather than a recent window, because the whole failure mode is that it is old by the time it matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;                &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;dispute_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;charge_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;            &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;debited&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fee&lt;/span&gt;               &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;dispute_fee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;reversed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;unrecovered&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;disputes&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;
  &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;transfer_reversals&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;charge_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;charge_id&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'lost'&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;unrecovered&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The policy question underneath the technical one
&lt;/h2&gt;

&lt;p&gt;Whether to claw back is genuinely a business decision, and reasonable platforms answer it differently. A marketplace with sellers who have no control over fraud may absorb disputes deliberately. A platform whose sellers &lt;em&gt;do&lt;/em&gt; control fraud usually should not.&lt;/p&gt;

&lt;p&gt;What is not a decision is doing it by accident. If you have never reversed a transfer on a lost dispute, you have chosen to absorb every one of them — you just did not know that was the choice you were making. Run the query and find out which platform you are.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>payments</category>
      <category>fintech</category>
      <category>api</category>
    </item>
    <item>
      <title>The Stripe Connect refund that only costs the platform</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Fri, 21 Aug 2026 13:39:36 +0000</pubDate>
      <link>https://dev.to/veristria/the-stripe-connect-refund-that-only-costs-the-platform-1l68</link>
      <guid>https://dev.to/veristria/the-stripe-connect-refund-that-only-costs-the-platform-1l68</guid>
      <description>&lt;p&gt;There is a class of bug in Stripe Connect that never throws, never logs, and never fails a webhook. The refund succeeds. The customer is made whole. The money comes out of your balance and stays with the seller.&lt;/p&gt;

&lt;p&gt;It is worth understanding precisely, because the fix is one boolean and the detection is one join.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a destination charge actually is
&lt;/h2&gt;

&lt;p&gt;On a destination charge, one API call does two things: it charges the customer, and it creates a &lt;strong&gt;transfer&lt;/strong&gt; moving funds to the connected account. Two objects, one call.&lt;/p&gt;

&lt;p&gt;A refund, by default, only undoes the first one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the customer gets their money back. the transfer is untouched.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ch_123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// the transfer is reversed too&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ch_123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;reverse_transfer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;reverse_transfer&lt;/code&gt; defaults to &lt;code&gt;false&lt;/code&gt;. That default is defensible — sometimes the platform genuinely wants to absorb a refund rather than claw it back from a seller who already shipped. What makes it a footgun is that choosing wrongly produces no signal at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second flag, which is not the same flag
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;refund_application_fee&lt;/code&gt; is independent. Reversing the transfer does not refund your platform fee, and refunding the fee does not reverse the transfer.&lt;/p&gt;

&lt;p&gt;That means a refund can be wrong in either direction on its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;transfer reversed, fee kept -&amp;gt; you kept a fee on revenue that no longer exists&lt;/li&gt;
&lt;li&gt;fee refunded, transfer not reversed -&amp;gt; you paid the customer &lt;em&gt;and&lt;/em&gt; gave up the fee&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Three details that bite during reconciliation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Partial refunds do not partially reverse unless you ask.&lt;/strong&gt; A partial refund with &lt;code&gt;reverse_transfer: true&lt;/code&gt; reverses the corresponding fraction. A partial refund &lt;em&gt;without&lt;/em&gt; it reverses nothing at all — not a smaller amount, nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A negative connected balance does not block the refund.&lt;/strong&gt; The reversal is attempted and the account can be left negative. You find out later, as a failed payout, in a different part of the system, with no obvious link back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disputes behave differently again.&lt;/strong&gt; On a lost dispute, Stripe debits the platform in full. The connected account that received the transfer is untouched unless you reverse it yourself. There is no &lt;code&gt;reverse_transfer&lt;/code&gt; on a dispute.&lt;/p&gt;

&lt;h2&gt;
  
  
  The query that finds them
&lt;/h2&gt;

&lt;p&gt;The reconciliation question is not "did the refund succeed". It is: &lt;strong&gt;for every &lt;code&gt;charge.refunded&lt;/code&gt;, is there a matching &lt;code&gt;transfer.reversal&lt;/code&gt; of the expected amount?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Working from the events you already receive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;charge_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;                      &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;refunded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;reversed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;unrecovered&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;refunds&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
  &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;transfer_reversals&lt;/span&gt; &lt;span class="n"&gt;rev&lt;/span&gt;
         &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;rev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transfer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transfer_id&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;unrecovered&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every row is money that left and did not come back. Run it across your full history the first time — this accumulates silently, so the first run is usually the interesting one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it survives code review
&lt;/h2&gt;

&lt;p&gt;Because the code looks correct. &lt;code&gt;refunds.create({ charge })&lt;/code&gt; is the documented call, it does what its name says, and the omission is a parameter that is not there. Reviewers see present code, not absent parameters.&lt;/p&gt;

&lt;p&gt;The durable fix is not to remember the flag. It is to make the two event streams reconcile automatically, so the answer to "is our ledger whole" is a number somebody looks at rather than an assumption everybody makes.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>payments</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>gitignore is not a security control</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Fri, 21 Aug 2026 13:34:31 +0000</pubDate>
      <link>https://dev.to/veristria/gitignore-is-not-a-security-control-2gok</link>
      <guid>https://dev.to/veristria/gitignore-is-not-a-security-control-2gok</guid>
      <description>&lt;p&gt;A &lt;code&gt;.env&lt;/code&gt; file in &lt;code&gt;.gitignore&lt;/code&gt; tells you one thing: the file is not in your git history. It tells you nothing about whether the value inside it is currently readable by anyone who loads your site.&lt;/p&gt;

&lt;p&gt;Those are different questions, and only the second one describes what an attacker actually does. Nobody clones your repo. They open the deployed bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the value actually goes
&lt;/h2&gt;

&lt;p&gt;A build reads &lt;code&gt;.env&lt;/code&gt; and then makes a decision about each variable. The decision is usually made by a prefix.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;Inlined into client output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Next.js&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vite&lt;/td&gt;
&lt;td&gt;&lt;code&gt;VITE_*&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create React App&lt;/td&gt;
&lt;td&gt;&lt;code&gt;REACT_APP_*&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nuxt&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NUXT_PUBLIC_*&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Astro&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PUBLIC_*&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SvelteKit&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PUBLIC_*&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those prefixes are not warnings. They are &lt;strong&gt;instructions to publish&lt;/strong&gt;. A variable so named is compiled into JavaScript that ships to every visitor, by design, and it works exactly as intended.&lt;/p&gt;

&lt;p&gt;The mistake is almost never that someone misunderstood the prefix. It is that a key was moved into one during a debugging session at 2am, the bug got fixed, and it never moved back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three places the value survives after you rotate
&lt;/h2&gt;

&lt;p&gt;This is the part people miss. Rotating the key is necessary. It is not sufficient, because the old value is still sitting in artefacts nobody thinks of as artefacts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Source maps.&lt;/strong&gt; A minified bundle may not show a readable key. The &lt;code&gt;.map&lt;/code&gt; file beside it usually does — that is its entire purpose. Shipping source maps to production is common and mostly harmless, right up until the moment it is not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# what an attacker runs, and takes about four seconds&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://yoursite.com/_next/static/chunks/main-&lt;span class="k"&gt;*&lt;/span&gt;.js &lt;span class="se"&gt;\n&lt;/span&gt;  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'(sk|pk|rk)_(live|test)_[A-Za-z0-9]{16,}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Previous deploys.&lt;/strong&gt; Every platform keeps old deployments addressable. A Vercel preview URL from three weeks ago serves the bundle from three weeks ago, containing the key from three weeks ago. Rotation does not reach backwards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Build logs.&lt;/strong&gt; CI output, deploy logs and provider dashboards retain output for weeks. One &lt;code&gt;console.log&lt;/code&gt; in a failing build is enough, and failing builds are exactly when people add logging.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that means something
&lt;/h2&gt;

&lt;p&gt;Stop asking whether the file is ignored. Ask whether the value is recoverable from what you shipped.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# fetch what the browser actually receives, and look in it&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://yoursite.com | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'/_next/static/chunks/[^"]+.js'&lt;/span&gt; &lt;span class="se"&gt;\n&lt;/span&gt;  | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt; &lt;span class="se"&gt;\n&lt;/span&gt;  | xargs &lt;span class="nt"&gt;-I&lt;/span&gt;&lt;span class="o"&gt;{}&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://yoursite.com{}"&lt;/span&gt; &lt;span class="se"&gt;\n&lt;/span&gt;  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'[A-Za-z0-9_-]{32,}'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is crude and it is still more informative than reading &lt;code&gt;.gitignore&lt;/code&gt;, because it operates on the artefact rather than on the intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about the ones that are already out
&lt;/h2&gt;

&lt;p&gt;In order, because the order matters:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rotate first, investigate second.&lt;/strong&gt; The window matters more than the root cause, and the root cause is still there in an hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the provider's logs for use.&lt;/strong&gt; Stripe, Supabase, OpenAI and most others will tell you whether the key was used and from where. "It leaked" and "it was used" call for very different responses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete or expire old deploy previews.&lt;/strong&gt; Otherwise step 1 protected the future and nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then&lt;/strong&gt; fix the prefix, move the call server-side, and add the check to CI.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The general principle is worth stating plainly, because it generalises past secrets: &lt;strong&gt;a control that operates on your source is not a control on what you ship.&lt;/strong&gt; The build stands between them, and the build is where the interesting mistakes live.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Your RLS policies were correct when you wrote them</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Fri, 21 Aug 2026 13:34:01 +0000</pubDate>
      <link>https://dev.to/veristria/your-rls-policies-were-correct-when-you-wrote-them-310h</link>
      <guid>https://dev.to/veristria/your-rls-policies-were-correct-when-you-wrote-them-310h</guid>
      <description>&lt;p&gt;Row-level security has a property that makes it unusually easy to get wrong over time: &lt;strong&gt;a policy that is correct is indistinguishable from a policy that is wrong, until someone reads it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is no error. No failed query. A too-permissive policy returns &lt;em&gt;more&lt;/em&gt; rows, which looks exactly like a working application. That is the whole problem in one sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four ways a policy stops being correct
&lt;/h2&gt;

&lt;p&gt;None of these involve anyone editing the policy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A new column arrives.&lt;/strong&gt; You wrote &lt;code&gt;USING (auth.uid() = user_id)&lt;/code&gt; and it was right. Then a migration added &lt;code&gt;organization_id&lt;/code&gt;, and rows are now reachable by a user who belongs to a different organisation but happens to match on &lt;code&gt;user_id&lt;/code&gt;. The policy did not change. Its meaning did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;USING&lt;/code&gt; was written and &lt;code&gt;WITH CHECK&lt;/code&gt; was not.&lt;/strong&gt; These are separate halves and people routinely write one. &lt;code&gt;USING&lt;/code&gt; filters what a statement can &lt;em&gt;see&lt;/em&gt;; &lt;code&gt;WITH CHECK&lt;/code&gt; constrains what it can &lt;em&gt;write&lt;/em&gt;. A policy with only &lt;code&gt;USING&lt;/code&gt; will happily let a user &lt;code&gt;UPDATE&lt;/code&gt; a row into a state where they can no longer see it — writing data they cannot read back, into someone else's tenant.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- readable only by the owner, writable into any shape&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- both halves&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. A table was created without RLS at all.&lt;/strong&gt; &lt;code&gt;ALTER TABLE ... ENABLE ROW LEVEL SECURITY&lt;/code&gt; is a separate statement from &lt;code&gt;CREATE TABLE&lt;/code&gt;. A table created by a migration, a dashboard click, or a framework generator does not have it on by default. Enabling RLS with &lt;strong&gt;no policies&lt;/strong&gt; denies everything, which is safe and visible. Forgetting to enable it exposes everything, which is unsafe and invisible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The service role bypasses all of it.&lt;/strong&gt; That is what it is for. The failure is not the key existing, it is the key being used from somewhere that handles end-user input — an edge function, a server action, a webhook handler that grew a query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading a policy is not testing it
&lt;/h2&gt;

&lt;p&gt;This is the part worth internalising. You can read a policy, agree it looks right, and be wrong — because what it does depends on the current schema, the current role, and the current &lt;code&gt;auth.uid()&lt;/code&gt;, none of which are in the policy text.&lt;/p&gt;

&lt;p&gt;The test that means something is: &lt;strong&gt;connect as the role, set the claims, and count rows you should not be able to see.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- as an authenticated user who owns nothing in this tenant&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'{"sub":"11111111-1111-1111-1111-111111111111"}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- expect 0, not "looks fine"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns a non-zero number, the policy is wrong regardless of how it reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check worth automating
&lt;/h2&gt;

&lt;p&gt;Three queries, run on every migration, catch most of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- tables with RLS off&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relrowsecurity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- tables with RLS on and no policies (denies all - usually unintended)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
  &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_policy&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;polrelid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relrowsecurity&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
 &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- policies with USING and no WITH CHECK on a write command&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;polrelid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;polname&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_policy&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;polwithcheck&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;polcmd&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'a'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'*'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of these need a tool. Run them in CI and fail the build.&lt;/p&gt;

&lt;p&gt;The reason drift is worth monitoring rather than auditing once is in the first sentence: nothing tells you. The schema moves, the policy stays, and the gap between them opens quietly. The only way you find out is by looking, on a schedule, at something that does not change unless you check it.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>database</category>
    </item>
  </channel>
</rss>
