<?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: Payneteasy</title>
    <description>The latest articles on DEV Community by Payneteasy (@payneteasy).</description>
    <link>https://dev.to/payneteasy</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3902834%2Fd453c0d4-fa60-426a-8fad-988deb98e5d8.jpeg</url>
      <title>DEV Community: Payneteasy</title>
      <link>https://dev.to/payneteasy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/payneteasy"/>
    <language>en</language>
    <item>
      <title>Cross-border payment reconciliation: matching multi-currency, multi-acquirer settlement files</title>
      <dc:creator>Payneteasy</dc:creator>
      <pubDate>Fri, 05 Jun 2026 09:44:44 +0000</pubDate>
      <link>https://dev.to/payneteasy/cross-border-payment-reconciliation-matching-multi-currency-multi-acquirer-settlement-files-301c</link>
      <guid>https://dev.to/payneteasy/cross-border-payment-reconciliation-matching-multi-currency-multi-acquirer-settlement-files-301c</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reconciliation is the part of a payments stack nobody architects for on day one and everyone pays for on day 200.&lt;/li&gt;
&lt;li&gt;The job: prove that every internal transaction matches the acquirer's settlement file, in the right currency, with the right fees, on the right value date — or surface the diff fast.&lt;/li&gt;
&lt;li&gt;The mechanics: normalize files → land into an events table → project to a read model → diff against the internal read model → buckets for ops to resolve.&lt;/li&gt;
&lt;li&gt;The boring details (file formats, fee parsing, FX rounding, value dates) are where 90% of the work lives.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you've ever opened a CSV from an acquirer at the end of the month, sorted by amount, and tried to "just match it in Excel" — yes, this post is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "reconciled" actually means
&lt;/h2&gt;

&lt;p&gt;A transaction is reconciled when, for the same logical payment, three views agree:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What you sent&lt;/strong&gt; — your internal record of the charge/payout (your read model).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What the acquirer says happened&lt;/strong&gt; — their settlement file or API report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What the bank actually credited / debited&lt;/strong&gt; — the bank statement.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Disagreements are normal. Persistent disagreements are how you lose money slowly and never know.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of a settlement file
&lt;/h2&gt;

&lt;p&gt;Across the major acquirers, settlement files look broadly similar — and broadly different in the places that matter:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Variants you'll see&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Transaction reference&lt;/td&gt;
&lt;td&gt;acquirer's &lt;code&gt;transaction_id&lt;/code&gt;, sometimes plus a &lt;code&gt;merchant_reference&lt;/code&gt; round-tripped from you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gross amount&lt;/td&gt;
&lt;td&gt;minor units / decimal; transaction currency vs settlement currency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fees&lt;/td&gt;
&lt;td&gt;inline per-row, &lt;em&gt;or&lt;/em&gt; aggregated at the file footer, &lt;em&gt;or&lt;/em&gt; in a separate fees file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FX&lt;/td&gt;
&lt;td&gt;inline rate vs separate FX file; sometimes only the converted amount&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Value date&lt;/td&gt;
&lt;td&gt;when the bank actually moves money — often T+1/T+2 from event date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adjustments&lt;/td&gt;
&lt;td&gt;refunds, chargebacks, fee corrections, reserves — usually mixed in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Encoding&lt;/td&gt;
&lt;td&gt;UTF-8 if you're lucky; CP1252 / fixed-width / SWIFT MT940 if you're not&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Granularity&lt;/td&gt;
&lt;td&gt;one row per transaction &lt;em&gt;or&lt;/em&gt; daily aggregates per merchant &lt;em&gt;or&lt;/em&gt; both&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There's no industry-clean schema for this. Plan to write one normalizer per acquirer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalizing into events
&lt;/h2&gt;

&lt;p&gt;The trick that pays off: don't try to reconcile &lt;em&gt;files&lt;/em&gt;. Reconcile &lt;strong&gt;events&lt;/strong&gt;, all in the same shape, in one table. Each row in the settlement file becomes an event:&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="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;settlement_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;event_id&lt;/span&gt;        &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;source&lt;/span&gt;          &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;-- acq_a / acq_b / bank_x&lt;/span&gt;
  &lt;span class="n"&gt;source_file&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;-- filename for traceability&lt;/span&gt;
  &lt;span class="k"&gt;type&lt;/span&gt;            &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;-- charge / refund / chargeback / fee / fx_adjustment / reserve&lt;/span&gt;
  &lt;span class="n"&gt;external_id&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;-- acquirer's transaction id&lt;/span&gt;
  &lt;span class="n"&gt;merchant_ref&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                    &lt;span class="c1"&gt;-- your charge_id if they round-tripped it&lt;/span&gt;
  &lt;span class="n"&gt;gross_minor&lt;/span&gt;     &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;fee_minor&lt;/span&gt;       &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;net_minor&lt;/span&gt;       &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;currency&lt;/span&gt;        &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;settlement_ccy&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;fx_rate&lt;/span&gt;         &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;value_date&lt;/span&gt;      &lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ts_event&lt;/span&gt;        &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;raw&lt;/span&gt;             &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;settlement_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;external_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value_date&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;code&gt;raw&lt;/code&gt; column is a lifeline: keep the original row as JSON for every event. The first time the normalizer is wrong, you'll need to re-parse without re-downloading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two read models
&lt;/h2&gt;

&lt;p&gt;Project to two flat tables — one from your internal events, one from the settlement events:&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="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;recon_internal&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;charge_id&lt;/span&gt;    &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;external_id&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;gross_minor&lt;/span&gt;  &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;currency&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;fee_minor&lt;/span&gt;    &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;net_minor&lt;/span&gt;    &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;acquirer&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;recon_settled&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;charge_key&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;-- (acquirer, external_id) or fallback (acquirer, merchant_ref)&lt;/span&gt;
  &lt;span class="n"&gt;acquirer&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;external_id&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;gross_minor&lt;/span&gt;    &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;fee_minor&lt;/span&gt;      &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;net_minor&lt;/span&gt;      &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;currency&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;settlement_ccy&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;fx_rate&lt;/span&gt;        &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;value_date&lt;/span&gt;     &lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now reconciliation is a SQL query against two tables, not a script against a CSV.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diff
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;paired&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;i&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;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;external_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;acquirer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gross_minor&lt;/span&gt;      &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;internal_gross&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gross_minor&lt;/span&gt;      &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;settled_gross&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fee_minor&lt;/span&gt;        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;internal_fee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fee_minor&lt;/span&gt;        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;settled_fee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;         &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;internal_ccy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;         &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;settled_ccy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value_date&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;recon_internal&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
  &lt;span class="k"&gt;FULL&lt;/span&gt; &lt;span class="k"&gt;OUTER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;recon_settled&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;acquirer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;acquirer&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;external_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;external_id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;CASE&lt;/span&gt;
         &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;charge_id&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;THEN&lt;/span&gt; &lt;span class="s1"&gt;'unknown_in_settlement'&lt;/span&gt;
         &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;external_id&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;THEN&lt;/span&gt; &lt;span class="s1"&gt;'missing_settlement'&lt;/span&gt;
         &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;internal_ccy&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;settled_ccy&lt;/span&gt;               &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'currency_mismatch'&lt;/span&gt;
         &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;internal_gross&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;settled_gross&lt;/span&gt;           &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'gross_mismatch'&lt;/span&gt;
         &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;internal_fee&lt;/span&gt;   &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;settled_fee&lt;/span&gt;             &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'fee_mismatch'&lt;/span&gt;
         &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;'ok'&lt;/span&gt;
       &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;paired&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;charge_id&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;OR&lt;/span&gt; &lt;span class="n"&gt;external_id&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;OR&lt;/span&gt; &lt;span class="n"&gt;internal_gross&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;settled_gross&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;internal_fee&lt;/span&gt;   &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;settled_fee&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;internal_ccy&lt;/span&gt;   &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;settled_ccy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six buckets, six runbooks. Ops can drain each bucket independently. The numbers in each bucket are the only health metric reconciliation actually has.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the fiddly bits live
&lt;/h2&gt;

&lt;p&gt;The query above is the easy part. The work is everywhere else.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-currency
&lt;/h3&gt;

