<?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.us-east-2.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>Anakatech Selects Payneteasy Orchestration as the Backbone for Its Multi-Brand Gaming Payments</title>
      <dc:creator>Payneteasy</dc:creator>
      <pubDate>Wed, 15 Jul 2026 15:19:48 +0000</pubDate>
      <link>https://dev.to/payneteasy/anakatech-selects-payneteasy-orchestration-as-the-backbone-for-its-multi-brand-gaming-payments-1l4m</link>
      <guid>https://dev.to/payneteasy/anakatech-selects-payneteasy-orchestration-as-the-backbone-for-its-multi-brand-gaming-payments-1l4m</guid>
      <description>&lt;p&gt;&lt;em&gt;&lt;strong&gt;Subheadline:&lt;/strong&gt; Five-year partnership enables flexible routing, fast integrations, and uplift in approval rates while relieving pressure on Anakatech’s development team.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzt95f1wt4ymqdqs8n2d2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzt95f1wt4ymqdqs8n2d2.png" alt=" " width="800" height="509"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.anakatech.com" rel="noopener noreferrer"&gt;Anakatech&lt;/a&gt; is a multi-brand gaming platform that operates its own PCI DSS Level 1 cashier. Over more than five years of collaboration, &lt;a href="https://payneteasy.com" rel="noopener noreferrer"&gt;Payneteasy’s orchestration&lt;/a&gt; has become the central layer powering payments across Anakatech’s brands. The company initially turned to Payneteasy to solve a core challenge: run payments at scale without consuming the development capacity needed for product work. Payneteasy’s role in the partnership is to provide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Flexible routing based on Anakatech’s risk appetite and business rules&lt;/li&gt;
&lt;li&gt;Fast activation of new payment integrations&lt;/li&gt;
&lt;li&gt;A significant reduction in integration-related workload for the development team&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;From the “plug and play” myth to ongoing operations&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At Anakatech’s scale, payments are no longer a one time project. Once a method goes live, it must be kept up to date, updated regularly, and continuously fixed as issues appear in real world processing. At the same time, the business needs to roll out new providers and methods without exhausting engineering capacity. Instead of investing more internal resources into maintaining an ever growing list of direct connections, Anakatech chose to rely on an external platform that offers flexible configuration and centralized control over traffic and risk. Payneteasy’s orchestration layer became that control point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Freeing developers to focus on product&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;By offloading the integration and maintenance layer to Payneteasy, Anakatech’s development team could shift focus to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Website performance&lt;/li&gt;
&lt;li&gt;CRM improvements&lt;/li&gt;
&lt;li&gt;Product and UX optimisation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This change reduced urgent PSP integration work and helped support long-term growth across Anakatech’s brands.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“We run our own cashier and hold PCI DSS Level 1, so the merchant side was never our bottleneck — integration throughput was. With Payneteasy orchestration, a ready integration is a switch we flip when the business needs it, not a multi-week project. Our team got its capacity back for the work only we can do.” — Jeff Letlat, COO at Anakatech Group. &lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Keeping up with a weekly-changing payment stack&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For platforms facing the “two-front war” of maintaining what’s in production while building what’s next, Anakatech’s experience with Payneteasy shows what happens when integration stops being a bottleneck and becomes a backbone.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmpa9gqg0z8cusezxohr2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmpa9gqg0z8cusezxohr2.png" alt=" " width="800" height="290"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Related Products&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://payneteasy.com/solutions/orchestration-payment-platform" rel="noopener noreferrer"&gt;Payment Orchestration&lt;/a&gt; - Route traffic intelligently across PSPs — by BIN, geography, card type, and business rules. The same layer powering Anakatech's multi-brand cashier. &lt;/li&gt;
&lt;li&gt;
&lt;a href="https://payneteasy.com/solutions/gateway" rel="noopener noreferrer"&gt;White Label  Payment Gateway&lt;/a&gt; – Fully brandable gateway: your brand on checkout, dashboards and statements, live in 2-4 weeks &lt;/li&gt;
&lt;li&gt;
&lt;a href="https://payneteasy.com/payment_technologies/routing_and_balancing_system" rel="noopener noreferrer"&gt;Smart Payment Strategy&lt;/a&gt; – Routing and balancing rules that decide where each transaction goes – send in traffic, get back profit. &lt;/li&gt;
&lt;li&gt;
&lt;a href="https://payneteasy.com/payment_technologies/ai-payment-operations" rel="noopener noreferrer"&gt;MCP Agent Access&lt;/a&gt; — AI Agent Access to Payment Data – agents observe payments and routing, never move money.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In our new case interview with Jeff Letla, Chief Operating Officer at Anakatech, we look at how a five-year partnership with Payneteasy turned that bottleneck into a backbone: faster go-lives, higher approval rates, and dev teams finally focusing on product instead of PSP firefighting.&lt;/p&gt;

