Making cross-provider payments idempotent
Designed a strategy/auto-registry payment subsystem so each provider's quirks stay isolated while checkout, invoicing, and settlement keep using one stable contract — with idempotency guarantees that prevent double-spend across providers.
- Per-provider logic isolated from business flows
- New provider added without touching checkout
- Idempotency enforced at the domain boundary
Context
Operating in the Iranian market means integrating local payment providers, and there are several of them — each with its own redirect flow, callback shape, verification handshake, and failure semantics. The real risk isn’t the integration work itself; it’s what happens without correctness guarantees: a network retry replays a charge, a webhook arrives twice, a verification call is ambiguous — and money moves twice.
Constraints
- Providers differ in every detail that matters. Redirect vs. direct, synchronous vs. webhook verification, distinct error codes and refund rules.
- The set changes over time. Providers are added, swapped, or deprecated for commercial reasons, and that churn must not be a checkout rewrite each time.
- Verification is non-negotiable. A payment that can double-charge — due to a retry, a duplicate webhook, or a race between callback and polling — is a serious correctness failure.
Approach
The hardest constraint to satisfy is idempotency. Every transaction carries an idempotency key; the domain boundary rejects a second initiation or verification request for the same key, regardless of which provider is involved or how many times the callback arrives. This is the guard that makes double-spend structurally impossible, not a matter of “hope the provider doesn’t retry.”
On top of that, I modeled every provider behind a single domain interface, using a strategy / auto-registry pattern so each provider is a self-contained strategy owning its redirect flow, callback handling, and provider-specific behavior. Providers register into the registry; adding one means implementing the contract — the business flows discover it without being modified.
checkout / invoicing / settlement
│
[domain interface]
initiate · verify · settle
│
┌───────┴────────┐
│ idempotency │ ← double-spend guard
│ guard │
└───────┬────────┘
┌───────┴────────┐
[strategy A] [strategy B] …
(provider-specific · self-registering)
Decision — idempotency at the domain boundary, not per provider. Each provider has its own idea of what “already processed” means (or no idea at all). Relying on provider-side deduplication is fragile. Enforcing idempotency once, above the strategy layer, means the guarantee holds regardless of how any individual provider behaves.
Decision — strategy + auto-registry over a central dispatch. A switch on provider type is the obvious first version, and it rots fastest: every new provider edits shared code, and every edit risks the flows that handle real money. Self- registering strategies make provider logic additive and isolated — the blast radius of a new or changed provider is exactly one strategy.
Decision — push provider quirks down, keep the contract narrow. The interface exposes only what business flows genuinely need: initiate, verify, settle. Anything provider-specific lives below that line. A narrow contract keeps the core stable regardless of provider churn.
Outcome
Checkout, invoicing, and settlement are decoupled from any individual provider’s behavior. A provider can be added, changed, or removed independently, and the idempotency guard means that duplicate callbacks and retries resolve safely rather than silently charging twice. It’s the same isolation principle as the messaging service’s per-provider lanes — contain each third party’s quirks so the core stays correct.
What I’d revisit
The strategies are validated against each provider’s documented behavior. Given how often local providers drift from their own docs, I’d build a contract-test harness that exercises each strategy against the provider’s sandbox on a schedule, so a provider-side change surfaces as a failing test instead of a failed payment.