&lt;p&gt;Two currencies in every settlement row: &lt;strong&gt;transaction currency&lt;/strong&gt; (what the customer paid) and &lt;strong&gt;settlement currency&lt;/strong&gt; (what the acquirer pays you in). Fees can be in &lt;em&gt;either&lt;/em&gt;. FX may be inline or in a separate file with a value-date join. Two production rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Store both currencies and the rate. Never store only the converted amount.&lt;/li&gt;
&lt;li&gt;Use a &lt;strong&gt;rounding rule chosen once and never argued with again&lt;/strong&gt; — banker's rounding (half-to-even) is the safe default for finance.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Partial settlements and split files
&lt;/h3&gt;

&lt;p&gt;A high-volume acquirer often splits a day's traffic across multiple files, sometimes across multiple days. Match on &lt;code&gt;external_id&lt;/code&gt;, not file boundaries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fees that arrive separately
&lt;/h3&gt;

&lt;p&gt;A few acquirers send a &lt;code&gt;fees&lt;/code&gt; file independently of &lt;code&gt;transactions&lt;/code&gt;. Project both into &lt;code&gt;settlement_events&lt;/code&gt; with different &lt;code&gt;type&lt;/code&gt;s, then aggregate per &lt;code&gt;external_id&lt;/code&gt; in the read model. Don't try to keep fees in a parallel structure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Refunds and chargebacks
&lt;/h3&gt;

&lt;p&gt;Each one is a &lt;em&gt;new event&lt;/em&gt;, not an update to the original. They have their own &lt;code&gt;external_id&lt;/code&gt; from the acquirer; they reference the original via a parent field that — surprise — is named differently per acquirer (&lt;code&gt;original_transaction_id&lt;/code&gt; / &lt;code&gt;refund_for&lt;/code&gt; / &lt;code&gt;linked_id&lt;/code&gt;). Normalize on ingest.&lt;/p&gt;

&lt;h3&gt;
  
  
  Value-date drift
&lt;/h3&gt;

&lt;p&gt;The transaction settles in your read model on T+0; the bank credits you on T+2. The reconciliation report can't blow up just because today's file doesn't include yesterday's late-evening charges. Compare by &lt;em&gt;value date window&lt;/em&gt;, not "today".&lt;/p&gt;

&lt;h2&gt;
  
  
  A reusable matching heuristic
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;external_id&lt;/code&gt; is missing or wrong (it happens), fall back through a small ladder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;match&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;candidates&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# 1. exact match on external_id (best)
&lt;/span&gt;    &lt;span class="n"&gt;by_ext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;external_id&lt;/span&gt; &lt;span class="o"&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;external_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&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;by_ext&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;by_ext&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. amount + currency + day + acquirer + last4
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;acquirer&lt;/span&gt; &lt;span class="o"&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;acquirer&lt;/span&gt;
            &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gross_minor&lt;/span&gt; &lt;span class="o"&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;gross_minor&lt;/span&gt;
            &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt; &lt;span class="o"&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;currency&lt;/span&gt;
            &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value_date&lt;/span&gt; &lt;span class="o"&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;event_date&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
            &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last4&lt;/span&gt; &lt;span class="o"&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;last4&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;c&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last4 is rarely on the settlement row but often on the transaction-detail report; if you can join the two, you've covered ~95% of "missing external_id" rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metrics for a reconciliation function
&lt;/h2&gt;

&lt;p&gt;If you're running this, three numbers belong on the dashboard:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Definition&lt;/th&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Match rate (T+1)&lt;/td&gt;
&lt;td&gt;% of internal events with a matching settlement event by T+1 close&lt;/td&gt;
&lt;td&gt;&amp;gt; 99% steady-state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unmatched aging&lt;/td&gt;
&lt;td&gt;oldest unmatched row's age, per bucket&lt;/td&gt;
&lt;td&gt;days, not weeks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Net delta (per currency, per acquirer)&lt;/td&gt;
&lt;td&gt;sum(internal_net) − sum(settled_net) over a day&lt;/td&gt;
&lt;td&gt;within rounding&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A reconciliation team's job isn't to &lt;em&gt;do&lt;/em&gt; matching — it's to keep these three numbers honest while the engineers fix whichever normalizer is currently lying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The settlement file is the truth your books are built on; treat it as a stream of events and the work scales. Treat it as a CSV to wrestle with and the work explodes.&lt;/p&gt;

&lt;p&gt;If you're at the point where reconciliation is taking real engineering hours per month, the &lt;a href="https://payneteasy.com/solutions/global-payouts?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=content2026q2&amp;amp;utm_content=cross-border-reconciliation&amp;amp;utm_term=cross-border%20payment%20reconciliation" rel="noopener noreferrer"&gt;global payouts solution&lt;/a&gt; page lays out the model we use end-to-end (events → read models → diffs → ops queues).&lt;/p&gt;

&lt;p&gt;Next in this series: what &lt;strong&gt;Verification of Payee&lt;/strong&gt; and &lt;strong&gt;DORA&lt;/strong&gt; change in your payment integration before the Sept/Jan 2026 deadlines.&lt;/p&gt;