&lt;p&gt;🔗 Read the full story about Anakatech’s payment orchestration journey with Payneteasy - &lt;a href="https://payneteasy.com/cases/anakatech" rel="noopener noreferrer"&gt;https://payneteasy.com/cases/anakatech&lt;/a&gt; &lt;/p&gt;

</description>
      <category>stripe</category>
      <category>fintech</category>
      <category>interview</category>
      <category>devops</category>
    </item>
    <item>
      <title>Running payments at scale is never plug-and-play. Anakatech had 5–10 brands, new GEOs, and one integration queue — payments became the bottleneck. COO Jeff Letla on how Payneteasy turned that into a backbone. https://payneteasy.com/cases/anakatech</title>
      <dc:creator>Payneteasy</dc:creator>
      <pubDate>Wed, 15 Jul 2026 15:09:09 +0000</pubDate>
      <link>https://dev.to/payneteasy/running-payments-at-scale-is-never-plug-and-play-anakatech-had-5-10-brands-new-geos-and-one-310f</link>
      <guid>https://dev.to/payneteasy/running-payments-at-scale-is-never-plug-and-play-anakatech-had-5-10-brands-new-geos-and-one-310f</guid>
      <description>&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://payneteasy.com/cases/anakatech" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpayneteasy.com%2Fstatic%2Fimg%2Fmain_screen.jpg" height="397" class="m-0" width="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://payneteasy.com/cases/anakatech" rel="noopener noreferrer" class="c-link"&gt;
            Anakatech Payment Orchestration Case Study | Payneteasy
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Learn how Anakatech scaled gaming payments across multiple brands and GEOs using Payneteasy orchestration, flexible routing, and one stable integration.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpayneteasy.com%2Fstatic%2Fimg%2Flogo-ico.svg" width="120" height="120"&gt;
          payneteasy.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


</description>
    </item>
    <item>
      <title>Payneteasy Brings the MCP to the Payment Platform — Let AI Agents Safely Read Your Operational Data

Read the full article
To explore all the details, examples and technical specifics https://payneteasy.com/payment_technologies/ai-payment-operations</title>
      <dc:creator>Payneteasy</dc:creator>
      <pubDate>Sun, 12 Jul 2026 22:12:09 +0000</pubDate>
      <link>https://dev.to/payneteasy/payneteasy-brings-the-mcp-to-the-payment-platform-let-ai-agents-safely-read-your-operational-5a85</link>
      <guid>https://dev.to/payneteasy/payneteasy-brings-the-mcp-to-the-payment-platform-let-ai-agents-safely-read-your-operational-5a85</guid>
      <description>&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://payneteasy.com/payment_technologies/ai-payment-operations" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpayneteasy.com%2Fstatic%2Fimg%2Fmain_screen.jpg" height="397" class="m-0" width="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://payneteasy.com/payment_technologies/ai-payment-operations" rel="noopener noreferrer" class="c-link"&gt;
            MCP for Payment Platform | Payneteasy AI Agent Access
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Give AI agents read-only access to Payneteasy payment operations data. Query transaction analytics, order trails and platform records safely.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpayneteasy.com%2Fstatic%2Fimg%2Flogo-ico.svg" width="120" height="120"&gt;
          payneteasy.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


