<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Payneteasy</title>
    <description>The latest articles on DEV Community by Payneteasy (@payneteasy).</description>
    <link>https://dev.to/payneteasy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3902834%2Fd453c0d4-fa60-426a-8fad-988deb98e5d8.jpeg</url>
      <title>DEV Community: Payneteasy</title>
      <link>https://dev.to/payneteasy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/payneteasy"/>
    <language>en</language>
    <item>
      <title>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>