&lt;p&gt;*Author: payments engineer at PaynetEasy — we build payment orchestration and global payouts infrastructure → [payneteasy.com](&lt;a href="https://payneteasy.com" rel="noopener noreferrer"&gt;https://payneteasy.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>fintech</category>
      <category>database</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Designing a payout ledger as a real-time read model (event sourcing in payments)</title>
      <dc:creator>Payneteasy</dc:creator>
      <pubDate>Tue, 26 May 2026 20:48:44 +0000</pubDate>
      <link>https://dev.to/payneteasy/designing-a-payout-ledger-as-a-real-time-read-model-event-sourcing-in-payments-4hnc</link>
      <guid>https://dev.to/payneteasy/designing-a-payout-ledger-as-a-real-time-read-model-event-sourcing-in-payments-4hnc</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A payouts table that gets &lt;code&gt;UPDATE&lt;/code&gt;d on every webhook is the bug factory. Replace it with an &lt;strong&gt;append-only event log&lt;/strong&gt; + a &lt;strong&gt;read model&lt;/strong&gt; projected from it.&lt;/li&gt;
&lt;li&gt;The read model is your "current balance / current state" view — rebuildable any time, idempotent, and cheap to query.&lt;/li&gt;
&lt;li&gt;Reconciliation against bank/PSP statements becomes a &lt;em&gt;diff between two read models&lt;/em&gt;, not a Friday-night batch.&lt;/li&gt;
&lt;li&gt;Costs are real (storage, projection latency, schema discipline) but smaller than the cost of every payouts bug you'd otherwise pay.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;If your payouts table looks like &lt;code&gt;(id, status, amount, updated_at)&lt;/code&gt; and your code is full of &lt;code&gt;WHERE status IN ('queued', 'in_flight', 'settled', 'failed')&lt;/code&gt;, this post is a refactor sketch worth keeping.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CRUD-payouts trap
&lt;/h2&gt;

&lt;p&gt;Most payout systems start the same way:&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="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;payouts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;           &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;      &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;amount_cents&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;currency&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;-- queued / in_flight / settled / failed / reversed&lt;/span&gt;
  &lt;span class="n"&gt;acquirer&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;external_id&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;updated_at&lt;/span&gt;   &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bugs follow within a quarter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Webhooks arrive out of order&lt;/strong&gt; — a &lt;code&gt;settled&lt;/code&gt; overwrites a later &lt;code&gt;reversed&lt;/code&gt;, and the user-facing balance is wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Race between worker and webhook&lt;/strong&gt; — both try to update &lt;code&gt;status&lt;/code&gt; at once; last write wins, and last write is often the stale one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No history&lt;/strong&gt; — "why does this payout say &lt;code&gt;failed&lt;/code&gt;?" has no answer beyond a Slack thread.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconciliation is a batch job&lt;/strong&gt; — finance can't trust the table mid-day, so they wait for an overnight reconciliation that ends up patching the table from the bank statement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these is structural. Updating a single row with the latest status throws away the truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Events as the source of truth
&lt;/h2&gt;

&lt;p&gt;The truth is the &lt;em&gt;sequence of things that happened&lt;/em&gt;. Model that, project everything else from 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="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;payout_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;event_id&lt;/span&gt;     &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;-- idempotency&lt;/span&gt;
  &lt;span class="n"&gt;payout_id&lt;/span&gt;    &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;type&lt;/span&gt;         &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;-- created / submitted / settled / failed / reversed / fee_charged&lt;/span&gt;
  &lt;span class="n"&gt;payload&lt;/span&gt;      &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ts_event&lt;/span&gt;     &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;-- when it happened (PSP/bank time)&lt;/span&gt;
  &lt;span class="n"&gt;ts_recv&lt;/span&gt;      &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;-- when we received it&lt;/span&gt;
  &lt;span class="k"&gt;source&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;             &lt;span class="c1"&gt;-- internal / psp_a / bank_statement / ops&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;payout_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payout_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts_event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few conscious choices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;event_id&lt;/code&gt; is the idempotency key.&lt;/strong&gt; Same event from the same source arrives twice → second insert is a primary-key conflict → no-op. This is how out-of-order delivery becomes safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two timestamps.&lt;/strong&gt; &lt;code&gt;ts_event&lt;/code&gt; is the &lt;em&gt;real&lt;/em&gt; clock (what the PSP says happened); &lt;code&gt;ts_recv&lt;/code&gt; is &lt;em&gt;your&lt;/em&gt; clock (when you saw it). Projections must order by &lt;code&gt;ts_event&lt;/code&gt; and break ties deterministically. You'll thank yourself the first time a webhook arrives 6 hours late.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;source&lt;/code&gt; tagged on every row.&lt;/strong&gt; When the PSP and the bank statement disagree, you need to know which one said what.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Examples of events you'd actually emit:&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="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"payout.created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"payout_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;"p1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"payload"&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;"amount_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;12000&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;"EUR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"user_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;"u1"&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"payout.submitted"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"payout_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;"p1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"payload"&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;"acquirer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"acq_a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"external_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;"tx_998"&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"payout.settled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"payout_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;"p1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"payload"&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;"external_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;"tx_998"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"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;35&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"payout.reversed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"payout_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;"p1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"payload"&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;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wrong_account"&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;h2&gt;
  
  
  The read model — what apps actually query
&lt;/h2&gt;

&lt;p&gt;Apps don't want to fold over event logs. Project to a flat table that's fast to read and trivially rebuildable.&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="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;payout_state&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;payout_id&lt;/span&gt;    &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;      &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;amount_cents&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;currency&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;-- derived&lt;/span&gt;
  &lt;span class="n"&gt;fee_cents&lt;/span&gt;    &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;acquirer&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;external_id&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_event&lt;/span&gt;   &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;-- pointer back into payout_events&lt;/span&gt;
  &lt;span class="n"&gt;last_ts&lt;/span&gt;      &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The projector is a small, &lt;strong&gt;deterministic&lt;/strong&gt; function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;project&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&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;t&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payout.created&lt;/span&gt;&lt;span class="sh"&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;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payout_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payout_id&lt;/span&gt;&lt;span class="sh"&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;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payload&lt;/span&gt;&lt;span class="sh"&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;user_id&lt;/span&gt;&lt;span class="sh"&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;amount_cents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payload&lt;/span&gt;&lt;span class="sh"&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;amount_cents&lt;/span&gt;&lt;span class="sh"&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;currency&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payload&lt;/span&gt;&lt;span class="sh"&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;currency&lt;/span&gt;&lt;span class="sh"&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;status&lt;/span&gt;&lt;span class="sh"&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;queued&lt;/span&gt;&lt;span class="sh"&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;fee_cents&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_id&lt;/span&gt;&lt;span class="sh"&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;last_ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts_event&lt;/span&gt;&lt;span class="sh"&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="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payout.submitted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_flight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;acquirer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payload&lt;/span&gt;&lt;span class="sh"&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;acquirer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                     &lt;span class="n"&gt;external_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payload&lt;/span&gt;&lt;span class="sh"&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;external_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payout.settled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;settled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;fee_cents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fee_cents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payload&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fee_cents&lt;/span&gt;&lt;span class="sh"&gt;"&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;elif&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payout.failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payout.reversed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reversed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts_event&lt;/span&gt;&lt;span class="sh"&gt;"&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;state&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three properties of a good projector:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pure.&lt;/strong&gt; No side effects beyond writing the state row. No external calls, no clocks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent.&lt;/strong&gt; Replaying the same event must produce the same result. The &lt;code&gt;event_id&lt;/code&gt; short-circuit lives at the caller layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order-tolerant.&lt;/strong&gt; A late &lt;code&gt;submitted&lt;/code&gt; arriving after &lt;code&gt;settled&lt;/code&gt; shouldn't downgrade the status. Encode the rule explicitly:
&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="n"&gt;RANK&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;queued&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_flight&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;settled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reversed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&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;upgrade&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;candidate_status&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;RANK&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;candidate_status&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;RANK&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;candidate_status&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire "out-of-order webhooks" problem solved in five lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rebuilding the read model
&lt;/h2&gt;

&lt;p&gt;The thing event sourcing buys you that the CRUD table can't: you can always rebuild.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rebuild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payout_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;state&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="n"&gt;ev&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;events_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payout_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts_event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;project&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payout_state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production you run this on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Schema migrations.&lt;/strong&gt; Add a new field to &lt;code&gt;payout_state&lt;/code&gt;? Backfill by replay, not by &lt;code&gt;UPDATE ... SET&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bug fixes in the projector.&lt;/strong&gt; A wrong status mapping fixes itself on replay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconciliation discrepancies.&lt;/strong&gt; When a balance is off, dump the events, replay, and the bug is either in the events (missing/extra) or in the projector (logic). One of those two — never both.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reconciliation as a diff
&lt;/h2&gt;

&lt;p&gt;This is where the model earns the rest of its weight. Take the bank statement, ingest it as events (&lt;code&gt;source = bank_statement&lt;/code&gt;), project to a &lt;em&gt;separate&lt;/em&gt; read model, and diff:&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payout_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;bank&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;payout_state&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="k"&gt;FULL&lt;/span&gt; &lt;span class="k"&gt;OUTER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;payout_state_bank&lt;/span&gt; &lt;span class="n"&gt;b&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;payout_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt;  &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;       &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output is a finite list of disputes-with-the-truth. Each row is either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A missing event on one side (most common) — backfill from the other source.&lt;/li&gt;
&lt;li&gt;A real money discrepancy (rare, important) — escalate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note the asymmetry: you don't &lt;em&gt;correct&lt;/em&gt; the read model directly. You add the missing event, replay the projector, and the diff resolves itself. That's the discipline that keeps the books honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs you
&lt;/h2&gt;

&lt;p&gt;Honest tradeoffs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cost&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;More storage (events live forever)&lt;/td&gt;
&lt;td&gt;Cold storage / archive tier after N months; events are tiny&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Projection latency (a few ms → seconds for high volume)&lt;/td&gt;
&lt;td&gt;Synchronous projector for the common path; async for bulk events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema discipline (event types are forever)&lt;/td&gt;
&lt;td&gt;Treat event types like API contracts — versioned, deprecated, never silently changed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational learning curve&lt;/td&gt;
&lt;td&gt;One page in the runbook on "how to replay" pays for itself the first incident&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are real but not large. The alternative is debugging a payouts table at midnight every settlement window.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about your accounting ledger?
&lt;/h2&gt;

&lt;p&gt;Two ledgers, two purposes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The payout read model&lt;/strong&gt; is for &lt;em&gt;operational&lt;/em&gt; state — what's in flight, what settled, what failed. Cached, fast to query, regenerable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The accounting ledger&lt;/strong&gt; (debits/credits, double-entry) is for &lt;em&gt;financial&lt;/em&gt; state — what your books look like. Driven from the same events but with stricter rules and an immutable history that finance can audit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don't confuse the two. Both should be projections from the same event log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Event sourcing isn't free, but for &lt;em&gt;payouts specifically&lt;/em&gt; the alignment is unusually clean: payments are events in the real world, and webhooks are the world telling you about them. The read model is the part your apps query; the events are the truth you can always fall back to.&lt;/p&gt;

&lt;p&gt;If you want to see how this fits the wider orchestration picture — global payouts, multi-acquirer settlement, FX, reconciliation — the &lt;a href="https://payneteasy.com/solutions/global-payouts?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=content2026q2&amp;amp;utm_content=payout-ledger-read-model&amp;amp;utm_term=global%20payouts" rel="noopener noreferrer"&gt;global payouts solution&lt;/a&gt; page has the architecture diagram.&lt;/p&gt;

&lt;p&gt;Next in this series: &lt;strong&gt;cross-border payment reconciliation&lt;/strong&gt; — matching multi-currency, multi-acquirer settlement files against the read model above.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Author: payments engineer at PaynetEasy — we build payment orchestration and global payouts infrastructure → &lt;a href="https://payneteasy.com" rel="noopener noreferrer"&gt;payneteasy.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>database</category>
      <category>systemdesign</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Smart transaction routing: turning auth-rate data into routing rules (without a black box)</title>
      <dc:creator>Payneteasy</dc:creator>
      <pubDate>Wed, 20 May 2026 09:11:49 +0000</pubDate>
      <link>https://dev.to/payneteasy/smart-transaction-routing-turning-auth-rate-data-into-routing-rules-without-a-black-box-3gj7</link>
      <guid>https://dev.to/payneteasy/smart-transaction-routing-turning-auth-rate-data-into-routing-rules-without-a-black-box-3gj7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A "smart router" is not a model — it's a rules engine fed by &lt;em&gt;fresh, segmented&lt;/em&gt; approval-rate data.&lt;/li&gt;
&lt;li&gt;Inputs you actually need: BIN/issuer, country, MCC, amount, currency, retry-count, acquirer health, rolling approval window.&lt;/li&gt;
&lt;li&gt;Cascading retries: retry only &lt;em&gt;soft&lt;/em&gt; declines, never &lt;em&gt;hard&lt;/em&gt; / fraud / lost-stolen. Fresh idempotency key per attempt.&lt;/li&gt;
&lt;li&gt;Approval-rate-aware routing without a circuit breaker is a way to hammer a dead acquirer. Don't skip the breaker.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;The phrase "smart transaction routing" gets used to sell a lot of black boxes. This post is the opposite: how to build one whose decisions a human can read, and how to keep it honest with data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a single acquirer stops being enough
&lt;/h2&gt;

&lt;p&gt;Three things, sooner or later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A regional approval dip.&lt;/strong&gt; Card schemes adjust, an issuer changes its fraud rules, you wake up to a 5pp drop in DE/NL and zero levers to pull.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An outage.&lt;/strong&gt; Acquirers go down. A merger or migration takes a network offline for a window. With one acquirer, your checkout is down with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing leverage.&lt;/strong&gt; Once you can move a percentage of traffic with a config change, every renegotiation is real.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is structural: route every transaction through a layer that knows about &lt;em&gt;more than one&lt;/em&gt; acquirer and decides where it goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of a rules engine
&lt;/h2&gt;

&lt;p&gt;Inputs that matter (in roughly this order of usefulness):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Why it changes the route&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Issuer BIN / country&lt;/td&gt;
&lt;td&gt;Local acquirers approve local cards better — almost universally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Currency / amount&lt;/td&gt;
&lt;td&gt;Cross-border fees jump at currency mismatches and ticket size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCC&lt;/td&gt;
&lt;td&gt;Some acquirers are strong in specific verticals (travel, digital goods, subscription)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brand (Visa / Mastercard / Amex / local)&lt;/td&gt;
&lt;td&gt;Amex / domestic networks often need a specialist acquirer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry count&lt;/td&gt;
&lt;td&gt;Sticky-on-retry vs failover-on-retry are different strategies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Acquirer health / approval-rate window&lt;/td&gt;
&lt;td&gt;Excludes acquirers in an active dip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time-of-day / day-of-week&lt;/td&gt;
&lt;td&gt;Some issuers have approval cycles — rarely worth bothering with day one&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Outputs are simple: the chosen acquirer (plus credentials) and a fallback chain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# rules.yaml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eu-cards-primary&lt;/span&gt;
  &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;issuer_country&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;DE&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;FR&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;NL&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;ES&lt;/span&gt;&lt;span class="pi"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;currency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;EUR&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;amount_lt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;50000&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;acquirer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;acq_a&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;70&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;acquirer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;acq_b&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;30&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;acq_c&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high-ticket-amex&lt;/span&gt;
  &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;brand&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;amex&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;amount_gte&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;50000&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;acquirer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;acq_amex_specialist&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;100&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;
  &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;acq_a&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;latam-default&lt;/span&gt;
  &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;issuer_country&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;BR&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;MX&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;CO&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;AR&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;acquirer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;acq_local_latam&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;100&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;
  &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;acq_a&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;):&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="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;matches&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;match&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;pick_weighted&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;route&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fallback&lt;/span&gt;&lt;span class="sh"&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="nf"&gt;default_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;default_fallbacks&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rules file is the &lt;strong&gt;contract between engineering and ops.&lt;/strong&gt; If your PM can't read it without you, you've built a black box. The most common mistake here is letting the matcher grow regex/DSL features until only its author understands it — resist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Routing strategies, with honest tradeoffs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;When to use&lt;/th&gt;