</description>
    </item>
    <item>
      <title>Payneteasy Brings the Model Context Protocol (MCP) to the Payment Platform — Let AI Agents Safely Read Your Operational Data</title>
      <dc:creator>Payneteasy</dc:creator>
      <pubDate>Tue, 30 Jun 2026 14:58:16 +0000</pubDate>
      <link>https://dev.to/payneteasy/payneteasy-brings-the-model-context-protocol-mcp-to-the-payment-platform-let-ai-agents-safely-1d0h</link>
      <guid>https://dev.to/payneteasy/payneteasy-brings-the-model-context-protocol-mcp-to-the-payment-platform-let-ai-agents-safely-1d0h</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;How a read-only MCP layer lets AI assistants query your Payneteasy backoffice without ever moving money or touching PCI cardholder data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What MCP is — in plain words
&lt;/h2&gt;

&lt;p&gt;The Model Context Protocol (MCP) is an open standard for connecting AI agents to external systems.&lt;/p&gt;

&lt;p&gt;Instead of every assistant needing a custom plugin for every tool, an MCP server advertises the questions it can answer, and any MCP-capable client — Claude, Cursor, a coding agent — discovers and asks those questions over one common protocol.&lt;/p&gt;

&lt;p&gt;Applied to the Payneteasy platform, the MCP server exposes the read side of your backoffice: the statistics your analysts already pull, and the merchant, project, endpoint, gate and processor records they already look up.&lt;/p&gt;

&lt;p&gt;It is a window for reading, not a lever for moving money.&lt;/p&gt;

&lt;h2&gt;
  
  
  What MCP actually does on Payneteasy today
&lt;/h2&gt;

&lt;p&gt;MCP lets AI agents read your platform’s operational data.&lt;/p&gt;

&lt;p&gt;Over one common protocol, an assistant can answer questions about transaction statistics and the merchant/project/endpoint/gate/processor configuration behind them — read-only, with no card (PCI) data in scope.&lt;/p&gt;

&lt;p&gt;Today, the read layer already covers three main areas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Transaction analytics: counts, amounts and ratios over time, by card type, by country and by decline/fraud reason, plus top-entity rankings.&lt;/li&gt;
&lt;li&gt;Order-level lookup: a single transaction and its step-by-step processing trail.&lt;/li&gt;
&lt;li&gt;Fast navigation of reference data: finding the right merchant, project, endpoint, gate or processor and reading its configuration without clicking through the backoffice.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When AI assistants stop being a demo
&lt;/h2&gt;

&lt;p&gt;Most teams today have the same experience with AI assistants: nice to have in a browser tab, good at drafting an email, but not something you would trust with real operational work.&lt;/p&gt;

&lt;p&gt;The change comes when assistants stop hallucinating and start answering concrete questions about your business — “how did our approval rate move this month?”, “which processor is dragging down performance?”, “what’s the chargeback ratio for this merchant by card type?”.&lt;/p&gt;

&lt;p&gt;On Payneteasy, that jump from “demo” to “operational” now happens over one clean, safe interface: the Model Context Protocol (MCP).&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP in action: you connect once, then ask
&lt;/h2&gt;

&lt;p&gt;The experience is simple on purpose.&lt;/p&gt;

&lt;p&gt;You connect an MCP-capable assistant to the Payneteasy MCP server with a scoped token, and from that point on you talk to it in plain words.&lt;/p&gt;

&lt;p&gt;You might ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Compare approved vs declined transaction volume for merchant ACME this month, broken down by week.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent reads and answers — strictly read-only:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“ACME, June 2026, weekly: approved turnover trending up week over week; declined count flat; filtered (fraud-blocked) share around 3% of attempts. (read-only — nothing was changed or charged.)”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No exports, no pivot tables, no manual dashboard tours — just one question and one operational answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zooming out: what an AI agent can read
&lt;/h2&gt;

&lt;p&gt;Underneath that conversation, MCP exposes three kinds of information.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Transaction statistics
&lt;/h3&gt;

