Skip to content
Chinonso.Ani
All articles

Oct 01, 2025 ·

Payments Layer Evolution: Wrapper to Adapter

Payment architecture usually improves when gateway behavior stops leaking through the application. The useful progression is not one gateway, then many gateways; it is wrapper, service boundary, adapter contract, and explicit webhook verification.

Payments Layer Evolution: Wrapper to Adapter

A growing product rarely starts with a perfect payment architecture. It starts with the first gateway that lets customers pay, a callback that marks an order complete, and enough server-side verification to keep the business moving. That first version can be correct for its moment and still become the wrong boundary once the product has more tenants, more payment flows, and more operational risk.

This use case covers that evolution in a Django payment stack. The first stage was a direct Paystack wrapper. The next stage moved more payment behavior into service classes. The later design introduced a gateway adapter contract, tenant-aware credential resolution, and explicit webhook verification. The important lesson is not that every product needs many gateways. It is that payment code improves when provider behavior, credentials, and trust checks stop leaking into product logic.

Project context

The payment layer supported ordinary commerce workflows: initialize a transaction, redirect or hand off to the gateway, verify the result, update local payment state, and react to provider webhooks. Paystack was the initial concrete provider, while Stripe-aware behavior appeared later in the webhook and contract design.

That context shaped the architecture. A single gateway integration can live close to a Django view for a while. A multi-tenant product cannot. Once organizations need their own credentials, webhook secrets, subaccount routing, and failure diagnostics, a process-wide secret in settings becomes a liability. Once a second gateway is even plausible, provider-specific response shapes also need a place to live.

The constraint was practical: improve the boundary without pretending that every gateway was already implemented. Historical gateway choices existed, including a Flutterwave option, but the verified newer boundary supported Paystack adapter behavior, Stripe-aware webhook handling, and a contract that could accept additional adapters later. It should not be described as current Flutterwave support.

The problem with a convenient wrapper

The earliest version wrapped Paystack directly. It read a secret from Django settings, verified transactions from a callback path, and emitted a signal when verification succeeded. For a narrow system, that was a reasonable move. Paystack's public API expects secret keys to stay on the server, uses bearer authentication for server requests, and supports transaction initialization plus verification through server-side API calls.

The problem was not that the wrapper existed. The problem was that a wrapper can quietly become the product architecture. If the gateway secret is global, routing is global in practice. If verification sits in the callback path, transport handling and business decisions become tangled. If event hooks are the main integration point, required side effects can be hard to distinguish from incidental ones.

The first stage gave the team speed, but it also made several future changes harder: organization-level credentials, provider-specific webhook signing, refund behavior, and clean tests for gateway differences. It also exposed a broader documentation rule: examples can describe credential resolution without repeating real keys, private URLs, or environment-specific values.

The engineering approach

The useful shift was to move from gateway convenience to a small adapter contract. The contract described the operations the product actually needed: initialize a payment, verify a payment, initiate a refund, and verify a webhook signature. The outputs were normalized around product concerns such as payment status, amount, currency, fee, paid time, metadata, redirect URL, provider token, and refund details.

That contract changed ownership. Product code could ask for a payment to be initialized or verified. It no longer had to know whether Paystack returned an authorization URL, whether Stripe centered the flow on a PaymentIntent, or which provider-specific header carried the webhook signature.

Paystack behavior stayed behind the Paystack adapter. Amounts were converted into the smallest currency unit. Transaction initialization and verification mapped to Paystack's documented endpoints. Refund initiation mapped to the provider refund API. Webhook verification used the raw payload, the Paystack signature header, HMAC-SHA512, and a constant-time comparison.

Stripe was treated differently because its webhook contract is different. Stripe documents signed webhooks using its own header and endpoint secret. The architecture did not flatten those differences. It required each adapter to prove that an incoming provider event belonged to the gateway it claimed to represent.

Tenant-aware credentials

The second critical change was credential resolution. In the earlier wrapper, a Django setting was enough because the system behaved like one operational context. In the later design, credentials were resolved with organization awareness.

The resolver chose the gateway, checked for organization-specific credentials, fell back to environment defaults where appropriate, and raised targeted configuration errors when required values were missing. Public keys, secret keys, webhook secrets, and subaccount identifiers were handled as configuration decisions rather than scattered conditionals.

That mattered for more than cleanliness. It improved incident response. A failed webhook verification can come from the wrong gateway, a missing webhook secret, an incomplete organization credential record, or an environment fallback that was never configured. A resolver with explicit failure modes gives operators a real diagnostic path instead of a generic gateway error.

This did not make secrets management complete by itself. Database row protection, administrator permissions, secret rotation, and environment management still matter. The narrower achievement was that gateway credentials were no longer treated as a single process-wide constant.

Webhooks and retry control

Webhooks were treated as part of payment state, not just HTTP callbacks. The handler verified signatures, dispatched gateway-specific processing, and used a deliberate acknowledgement policy for some internal failures.

That policy needs careful wording. Returning 200 after certain internal failures can be valid when the application owns recovery through internal queues, logs, or reconciliation. It is not a universal Paystack or Stripe rule. Without observable internal recovery, acknowledging too early can hide a failed payment update.

The approach was:

  1. Verify the external event before trusting it.
  2. Resolve the correct organization and gateway configuration.
  3. Map the event to a local payment or order.
  4. Decide whether the provider or the application owns retry behavior.
  5. Store enough state to investigate failures without storing unnecessary sensitive data.

That separation keeps provider delivery semantics from becoming the only recovery design. It also gives operators a clearer next step when something fails: check trust verification first, then credential resolution, then local state mapping, then retry ownership.

Tradeoffs and safeguards

The adapter boundary added structure, but it also introduced maintenance duties. Every adapter needs direct tests for request construction, response parsing, failed responses, refund behavior, and signature verification. The webhook handler still needs integration tests for invalid signatures, missing secrets, unknown gateways, duplicate delivery, and processing failures.

The design also avoided overclaiming gateway coverage. A contract that can accept another adapter is not the same as a production-ready adapter. Historical options in a model or enum should be documented as historical or unsupported unless the current implementation proves otherwise.

The main safeguard was explicitness. Gateway behavior stayed at the edge. Product services received normalized results. Credentials were resolved before adapter use. Webhook trust checks were required instead of assumed. Retry policy became an application decision instead of an accident of provider defaults.

What changed

The payment layer became easier to reason about. A team could add or change provider behavior without touring the entire checkout flow. Operators could diagnose missing credentials more precisely. Tests could target the adapter boundary instead of exercising every provider detail through a callback view. Webhook verification became a first-class requirement rather than an informal convention.

The evidence does not support claims of complete gateway coverage, full production hardening, complete idempotency, or current Flutterwave support. It supports a more useful claim: payment architecture became safer to extend when provider APIs, tenant credentials, and webhook trust checks moved out of shared application code and into explicit boundaries.

Where this pattern applies

This pattern fits SaaS products, marketplaces, schools, membership platforms, and commerce systems where one codebase may serve multiple organizations or payment configurations. It is especially useful when the first gateway integration has started to absorb business rules, when webhooks are hard to test, or when credential failures are hard to diagnose.

For similar payment architecture work, start with the boundary money depends on most: credentials, verification, and callbacks. Make each provider show its differences behind a small adapter, then keep the product layer boring enough that adding a gateway does not require rewriting the application.

Sources

Share
Email copied to clipboard