&lt;th&gt;Optimizes&lt;/th&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Least-cost&lt;/td&gt;
&lt;td&gt;Stable approval rates across acquirers&lt;/td&gt;
&lt;td&gt;Fees per approved tx&lt;/td&gt;
&lt;td&gt;A penny saved on fees can cost a dollar in declines if approval is uneven&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approval-rate-aware&lt;/td&gt;
&lt;td&gt;Volatile approval, multi-region&lt;/td&gt;
&lt;td&gt;Overall approval %&lt;/td&gt;
&lt;td&gt;Requires fresh data; flap risk if the window is too small&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weighted A/B&lt;/td&gt;
&lt;td&gt;Onboarding a new acquirer&lt;/td&gt;
&lt;td&gt;Risk-controlled ramp&lt;/td&gt;
&lt;td&gt;Don't keep the A/B forever — pick a winner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sticky-on-retry&lt;/td&gt;
&lt;td&gt;Card-on-file retries&lt;/td&gt;
&lt;td&gt;Consistency / fewer step-ups&lt;/td&gt;
&lt;td&gt;Sticky to a &lt;em&gt;failing&lt;/em&gt; acquirer = obvious bug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failover-on-retry&lt;/td&gt;
&lt;td&gt;First attempt failed, try elsewhere&lt;/td&gt;
&lt;td&gt;Recovers approvals&lt;/td&gt;
&lt;td&gt;Wrong on hard declines — see below&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The boring answer is that mature stacks combine all of these — the rules file becomes the explicit place where you say &lt;em&gt;when&lt;/em&gt; each one fires.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning auth-rate data into a routing rule (without a model)
&lt;/h2&gt;

&lt;p&gt;You don't need ML for this on day one. A rolling window per &lt;code&gt;(acquirer, bin_country, brand)&lt;/code&gt; is enough to be useful:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;approval_rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;acq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bin_country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window&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;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;acq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bin_country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="o"&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="n"&gt;window&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;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;MIN_SAMPLE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# not enough signal — fall back to the default rule
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;approved&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attempts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two production-grade details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Minimum sample.&lt;/strong&gt; With low volume on a corridor, a single decline drops the rate to 0% and you'd eject a fine acquirer. Require N ≥ 50 attempts before letting the data steer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Damping.&lt;/strong&gt; Don't switch winners on every refresh. EMA, hysteresis bands, or a 5-minute lockout after a flip — pick one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then the routing rule becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;best_acquirer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ranked&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="n"&gt;acq&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;approval_rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;acq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bin_country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;brand&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;rate&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;acq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&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;rate&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;KILL_SWITCH&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;           &lt;span class="c1"&gt;# e.g. 0.10
&lt;/span&gt;            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;acq&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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="n"&gt;acq&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;acq&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire "smart" of smart routing on day one. Add cost-weighting after the approval-rate signal is stable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cascading retries done right
&lt;/h2&gt;

&lt;p&gt;Retry soft declines, never hard ones:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decline class&lt;/th&gt;
&lt;th&gt;Retry?&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;do_not_honor&lt;/code&gt; (often issuer transient)&lt;/td&gt;
&lt;td&gt;Yes — different acquirer&lt;/td&gt;
&lt;td&gt;Issuers re-evaluate via a different fingerprint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;insufficient_funds&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Maybe, after delay&lt;/td&gt;
&lt;td&gt;Topping-up is a real event but most retries are wishful&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;issuer_unavailable&lt;/code&gt; / &lt;code&gt;network_timeout&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Definitionally transient&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;lost_card&lt;/code&gt; / &lt;code&gt;stolen_card&lt;/code&gt; / &lt;code&gt;pickup_card&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Never&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Card-network rule; you'll get fined&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;do_not_honor&lt;/code&gt; flagged as fraud&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Never&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fraud scores stack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expired_card&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Need new credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;charge_with_cascade&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fallbacks&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;acq&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;fallbacks&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;circuit_open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;acq&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;adapters&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;acq&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idem_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;attempt_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;approved&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;res&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;taxonomy&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hard&lt;/span&gt;&lt;span class="sh"&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;fraud&lt;/span&gt;&lt;span class="sh"&gt;"&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;res&lt;/span&gt;   &lt;span class="c1"&gt;# do NOT cascade
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Declined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;all routes exhausted&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;The two non-obvious details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fresh idempotency key per attempt.&lt;/strong&gt; Different acquirers don't share state; reusing the same key is undefined.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard-decline short-circuit.&lt;/strong&gt; This is also the thing that protects you from disputes if a card was reported stolen between attempts.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Health checks, circuit breakers, observability
&lt;/h2&gt;