&lt;p&gt;The agent can answer questions about sales, reversals, chargebacks, frauds and disputes for a date range — as counts, amounts and decline/chargeback/fraud ratios, broken down by card type with a grand total.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Individual orders — one payment end to end
&lt;/h3&gt;

&lt;p&gt;Sometimes you don’t want a chart; you want to know what happened to a specific transaction.&lt;/p&gt;

&lt;p&gt;MCP can look one up and replay its processing trail using three tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Order search&lt;/li&gt;
&lt;li&gt;Order details&lt;/li&gt;
&lt;li&gt;Order logs&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Platform reference data
&lt;/h3&gt;

&lt;p&gt;MCP exposes the platform’s reference data via a simple pattern: search, then read.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Merchants: find a merchant by name; read its profile, payment group and status.&lt;/li&gt;
&lt;li&gt;Projects: find a project; read its manager, currency and rate plan.&lt;/li&gt;
&lt;li&gt;Endpoints: find an endpoint; read its payment strategy, capture/return timeouts, form templates and flags.&lt;/li&gt;
&lt;li&gt;Gates: find a gate; read its processor, manager, currency and rate plan.&lt;/li&gt;
&lt;li&gt;Processors: find a processor; read its type/code, status and owning group.&lt;/li&gt;
&lt;li&gt;Managers / superiors: find platform users and read their profile and status.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent resolves a name to an id and then reads the record.&lt;/p&gt;

&lt;p&gt;None of it requires write access, and none of it touches card data.&lt;/p&gt;

&lt;h2&gt;
  
  
  How MCP works in practice: three steps
&lt;/h2&gt;

&lt;p&gt;Behind the scenes, the pattern stays deliberately simple.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connect the agent
&lt;/h3&gt;

&lt;p&gt;An MCP-capable assistant connects to the Payneteasy MCP server with a scoped token.&lt;/p&gt;

&lt;h3&gt;
  
  
  It discovers the questions
&lt;/h3&gt;

&lt;p&gt;The server advertises its read-only tools — transaction statistics, order lookup, and merchant / project / endpoint / gate / processor lookups.&lt;/p&gt;

&lt;h3&gt;
  
  
  Plain-language answers
&lt;/h3&gt;

&lt;p&gt;The agent asks in plain words and gets clear, read-only answers about your payments.&lt;/p&gt;

&lt;p&gt;The shift is from people clicking through the backoffice to assistants querying it directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why adopt MCP now, not “later”
&lt;/h2&gt;

&lt;p&gt;Agentic workflows are moving from concept to daily operations.&lt;/p&gt;

&lt;p&gt;Teams are beginning to expect that they can ask an assistant about platform behaviour instead of clicking through dashboards.&lt;/p&gt;

&lt;p&gt;A clean, safe MCP lets your team query the platform through an assistant instead of clicking dashboards — establishing the agent-native workflow before it becomes a baseline expectation.&lt;/p&gt;

&lt;p&gt;Platforms that wire it in now own that agent-native workflow before it becomes table stakes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the full article
&lt;/h2&gt;

&lt;p&gt;To explore all the details, examples and technical specifics of MCP on the Payneteasy platform, read the full launch article on our website:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://payneteasy.com/payment_technologies/ai-payment-operations" rel="noopener noreferrer"&gt;Model Context Protocol for Payments&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  See MCP in action at iGB L!VE London
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.linkedin.com/posts/payneteasy_want-to-see-how-ai-can-support-payment-operations-activity-7477603334233829377-6byU?utm_source=share&amp;amp;utm_medium=member_desktop&amp;amp;rcm=ACoAAFJ-On0BzBgMX7EGb7Zzb4zLVRJ-OWE-MGU" rel="noopener noreferrer"&gt;Join Payneteasy at &lt;strong&gt;iGB L!VE London on July 1–2&lt;/strong&gt; to see MCP in action&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>stripe</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Payneteasy OpenAPI: Launch Payment Integrations in Minutes</title>
      <dc:creator>Payneteasy</dc:creator>
      <pubDate>Fri, 26 Jun 2026 21:29:35 +0000</pubDate>
      <link>https://dev.to/payneteasy/payneteasy-openapi-launch-payment-integrations-in-minutes-3ehk</link>
      <guid>https://dev.to/payneteasy/payneteasy-openapi-launch-payment-integrations-in-minutes-3ehk</guid>
      <description>&lt;p&gt;Generate payment API clients in minutes with Payneteasy OpenAPI. Reduce integration time, automate workflows, and scale faster with AI-ready tools.&lt;/p&gt;

