🧩 CH2 - Building a Modern Payment Gateway with a Rebill Scheduler - Chapter 2 – Integration & Abstractions
Why you might want to build your own payment gateway — and how to do it safely with an external PCI-compliant vault.
🎯 The Goal
In Chapter 1, we defined the foundations: a modular gateway, external vault, and strict PCI boundaries.
Now it’s time to make it extensible — one core, many providers.
We’ll design a universal PSP interface in Go,
show how to plug in multiple payment providers (Stripe, Adyen, TrustPayments…),
and keep everything aligned with compliance and clean abstractions.
🧩 Designing the Universal PSP Interface
Each PSP has its own SDK, terminology, and quirks:
- Stripe talks about “Payment Intents” and “Next Actions.”
- Adyen uses “Payments API” with
resultCodeand redirect URLs. - TrustPayments has its own authorization + rebill flows.
To unify them, we define one common contract — the PSP interface.
// --- 3DS & SCA primitives ----------------------------------------------------
type ThreeDSVersion string
const (
ThreeDS1 ThreeDSVersion = "1.0"
ThreeDS2 ThreeDSVersion = "2.x"
)
// DeviceChannel per EMV 3DS
// 01 = App (SDK), 02 = Browser, 03 = 3RI (merchant-initiated)
type DeviceChannel string
const (
DeviceApp DeviceChannel = "01"
DeviceBrowser DeviceChannel = "02"
Device3RI DeviceChannel = "03"
)
type ThreeDSRequest struct {
Version ThreeDSVersion // desired or supported version
Device DeviceChannel // app/browser/3RI
ReturnURL string // where ACS/PSP redirects the customer (browser)
NotificationURL string // where the provider posts results (app/3DS SDK)
// For Browser flows (3DS1/2):
BrowserAcceptHeader string
BrowserUserAgent string
ScreenColorDepth int
ScreenWidth int
ScreenHeight int
Timezone int
JavaEnabled bool
Language string
// For App (SDK) flows (3DS2):
SdkAppID string
SdkTransID string
SdkEncData string
SdkEphemPubKey string
SdkMaxTimeout int
}
type ThreeDSOutcome struct {
Version ThreeDSVersion
ECI string // e.g. 05/06/07
LiabilityShift bool
AuthResult string // Y, A, N, U, R
DSReference string // dsTransID / threeDS2TransID
Cavv string
Xid string // 3DS1
Ares []byte // sanitized provider payloads (PII-free)
Cres []byte
}
type Money struct { // smallest unit (cents)
Currency string
Amount int64
}
type PaymentMethodRef string // token from external vault
type InitiationMode string
const (
CIT InitiationMode = "CIT" // customer-initiated
MIT InitiationMode = "MIT" // merchant-initiated (3RI)
)
// NextAction guides the client on what to do to continue the flow
// (redirect to URL, run 3DS SDK challenge, display QR, etc.)
type NextAction struct {
Type string // redirect|three_ds_challenge|fingerprint|none
URL string // for redirects
Payload map[string]string // key/values required by SDK/form (PII-free)
}
type PaymentIntent struct {
ID string // provider-specific intent ID
Status string // requires_payment_method|requires_action|processing|succeeded|canceled
Amount Money // in smallest unit
PSP string // provider name (Stripe, Adyen, etc.)
PSPRef string // provider-specific reference
PaymentRef PaymentMethodRef // tokenized ref from external vault
Next *NextAction // if requires_action
ThreeDS *ThreeDSOutcome
}
type PaymentRequest struct {
TenantID string
OrderID string
Amount Money
PaymentRef PaymentMethodRef // tokenized ref from external vault
Capture bool // auth+capture or auth-only
Mode InitiationMode // CIT/MIT (MIT may be SCA-exempt)
ThreeDS *ThreeDSRequest // optional; provider decides 3DS1 vs 3DS2 fallback
Metadata map[string]string
}
type PaymentResponse struct {
Intent PaymentIntent,
Raw []byte // minimal raw for audit/debug (PII-free)
}
type PSP interface {
CreateIntent(ctx context.Context, req PaymentRequest) (PaymentResponse, error)
ContinueIntent(ctx context.Context, intentID string, payload map[string]string) (PaymentResponse, error)
Capture(ctx context.Context, intentID string, amount *Money) (PaymentResponse, error)
Refund(ctx context.Context, intentID string, amount *Money) (PaymentResponse, error)
Cancel(ctx context.Context, intentID string) error
VerifyWebhook(ctx context.Context, signature string, payload []byte) (WebhookEvent, error)
}
This interface represents a complete lifecycle, independent of any SDK. Each adapter implements it, mapping between your internal types and the provider’s native objects.
⚙️ Example: Stripe Adapter
Let’s implement CreateIntent for Stripe.
func (s *StripePSP) CreateIntent(ctx context.Context, req ports.PaymentRequest) (ports.PaymentResponse, error) {
params := &stripe.PaymentIntentParams{
Amount: stripe.Int64(req.Amount.Amount),
Currency: stripe.String(req.Amount.Currency),
Confirm: stripe.Bool(req.Capture),
Metadata: req.Metadata,
}
// 3DS flow (CIT vs MIT)
if req.Mode == ports.CIT && req.ThreeDS != nil {
params.PaymentMethodOptions = &stripe.PaymentIntentPaymentMethodOptionsParams{
Card: &stripe.PaymentIntentPaymentMethodOptionsCardParams{
RequestThreeDSecure: stripe.String("automatic"),
},
}
}
pi, err := paymentintent.New(params)
if err != nil {
return ports.PaymentResponse{}, err
}
return ports.PaymentResponse{
Intent: ports.PaymentIntent{
ID: pi.ID,
Status: string(pi.Status),
Amount: ports.Money{Currency: string(pi.Currency), Amount: pi.Amount},
PSP: "stripe",
PSPRef: pi.ID,
PaymentRef: req.PaymentRef,
Next: nextActionFromStripe(pi.NextAction),
ThreeDS: extractThreeDSOutcome(pi),
},
}, nil
}
By returning a PaymentIntent and a potential NextAction, your gateway can react uniformly whether it’s Stripe, Adyen, or TrustPayments.
🧠 Abstraction Patterns
Each PSP adapter lives under internal/psp/<provider> and implements ports.PSP.
You can organize them like this:
internal/
psp/
stripe/
stripe.go
mapper.go # convert internal ↔ external structs
webhook.go
adyen/
adyen.go
mapper.go
webhook.go
trustpayments/
trustpayments.go
mapper.go
webhook.go
The mapper files handle conversion logic (e.g. PaymentRequest → API params). This ensures your domain layer never depends on SDK types.
Example mapping helper:
func nextActionFromStripe(na *stripe.PaymentIntentNextAction) *ports.NextAction {
if na == nil {
return &ports.NextAction{Type: "none"}
}
if na.RedirectToURL != nil {
return &ports.NextAction{Type: "redirect", URL: na.RedirectToURL.URL}
}
return &ports.NextAction{Type: "three_ds_challenge"}
}
🔐 Staying PCI-Compliant with External Vaults
As in Chapter 1, your backend never sees card numbers. The vault (e.g. Checkout.com Vault, TokenEx, or Spreedly) handles all sensitive data.
The gateway only manipulates tokens:
Frontend calls the vault → receives a token.
Token sent to your backend.
Backend uses the vault’s API to resolve that token to a PaymentMethodRef.
That ref is sent to the PSP adapter (e.g. Stripe pm_xxx).
This allows you to integrate multiple PSPs with zero PCI impact.
🪄 Managing Provider Differences
Here’s how to keep it clean when PSPs diverge:
| Concern | Strategy |
|---|---|
| SDKs differ | Use mappers and interface contracts. |
| Auth models differ (3DS1/3DS2) | Normalize through ThreeDSRequest and ThreeDSOutcome. |
| Webhook formats differ | Implement VerifyWebhook() for each provider; return a standard WebhookEvent. |
| Statuses differ | Map everything to common intent statuses: requires_action, processing, succeeded, failed, canceled. |
| Retry semantics differ | Handle at the gateway level with an idempotent store. |
🚀 Coming Next
In Chapter 3, we’ll focus on the payment lifecycle:
-
How to manage authorize → capture → refund → cancel flows.
-
How to build a robust idempotency engine.
-
How to make your gateway the single source of truth for payments.
Your gateway is now modular, extensible, and multi-PSP ready. Next, we make it reliable under real transaction pressure.