&lt;p&gt;The router will lie to you if it's not measured. The minimum kit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Synthetic pings.&lt;/strong&gt; Heartbeats per acquirer, decoupled from real traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error-rate circuit breaker.&lt;/strong&gt; Trip on (5xx + timeouts) / total &amp;gt; X% over Y minutes. Auto-eject, auto-return after a cool-down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approval-rate alerting.&lt;/strong&gt; Page on a 3σ drop per &lt;code&gt;(acquirer × bin_country)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-rule shadow.&lt;/strong&gt; Log what the &lt;em&gt;previous&lt;/em&gt; rule version would have decided. You'll need this every time you change a rule.
&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="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CircuitBreaker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;fail_rate_threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;
    &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timedelta&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;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cooldown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timedelta&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;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&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;open_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;acq&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;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;acq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Metrics that matter
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Formula&lt;/th&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Overall approval %&lt;/td&gt;
&lt;td&gt;approved / attempted (unique tx)&lt;/td&gt;
&lt;td&gt;maximize&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per approved tx&lt;/td&gt;
&lt;td&gt;total fees / approved&lt;/td&gt;
&lt;td&gt;minimize&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fallback rate&lt;/td&gt;
&lt;td&gt;tx using fallback / total&lt;/td&gt;
&lt;td&gt;low + stable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry success uplift&lt;/td&gt;
&lt;td&gt;extra approvals from cascade / attempted&lt;/td&gt;
&lt;td&gt;track &amp;amp; celebrate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95 routing latency&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&amp;lt; 50 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-rule decision drift&lt;/td&gt;
&lt;td&gt;rules diff vs shadow&lt;/td&gt;
&lt;td&gt;review weekly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Without these you have a router; with them you have a routing system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build vs buy
&lt;/h2&gt;

&lt;p&gt;This is the article that gets answered most often with "buy" — and that's usually right when routing isn't your moat. Where an orchestration platform actually saves months is in the &lt;strong&gt;adapters, normalized error taxonomy, and reconciliation&lt;/strong&gt;, not the rules engine itself. The rules engine is the &lt;em&gt;easy&lt;/em&gt; part; staying current with 12 acquirer APIs is what burns a team out.&lt;/p&gt;

&lt;p&gt;A pragmatic split: keep the rules and the data in-house, integrate the adapters via a platform. That gives you the strategic edge and removes the busy-work.&lt;/p&gt;

&lt;p&gt;If you want the orchestration-layer view of the whole picture, the &lt;a href="https://payneteasy.com/solutions/payment-orchestration?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=content2026q2&amp;amp;utm_content=smart-transaction-routing-rules&amp;amp;utm_term=smart%20transaction%20routing" rel="noopener noreferrer"&gt;payment orchestration overview&lt;/a&gt; walks through the architecture and the build-vs-buy lines we use with customers.&lt;/p&gt;

&lt;p&gt;The next post in this series gets into &lt;strong&gt;cross-border reconciliation&lt;/strong&gt; — settlement files, currency, fee parsing — which is where most homemade routing layers quietly fall over.&lt;/p&gt;




&lt;p&gt;*Author: payments engineer at PaynetEasy — we build payment orchestration and global payouts infrastructure → &lt;a href="https://payneteasy.com" rel="noopener noreferrer"&gt;payneteasy.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>payments</category>
      <category>architecture</category>
      <category>fintech</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>What's your idempotency-key TTL — and what broke when you got it wrong?</title>
      <dc:creator>Payneteasy</dc:creator>
      <pubDate>Mon, 18 May 2026 22:26:17 +0000</pubDate>
      <link>https://dev.to/payneteasy/whats-your-idempotency-key-ttl-and-what-broke-when-you-got-it-wrong-58jj</link>
      <guid>https://dev.to/payneteasy/whats-your-idempotency-key-ttl-and-what-broke-when-you-got-it-wrong-58jj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Quick one for the payments / API engineers in here.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Idempotency keys are one of those features where "we have them" hides a lot of variance. The piece nobody agrees on is the &lt;strong&gt;TTL&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I've seen production systems run with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1 hour&lt;/strong&gt; — fine for browser checkout, breaks the moment a mobile app retries from an offline queue 2 hours later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;24 hours&lt;/strong&gt; — the default-ish answer, fits an end-of-day reconciliation cycle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;48–72 hours&lt;/strong&gt; — server-to-server with human-in-the-loop retries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;7 days&lt;/strong&gt; — usually because someone got burned once and didn't want to pick a new fight with finance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest heuristic I keep coming back to: &lt;strong&gt;TTL ≥ your longest legitimate retry path, and &amp;lt; your reconciliation window&lt;/strong&gt;. Anything tighter and a real retry lands after the key has expired (double charge); anything looser and you can't close the books cleanly.&lt;/p&gt;

&lt;p&gt;So — two questions, looking for war stories more than theory:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;What TTL do you run, and what's the use case it's tuned for?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Have you ever had a duplicate transaction (or a near-miss) because the TTL was wrong? What did the post-mortem land on?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;(If you're using idempotency keys &lt;em&gt;without&lt;/em&gt; a body fingerprint, that's a different conversation, and worth its own thread — same key + different body is a quiet way to get the wrong thing accepted.)&lt;/p&gt;

&lt;p&gt;I'm collecting answers for a follow-up post on idempotency patterns in payment APIs — happy to credit anyone who shares a story. Comments below 👇&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Author: payments engineer at PaynetEasy — we build payment orchestration and global payouts infrastructure.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>payments</category>
      <category>api</category>
    </item>
    <item>
      <title>Idempotency keys in payment APIs: the patterns that actually prevent double charges</title>
      <dc:creator>Payneteasy</dc:creator>
      <pubDate>Sun, 17 May 2026 09:58:53 +0000</pubDate>
      <link>https://dev.to/payneteasy/idempotency-keys-in-payment-apis-the-patterns-that-actually-prevent-double-charges-4bb2</link>
      <guid>https://dev.to/payneteasy/idempotency-keys-in-payment-apis-the-patterns-that-actually-prevent-double-charges-4bb2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;TTLs, key derivation, retry storms, and the six ways teams accidentally let the same charge through twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The idempotency key is the &lt;em&gt;only&lt;/em&gt; thing that keeps "the network blinked, let's retry" from turning into two real charges.&lt;/li&gt;
&lt;li&gt;The key has to be &lt;strong&gt;client-generated&lt;/strong&gt;, &lt;strong&gt;stable across retries&lt;/strong&gt;, &lt;strong&gt;scoped to the operation&lt;/strong&gt;, &lt;strong&gt;persisted server-side&lt;/strong&gt;, and &lt;strong&gt;TTL'd&lt;/strong&gt; — miss any one and you have a bug waiting for a busy Friday.&lt;/li&gt;
&lt;li&gt;Replay semantics matter: a retry with the same key must return the &lt;em&gt;same response&lt;/em&gt;, not "OK, accepted" again.&lt;/li&gt;
&lt;li&gt;The six classic ways teams get this wrong are all preventable. They just need to be on a checklist.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;If your payment API has a &lt;code&gt;/charge&lt;/code&gt; endpoint and no idempotency key in the request header, this post is for you. If it has one but you're not sure what the TTL should be — also for you.&lt;/p&gt;

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

&lt;p&gt;Every retry in a payment system is a coin flip on a duplicate. The classic failure goes like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client ──POST /charge───▶ server  (200 OK, settled)
       ◀──── socket reset ────
client ──POST /charge (retry)──▶ server  (200 OK, settled AGAIN)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server has no way of knowing the second request is the same one. The card was just charged twice. The customer files a chargeback. You eat the fee and (depending on the network) a hit to your dispute ratio.&lt;/p&gt;