&lt;p&gt;Integrating a payment gateway shouldn’t slow your product down. Yet for most fintech teams, banks, and PSPs, it still does. Even with solid documentation, developers spend days — sometimes weeks — figuring out field formats, handling edge cases, and aligning request/response logic before the first successful transaction goes through.&lt;/p&gt;

&lt;p&gt;Payneteasy changes that.&lt;/p&gt;

&lt;p&gt;With its OpenAPI specification — an industry-standard way to describe an API in a single, structured file — every endpoint, every input, every data type. It’s a blueprint that software can read and act on directly, not just a manual a person has to interpret.&lt;/p&gt;

&lt;p&gt;Payneteasy transforms payment integration into a fast, automated, and developer-friendly process — where code can be generated in minutes, not written from scratch.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;it turns “read this and figure it out” into “plug this in and go.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Problem: Integration Bottlenecks Are Still Real
&lt;/h2&gt;

&lt;p&gt;If you work in payments, you know the pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Documentation exists, but requires manual interpretation&lt;/li&gt;
&lt;li&gt;Developers rebuild request/response models from scratch&lt;/li&gt;
&lt;li&gt;Errors come from mismatched formats, enums, or validation rules&lt;/li&gt;
&lt;li&gt;SDKs and Postman collections are often assembled manually
This creates a hidden cost: slower onboarding, delayed launches, and engineering time spent on repetitive work instead of product innovation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Shift: From Documentation to Machine-Readable APIs
&lt;/h2&gt;

&lt;p&gt;Payneteasy introduces a more modern approach.&lt;br&gt;
Instead of relying only on human-readable documentation, the platform now provides a complete OpenAPI 3.1 specification — a single, structured contract that describes the entire API in a way both humans and machines can understand.&lt;br&gt;
This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every endpoint, parameter, and data model is standardized&lt;/li&gt;
&lt;li&gt;Tools can read and process the API automatically&lt;/li&gt;
&lt;li&gt;Code generation replaces manual implementation
In practical terms, the API becomes not just something you read — but something your tools can execute against instantly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How It Works in Practice
&lt;/h2&gt;

&lt;p&gt;The integration flow becomes radically simpler:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Provide the OpenAPI spec URL to your tool of choice&lt;/li&gt;
&lt;li&gt;The tool parses endpoints, fields, and logic automatically&lt;/li&gt;
&lt;li&gt;A working client, models, and examples are generated instantly
No manual mapping. No guesswork. No redundant coding.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Start Integrating Faster
&lt;/h2&gt;

&lt;p&gt;Payneteasy OpenAPI turns your payment integration into a standardized, automated process — powered by modern API design and AI-ready infrastructure.&lt;/p&gt;

&lt;p&gt;Read the full overview and start building with Payneteasy OpenAPI today:&lt;a href="https://payneteasy.com/blog/payneteasy-open-api" rel="noopener noreferrer"&gt;https://payneteasy.com/blog/payneteasy-open-api&lt;/a&gt;&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>apigateway</category>
      <category>devrel</category>
      <category>ai</category>
    </item>
    <item>
      <title>From OpenSSL to One Click: Meet the Payneteasy Key Pair Factory</title>
      <dc:creator>Payneteasy</dc:creator>
      <pubDate>Thu, 11 Jun 2026 12:56:51 +0000</pubDate>
      <link>https://dev.to/payneteasy/from-openssl-to-one-click-meet-the-payneteasy-key-pair-factory-1324</link>
      <guid>https://dev.to/payneteasy/from-openssl-to-one-click-meet-the-payneteasy-key-pair-factory-1324</guid>
      <description>&lt;p&gt;Connecting to a payment gateway rarely fails because of business logic. More often, it fails at the very first technical step: authentication.&lt;/p&gt;