&lt;p&gt;An idempotency key fixes this with one rule: &lt;strong&gt;for a given key, the server returns the same response for a window of time, regardless of how many times the request arrives.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client ──POST /charge   key=k1──▶ server  (200 OK, settled, store(k1, resp))
       ◀──── socket reset ────
client ──POST /charge   key=k1──▶ server  (returns stored resp; no new charge)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Five properties of a key that works
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Client-generated.&lt;/strong&gt; The point of the key is to survive a retry; if the server generates it, every retry gets a new one. Use a UUIDv4 or &lt;code&gt;&amp;lt;order_id&amp;gt;:&amp;lt;attempt&amp;gt;&lt;/code&gt; — anything the client controls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stable across retries.&lt;/strong&gt; The same logical operation must reuse the same key for the entire retry window. If you regenerate on each attempt, you've defeated the mechanism.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scoped to the operation.&lt;/strong&gt; "Charge $42" and "Refund $42" share nothing; reusing a key across them is undefined behavior. Most APIs scope by &lt;code&gt;(merchant, endpoint, key)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persisted server-side.&lt;/strong&gt; Not in memory, not in the load balancer — in a durable store the next worker can read. Otherwise a process restart between attempt #1 and attempt #2 nukes the protection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TTL'd.&lt;/strong&gt; Keys live for a bounded window (commonly 24h). After that they expire so you can reuse them for new operations and your store doesn't grow forever.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A reference implementation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;

&lt;span class="n"&gt;TTL_SECONDS&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;3600&lt;/span&gt;   &lt;span class="c1"&gt;# 24h — fits a typical end-of-day reconciliation window
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;canonical&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sort_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;separators&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;,&lt;/span&gt;&lt;span class="sh"&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;:&lt;/span&gt;&lt;span class="sh"&gt;"&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;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&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;handle_charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;acquirer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&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;key&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;400&lt;/span&gt;&lt;span class="p"&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;error&lt;/span&gt;&lt;span class="sh"&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;Idempotency-Key required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;fp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&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="n"&gt;key&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;cached&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Same key, same body → replay the previous response
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fingerprint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;fp&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;422&lt;/span&gt;&lt;span class="p"&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;error&lt;/span&gt;&lt;span class="sh"&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;Idempotency-Key reuse with different body&lt;/span&gt;&lt;span class="sh"&gt;"&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;cached&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="c1"&gt;# First sighting — reserve the slot so concurrent retries serialize
&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;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_if_absent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&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;state&lt;/span&gt;&lt;span class="sh"&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;in_flight&lt;/span&gt;&lt;span class="sh"&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;fingerprint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()},&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TTL_SECONDS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# Another worker is already processing this key — wait or return 409
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&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;error&lt;/span&gt;&lt;span class="sh"&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;request in progress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;acquirer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;store&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="n"&gt;key&lt;/span&gt;&lt;span class="p"&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;state&lt;/span&gt;&lt;span class="sh"&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;done&lt;/span&gt;&lt;span class="sh"&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;fingerprint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()},&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TTL_SECONDS&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;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details worth flagging:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;fingerprint check&lt;/strong&gt; (&lt;code&gt;cached["fingerprint"] != fp&lt;/code&gt;) is what turns "I sent the same key twice with different amounts" from an exploit into a 422.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;&lt;code&gt;set_if_absent&lt;/code&gt; reservation&lt;/strong&gt; is what stops two concurrent retries from both calling the acquirer at once. Without it, you've moved the race condition one layer down.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;24h TTL&lt;/strong&gt; is a convention, not a law. Pick it to match your operational window — see the next section.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  TTL: how long is long enough?
&lt;/h2&gt;

&lt;p&gt;This is where teams disagree. The answer depends on what &lt;em&gt;your&lt;/em&gt; retry surface looks like.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Caller&lt;/th&gt;
&lt;th&gt;Realistic retry horizon&lt;/th&gt;
&lt;th&gt;TTL guidance&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Browser checkout&lt;/td&gt;
&lt;td&gt;seconds → a few minutes&lt;/td&gt;
&lt;td&gt;1h is plenty&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mobile app (offline-tolerant)&lt;/td&gt;
&lt;td&gt;up to 24h (offline queue)&lt;/td&gt;
&lt;td&gt;24h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server-to-server with manual retry&lt;/td&gt;
&lt;td&gt;up to a few days&lt;/td&gt;
&lt;td&gt;48–72h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Batch payouts (overnight files)&lt;/td&gt;
&lt;td&gt;aligned to settlement cycle&lt;/td&gt;
&lt;td&gt;match the cycle (often 24h)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two heuristics that survive most arguments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Longer than your longest &lt;em&gt;real&lt;/em&gt; retry path, shorter than your reconciliation cycle.&lt;/strong&gt; If a retry can land after you've closed the books on that day, your books are wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If in doubt, 24h.&lt;/strong&gt; Then revisit when you have data on actual key reuse age.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The six classic ways teams get this wrong
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Regenerating the key on each retry.&lt;/strong&gt; Almost always a bug in a retry library's "what changes per attempt" config. Fix: generate the key once, persist it with the order, reuse it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storing keys in memory only.&lt;/strong&gt; Works until your process restarts. Fix: durable store (Postgres unique-index, Redis with persistence, DynamoDB).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not scoping the key.&lt;/strong&gt; A refund retry uses the same key as the original charge → 422 or, worse, undefined. Fix: scope by &lt;code&gt;(merchant, endpoint, key)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not fingerprinting the body.&lt;/strong&gt; Same key + different body = silent acceptance of whichever request arrived first, with no way to tell. Fix: hash the canonical body and compare.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No reservation between concurrent retries.&lt;/strong&gt; Two retries arrive on two workers, both miss the cache, both call the acquirer. Fix: &lt;code&gt;INSERT ... ON CONFLICT DO NOTHING&lt;/code&gt; or a Redis &lt;code&gt;SET NX&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TTL shorter than the retry window.&lt;/strong&gt; Mobile app retries 2 hours later; the key has expired; the charge goes through twice. Fix: TTL ≥ longest legitimate retry path.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Idempotency in cascading retries
&lt;/h2&gt;

&lt;p&gt;In a multi-acquirer setup, "retry" can mean two different things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same acquirer, transient network error&lt;/strong&gt; → same idempotency key. You want the acquirer to deduplicate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different acquirer, after a hard failure&lt;/strong&gt; → &lt;em&gt;new&lt;/em&gt; idempotency key per attempt. The acquirers don't share state; reusing the same key would either be ignored or — worse — happen to clash with someone else's traffic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A clean way to encode this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;attempt_key&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;charge_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:a&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# client-stable, per-attempt
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;charge_id&lt;/code&gt; is the logical operation (lives in your DB, survives retries), &lt;code&gt;attempt&lt;/code&gt; increments only when you fall over to a &lt;em&gt;different&lt;/em&gt; acquirer.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small checklist for your code review
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Header name documented (&lt;code&gt;Idempotency-Key&lt;/code&gt;) and required on mutating endpoints.&lt;/li&gt;
&lt;li&gt;[ ] Server returns 400 if missing on charge/refund/payout endpoints.&lt;/li&gt;
&lt;li&gt;[ ] Body fingerprint stored alongside the key; mismatch = 422.&lt;/li&gt;
&lt;li&gt;[ ] Reservation pattern prevents concurrent execution.&lt;/li&gt;
&lt;li&gt;[ ] TTL ≥ your longest legitimate retry path &lt;em&gt;and&lt;/em&gt; &amp;lt; your reconciliation window.&lt;/li&gt;
&lt;li&gt;[ ] Replay returns the original status code, not a generic 200.&lt;/li&gt;
&lt;li&gt;[ ] Tested with a fault injector that drops responses between server commit and client receipt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your checkout endpoint passes all seven, you've taken the most common cause of double charges off the table.&lt;/p&gt;




&lt;p&gt;The next post in this series treats the &lt;strong&gt;payout ledger as a real-time read model&lt;/strong&gt; — same correctness mindset, different shape: append-only events, idempotent projections, end-of-day reconciliation that doesn't need a Friday-night batch. If you want the orchestration-layer view, see the &lt;a href="https://payneteasy.com/solutions/payment-orchestration?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=content2026q2&amp;amp;utm_content=payment-idempotency-keys-patterns&amp;amp;utm_term=payment%20idempotency" rel="noopener noreferrer"&gt;payment orchestration overview&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;*Author: payments engineer at PaynetEasy — we build payment orchestration and global payouts infrastructure → &lt;a href="https://payneteasy.com" rel="noopener noreferrer"&gt;payneteasy.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>payments</category>
      <category>api</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Payment orchestration for engineers: what it is, when you actually need it, and build-vs-buy</title>
      <dc:creator>Payneteasy</dc:creator>
      <pubDate>Thu, 14 May 2026 10:39:29 +0000</pubDate>
      <link>https://dev.to/payneteasy/payment-orchestration-for-engineers-what-it-is-when-you-actually-need-it-and-build-vs-buy-59c</link>
      <guid>https://dev.to/payneteasy/payment-orchestration-for-engineers-what-it-is-when-you-actually-need-it-and-build-vs-buy-59c</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Not a buzzword. Orchestration is a routing layer + a normalized payment model + a reconciliation spine. Here's the engineering view — and an honest build-vs-buy.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Payment orchestration&lt;/strong&gt; is three things, not one: a &lt;em&gt;provider-agnostic API&lt;/em&gt;, a &lt;em&gt;routing layer&lt;/em&gt; (retries, cascades, least-cost / best-auth-rate decisions), and a &lt;em&gt;reconciliation spine&lt;/em&gt; that ties every attempt back to money that actually moved.&lt;/li&gt;
&lt;li&gt;You don't need it on day one. You need it the day your codebase grows its third &lt;code&gt;if provider == "X"&lt;/code&gt; branch — or the day Finance asks "why don't these two reports match?"&lt;/li&gt;
&lt;li&gt;"We'll just add acquirer #2 ourselves" is the classic trap: the easy 20% (a second adapter) is visible; the hard 80% (idempotency across providers, webhook fan-in, one settlement model, dispute plumbing) shows up six months later.&lt;/li&gt;
&lt;li&gt;Build-vs-buy is a real decision with real numbers. This post gives you the component checklist, a provider-agnostic interface sketch, a decision matrix, and a migration path that doesn't bet the checkout on a big-bang cutover.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The symptom: your payment code is branching on the provider
&lt;/h2&gt;

&lt;p&gt;Here's the smell. Somewhere in your codebase there's a function that started innocent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provider&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;provider&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acquirer_a&lt;/span&gt;&lt;span class="sh"&gt;"&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;acquirer_a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_payment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&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="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acquirer_b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# different field names, cents vs decimal, different 3DS flow...
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;acquirer_b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;order&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="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cur&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...})&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wallet_x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# redirect-based, no card at all, async callback
&lt;/span&gt;        &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every new payment method or geography adds a branch. The branches leak into refunds, into webhook handlers, into reconciliation, into your fraud checks. Soon "add a payment provider" is a two-sprint project and nobody wants to touch the file.&lt;/p&gt;

&lt;p&gt;That branching is the thing orchestration removes. Not by magic — by forcing a &lt;strong&gt;normalized payment model&lt;/strong&gt; at the boundary and pushing every provider's quirks into an adapter behind it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you have exactly one PSP and no plans to add another, you don't have this problem yet. Don't build orchestration for a problem you don't have. The rest of this post is about recognizing when you &lt;em&gt;do&lt;/em&gt; have it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What "orchestration" actually is — the five components
&lt;/h2&gt;

&lt;p&gt;Strip the marketing and a payment orchestration layer is five concrete things. If a "platform" gives you only the first one, you've bought a thin proxy, not orchestration.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;What it owns&lt;/th&gt;
&lt;th&gt;What breaks without it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;1. Normalized payment model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One &lt;code&gt;Charge&lt;/code&gt; / &lt;code&gt;Refund&lt;/code&gt; / &lt;code&gt;Payout&lt;/code&gt; shape; one currency representation; one status vocabulary; one error taxonomy&lt;/td&gt;
&lt;td&gt;Provider quirks leak into business logic; every consumer re-learns each provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2. Routing layer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Which provider gets this transaction; retries; cascades on soft declines; least-cost / best-auth-rate decisioning; circuit breakers&lt;/td&gt;
&lt;td&gt;One acquirer outage = checkout down; no recovery of "issuer was flaky for 400ms" declines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;3. Retry &amp;amp; cascade engine&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Idempotency keys across providers; bounded re-attempts; decline-code awareness; resume-after-3DS&lt;/td&gt;
&lt;td&gt;Double charges; infinite retry storms; cascading a &lt;code&gt;stolen_card&lt;/code&gt; (don't)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;4. Webhook fan-in&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Normalizing N providers' async callbacks into one event stream; de-dup; ordering; replay&lt;/td&gt;
&lt;td&gt;You write N webhook handlers, each with its own retry semantics; missed/duplicate events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;5. Reconciliation spine&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Joining attempts → authorizations → captures → settlement files → ledger; flagging mismatches&lt;/td&gt;
&lt;td&gt;Finance reports that don't tie out; disputes you can't evidence; silent revenue leakage&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Components 1–3 are what most people picture. Components &lt;strong&gt;4 and 5 are where the real engineering is&lt;/strong&gt;, and they're the ones a DIY "we just added an adapter" effort almost always skips. A second adapter is a week. A reconciliation spine that survives a contested chargeback eight months later is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code: the provider-agnostic boundary
&lt;/h2&gt;

&lt;p&gt;The heart of component 1 is an interface every provider adapter implements. Keep it small and money-shaped:&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="c1"&gt;// The normalized model — providers adapt TO this, not the other way around.&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Money&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;amountMinor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bigint&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="c1"&gt;// always minor units, no floats&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;PaymentProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;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="nf"&gt;authorize&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;AuthorizeRequest&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;AuthorizeResult&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;capture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authId&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;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idemKey&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CaptureResult&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;refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;captureId&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;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idemKey&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;RefundResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Async truth arrives via webhooks, normalized to the same event type:&lt;/span&gt;
  &lt;span class="nf"&gt;parseWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HttpRequest&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;NormalizedEvent&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;type&lt;/span&gt; &lt;span class="nx"&gt;AuthorizeResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;authId&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;providerRef&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="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;declined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DeclineCode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;retryable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// taxonomy is OURS&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;action_required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3ds_challenge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;redirect&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;url&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="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;                        &lt;span class="c1"&gt;// network/5xx ≠ decline&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two design rules that save you later:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Money is always minor units in an integer/bigint.&lt;/strong&gt; No &lt;code&gt;amount * 100&lt;/code&gt; scattered across adapters. The conversion happens once, in the adapter, on the way in and out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The decline-code taxonomy belongs to you, not to a provider.&lt;/strong&gt; Each adapter maps that provider's &lt;code&gt;91&lt;/code&gt; / &lt;code&gt;do_not_honor&lt;/code&gt; / &lt;code&gt;try_again_later&lt;/code&gt; onto &lt;em&gt;your&lt;/em&gt; enum and sets &lt;code&gt;retryable&lt;/code&gt;. The routing layer never sees a raw provider code.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the routing layer that sits on top is, at its simplest, a scored candidate list — not a black box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pick_candidates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;txn&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;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;scored&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="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;eligible_providers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;txn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;           &lt;span class="c1"&gt;# currency, method, scheme, geography
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;circuit_open&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="c1"&gt;# provider in cooldown? skip
&lt;/span&gt;            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="mf"&gt;0.55&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;rolling_auth_rate&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;txn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bin_country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;txn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_band&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# what works
&lt;/span&gt;          &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;normalized_cost&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;txn&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                           &lt;span class="c1"&gt;# what's cheap
&lt;/span&gt;          &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;health_score&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="c1"&gt;# latency/errors
&lt;/span&gt;        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;scored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;score&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scored&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)][:&lt;/span&gt;&lt;span class="n"&gt;MAX_HOPS&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# bounded!
&lt;/span&gt;
&lt;span class="c1"&gt;# Then: try candidate 0; on a *retryable* decline, cascade to candidate 1; stop at
# MAX_HOPS or a wall-clock deadline; never cascade a hard decline (insufficient_funds,
# stolen_card, ...). One idempotency key per *cascade*, reused per hop — see the
# idempotency-keys post in this series.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole "AI routing" mystique demystified for the simple case: features → score → ordered list → bounded cascade, with every decision logged so you can answer "why did this $4,000 transaction go to acquirer C?" A model can replace the weighted sum later; the explainability requirement doesn't go away when it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a single PSP is fine — and when you've outgrown it
&lt;/h2&gt;

&lt;p&gt;Adding orchestration has a cost (a new layer in your most critical path). Use it when the pain is real, not aspirational.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Single PSP is fine&lt;/th&gt;
&lt;th&gt;You've outgrown it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Geographies&lt;/td&gt;
&lt;td&gt;One market, local cards&lt;/td&gt;
&lt;td&gt;Multiple regions, local methods (iDEAL, PIX, UPI, SEPA Instant…)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Volume&lt;/td&gt;
&lt;td&gt;Low enough that a 0.5–2 pp auth-rate gap is noise&lt;/td&gt;
&lt;td&gt;High enough that 1 pp of auth rate is a meaningful revenue line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resilience&lt;/td&gt;
&lt;td&gt;An hour of PSP downtime is survivable&lt;/td&gt;
&lt;td&gt;PSP downtime = direct revenue loss / SLA breach&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Interchange-plus is whatever it is&lt;/td&gt;
&lt;td&gt;Routing by cost/scheme would save real money at your volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Method sprawl&lt;/td&gt;
&lt;td&gt;Cards (+ maybe one wallet)&lt;/td&gt;
&lt;td&gt;A growing matrix of methods × providers, each with its own webhook&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Org&lt;/td&gt;
&lt;td&gt;One team owns payments end-to-end&lt;/td&gt;
&lt;td&gt;Finance, fraud, and product all consume payment data and it must agree&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A useful litmus test: &lt;strong&gt;count the &lt;code&gt;if provider ==&lt;/code&gt; branches and the distinct webhook handlers.&lt;/strong&gt; One of each — you're fine. Three or more — orchestration will pay for itself, either as something you build deliberately or something you buy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build-vs-buy, honestly
&lt;/h2&gt;

&lt;p&gt;This is a genuine fork, and the honest answer is "it depends — here's on what."&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Build it yourself&lt;/th&gt;
&lt;th&gt;Buy / adopt a platform&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Upfront engineering&lt;/td&gt;
&lt;td&gt;~6–18 eng-months for a &lt;em&gt;real&lt;/em&gt; one (model + routing + retries + webhook fan-in + reconciliation), not the 1-month adapter&lt;/td&gt;
&lt;td&gt;Integration weeks, not months&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ongoing maintenance&lt;/td&gt;
&lt;td&gt;Permanent: new provider quirks, scheme mandates (3DS, SCA, network tokens, VoP…), reconciliation edge cases&lt;/td&gt;
&lt;td&gt;Mostly absorbed by the vendor; you track &lt;em&gt;their&lt;/em&gt; changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compliance / PCI scope&lt;/td&gt;
&lt;td&gt;You may pull more card data into scope unless you're careful with tokenization&lt;/td&gt;
&lt;td&gt;Often reduces your scope (vaulting, redirect/iframe) — verify per vendor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Acquirer contracts&lt;/td&gt;
&lt;td&gt;You negotiate and hold every contract&lt;/td&gt;
&lt;td&gt;Either you bring your own acquirers (BYO-acquiring) or use theirs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control &amp;amp; differentiation&lt;/td&gt;
&lt;td&gt;Total control; routing logic can be a competitive edge&lt;/td&gt;
&lt;td&gt;Less control over the deep internals; you depend on roadmap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The hidden 80%&lt;/td&gt;
&lt;td&gt;Idempotency across providers, webhook ordering/de-dup, dispute evidence, settlement-file parsing, ledger truth&lt;/td&gt;
&lt;td&gt;Should be solved already — &lt;em&gt;make this an evaluation question, not an assumption&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rule of thumb: &lt;strong&gt;build it if payment routing is core to your product's economics or differentiation&lt;/strong&gt; (you're a marketplace, a PSP, a platform whose margin lives in routing) &lt;strong&gt;and you can fund the ongoing team.&lt;/strong&gt; Otherwise the maintenance tail — not the initial build — is what makes "buy" win. Either way, the component checklist above is your spec: if you buy, score vendors against all five; if you build, don't ship 1–3 and call it done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anti-patterns
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Orchestration as a thin proxy.&lt;/strong&gt; A normalized API in front of two providers with no reconciliation spine = you built the easy 20% and named it after the hard part. The first contested chargeback exposes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routing without circuit breakers.&lt;/strong&gt; "Best auth rate" routing that keeps hammering a provider mid-incident turns one provider's outage into your outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-attempt idempotency keys.&lt;/strong&gt; Regenerating the key on each cascade hop defeats the point — a retried HTTP call to the &lt;em&gt;same&lt;/em&gt; provider can now create a second authorization. One key per cascade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Floating-point money.&lt;/strong&gt; &lt;code&gt;amount * 100&lt;/code&gt; in three adapters with three rounding behaviors. Minor units, integers, one conversion site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measuring auth rate per attempt.&lt;/strong&gt; Cascades make per-attempt auth rate look worse and routing changes look better than reality. Attribute per &lt;em&gt;cascade&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Big-bang cutover.&lt;/strong&gt; Moving 100% of checkout to a new orchestration layer over a weekend. Don't.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Migration path: strangler-fig, not big bang
&lt;/h2&gt;