&lt;p&gt;If you’ve ever worked with payment APIs, you know the drill. Before sending a single request, you need to generate a cryptographic key pair and sign every request correctly. Sounds straightforward—until you actually try to do it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The hidden hurdle in every integration&lt;/strong&gt;&lt;br&gt;
To securely call the Payneteasy API, each request must be signed. That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generating an RSA key pair (usually via OpenSSL)&lt;/li&gt;
&lt;li&gt;Converting between PKCS#1 and PKCS#8 formats&lt;/li&gt;
&lt;li&gt;Building a correct signature base string&lt;/li&gt;
&lt;li&gt;Percent-encoding everything properly&lt;/li&gt;
&lt;li&gt;Signing with RSA-SHA256 or HMAC-SHA1&lt;/li&gt;
&lt;li&gt;Assembling the Authorization header
One small mistake—a missing character, wrong encoding, or incorrect format—and your request gets rejected.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For teams without deep cryptography expertise, this step alone can turn a one-day integration into a week-long debugging session.&lt;/p&gt;

&lt;p&gt;If you’re curious, the full manual process is documented here:&lt;br&gt;
&lt;a href="https://doc.payneteasy.com/integration/general_api_usage/request_authentication_methods/oauth.html#generating-key-pair" rel="noopener noreferrer"&gt;https://doc.payneteasy.com/integration/general_api_usage/request_authentication_methods/oauth.html#generating-key-pair&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The solution: Key Pair Factory&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We built the Payneteasy Key Pair Factory to remove this bottleneck entirely.&lt;/p&gt;

&lt;p&gt;Instead of dealing with OpenSSL commands and key formats, you can generate everything you need in just a few clicks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generates a ready-to-use RSA key pair&lt;/li&gt;
&lt;li&gt;Ensures correct formatting for Payneteasy APIs&lt;/li&gt;
&lt;li&gt;Eliminates manual conversion and configuration errors&lt;/li&gt;
&lt;li&gt;Keeps the private key on your side&lt;/li&gt;
&lt;li&gt;Provides a public key for request verification&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No cryptography expertise required.&lt;/p&gt;

&lt;p&gt;The tool is open-source and available on GitHub:&lt;br&gt;
&lt;a href="https://github.com/payneteasy/key-pair-factory" rel="noopener noreferrer"&gt;https://github.com/payneteasy/key-pair-factory&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is not just about convenience—it directly impacts integration speed and success.&lt;/p&gt;

&lt;p&gt;With the Key Pair Factory, you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Faster onboarding&lt;/li&gt;
&lt;li&gt;Fewer integration errors&lt;/li&gt;
&lt;li&gt;Less back-and-forth with support teams&lt;/li&gt;
&lt;li&gt;A smoother developer experience
In short, you move from setup friction to actual product work much faster.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Flexibility without the friction&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Payneteasy has always focused on flexibility: multiple authentication methods, deep configuration, and powerful payment infrastructure.&lt;/p&gt;

&lt;p&gt;But flexibility often comes with complexity.&lt;/p&gt;

&lt;p&gt;With tools like the Key Pair Factory, we’re changing that. The goal is simple: keep the power, remove the friction.&lt;/p&gt;

&lt;p&gt;This is one example of how a traditionally complex, expert-only task can become a simple, self-service action—without requiring a large engineering effort.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Get started&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're integrating the Payout API or working with payouts via the virtual terminal, this is the fastest way to get authenticated.&lt;/p&gt;

&lt;p&gt;Learn more or request access&lt;a href="https://dev.tourl"&gt;(https://payneteasy.com/blog/payneteasy-key-pair-factory)&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Payneteasy is a global payment technology platform that connects payment businesses to acquirers, alternative payment methods, and risk tools through a single integration. Since 2006, we’ve been helping PSPs, banks, fintechs, and large merchants build scalable payment infrastructure under their own brand.&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>api</category>
      <category>security</category>
      <category>oauth</category>
    </item>
    <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>