&lt;p&gt;If you're adding orchestration to a live system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Wrap, don't replace.&lt;/strong&gt; Put the normalized &lt;code&gt;PaymentProvider&lt;/code&gt; interface in front of your &lt;em&gt;existing&lt;/em&gt; PSP first. Same provider, new boundary. Ship that. Nothing user-visible changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route a sliver.&lt;/strong&gt; Send 1% of eligible traffic through the new layer (still to the same provider). Watch auth rate, latency, error rate, and — critically — that reconciliation still ties out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add provider #2 behind the interface.&lt;/strong&gt; Now it's an adapter, not a branch in business logic. Route a small % to it; compare auth rates per BIN-country/amount-band.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn on cascades&lt;/strong&gt; on retryable declines, bounded (&lt;code&gt;MAX_HOPS&lt;/code&gt;, deadline). Measure recovered transactions and double-auth incidents (should be ~0, caught by the reconciliation sweep).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move webhooks to the fan-in.&lt;/strong&gt; One normalized event stream; retire the per-provider handlers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the ledger the source of truth.&lt;/strong&gt; Reconciliation runs against the spine, not against one provider's dashboard. Now adding provider #3 is a checklist, not a project.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each step is independently shippable and independently reversible. That's the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Copy-this checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Normalized money type — &lt;strong&gt;minor units, integer/bigint&lt;/strong&gt;, converted once per adapter.&lt;/li&gt;
&lt;li&gt;[ ] Decline-code taxonomy is &lt;strong&gt;yours&lt;/strong&gt;; each adapter maps the provider's codes onto it and sets &lt;code&gt;retryable&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;error&lt;/code&gt; (network/5xx/timeout) is &lt;strong&gt;not&lt;/strong&gt; the same as &lt;code&gt;declined&lt;/code&gt; in your model.&lt;/li&gt;
&lt;li&gt;[ ] Routing produces a &lt;strong&gt;bounded&lt;/strong&gt; candidate list (&lt;code&gt;MAX_HOPS&lt;/code&gt;, wall-clock deadline).&lt;/li&gt;
&lt;li&gt;[ ] Every routing decision is &lt;strong&gt;logged with its reason&lt;/strong&gt; (explainable, even if a model picks).&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Circuit breakers&lt;/strong&gt; pull a provider out of rotation on error-rate spike; bleed traffic back gradually.&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;One idempotency key per cascade&lt;/strong&gt;, reused per hop — never regenerated.&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Webhook fan-in&lt;/strong&gt;: de-dup, ordering, replay — one normalized event stream, not N handlers.&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Reconciliation spine&lt;/strong&gt; joins attempt → auth → capture → settlement file → ledger; flags mismatches.&lt;/li&gt;
&lt;li&gt;[ ] Auth rate measured &lt;strong&gt;per cascade&lt;/strong&gt;, not per attempt; you also watch &lt;strong&gt;cost per approved txn&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;[ ] If buying: vendor scored against &lt;strong&gt;all five components&lt;/strong&gt;, not just the normalized API.&lt;/li&gt;
&lt;li&gt;[ ] If building: rollout is &lt;strong&gt;strangler-fig&lt;/strong&gt; (wrap → 1% → provider #2 → cascades → webhooks → ledger), each step reversible.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;*Written by the engineering team at PaynetEasy — payment orchestration &amp;amp; cross-border payouts infrastructure. We write about routing, reconciliation and money-movement correctness at &lt;a href="https://payneteasy.com" rel="noopener noreferrer"&gt;payneteasy.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>payments</category>
      <category>systemdesign</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
