🧩 CH3 - Building a Modern Payment Gateway with a Rebill Scheduler - Chapter 3 - The Payment Lifecycle
Why you might want to build your own payment gateway — and how to do it safely with an external PCI-compliant vault.
⚙️ Understanding the Payment Lifecycle
A payment isn’t a single event — it’s a state machine.
From authorization to capture, refund, and cancellation, every step involves asynchronous systems, retries, and potential race conditions.
A robust gateway must:
- Know exactly where a payment stands (status consistency),
- Handle network retries safely (idempotency),
- Reconcile external states (webhooks),
- And maintain a single source of truth across multiple PSPs.
Let’s go through the flow step by step.
🧩 Authorize → Capture → Refund → Cancel
In a typical payment journey, your gateway will manage the following transitions:
created → requires_action → authorized → captured → refunded / canceled
Each state transition can be triggered either:
- by your system (e.g., API call to capture or refund),
- or by the PSP (via webhook, e.g., “payment_intent.succeeded”).
Here’s a simplified lifecycle:
type PaymentStatus string
const (
StatusCreated PaymentStatus = "created"
StatusRequiresAction PaymentStatus = "requires_action"
StatusAuthorized PaymentStatus = "authorized"
StatusCaptured PaymentStatus = "captured"
StatusRefunded PaymentStatus = "refunded"
StatusCanceled PaymentStatus = "canceled"
StatusFailed PaymentStatus = "failed"
)
🔹 Authorization
The authorization reserves funds on the customer’s card.
resp, err := psp.CreateIntent(ctx, ports.PaymentRequest{
Amount: ports.Money{Currency: "EUR", Amount: 499},
Mode: ports.CIT,
Capture: false,
})
-
For 3DS-enabled cards, this may trigger a requires_action status.
-
The gateway will send back a
NextActionto the frontend (redirect or challenge). -
Once the customer completes the challenge, you call
ContinueIntent().
🔹 Capture
Once authorized, you capture the funds.
resp, err := psp.Capture(ctx, intentID, &ports.Money{Currency: "EUR", Amount: 499})
You can capture full or partial amounts. Some providers require capture within a time window (e.g., 7 days for Visa).
Your gateway should:
-
Record the capture action in an audit trail,
-
Prevent duplicate captures via idempotency keys,
-
Transition the status from authorized → captured.
🔹 Refunds and Cancellations
resp, err := psp.Refund(ctx, intentID, &ports.Money{Currency: "EUR", Amount: 499})
For refunds, the gateway:
-
Creates a refund record,
-
Updates state once the PSP confirms via webhook,
-
Logs the event for reconciliation.
Cancellation simply releases reserved funds before capture.
🧠 Building an Idempotency Engine
When dealing with payments, retries are inevitable — network timeouts, client retries, webhook replays, etc. Without idempotency, you risk double-charging customers or duplicating records.
An idempotency engine ensures that the same operation executed twice produces the same result.
Example Flow
Example Flow
-
Client sends a POST /payments with an Idempotency-Key header.
-
Middleware checks if this key already exists in the store.
-
If it does → return the cached response.
-
If not → execute the operation and store the result.
func (s *RedisIdemStore) Reserve(ctx context.Context, tenantID, key string, ttlSeconds int) (ok bool, err error) {
// SETNX pattern with expiration
return redisClient.SetNX(ctx, fmt.Sprintf("idem:%s:%s", tenantID, key), "1", time.Duration(ttlSeconds)*time.Second).Result()
}
Retry Strategy
Use exponential backoff and dead-letter queues for background retries:
| Retry Type | Purpose | Typical TTL |
|---|---|---|
| Network retry | PSP timeouts / transient errors | 3–5 times |
| Webhook retry | PSP event delivery | 24h |
| Reconciliation retry | Async mismatch correction | 72h |
🔄 Reconciliation & State of Truth
Even with perfect logic, asynchronous systems can drift. A PSP might mark a payment as captured while your gateway still thinks it’s pending.
Reconciliation ensures both systems agree on the truth.
Strategy
-
Receive PSP webhooks → update internal state.
-
Schedule daily reconciliation jobs:
-
Fetch PSP transactions (via API),
-
Compare with local database,
-
Resolve mismatches automatically or flag them for review.
Example: Reconciliation Job
func (r *ReconcileService) Sync(ctx context.Context, since time.Time) {
for _, psp := range r.PSPs {
txs := psp.FetchTransactions(ctx, since)
for _, tx := range txs {
local, _ := r.Store.GetPaymentByID(ctx, tx.IntentID)
if local.Status != tx.Status {
_ = r.Store.UpsertPayment(ctx, tx)
_ = r.Store.AppendAudit(ctx, tx.IntentID, "reconcile", fmt.Sprintf("corrected from %s to %s", local.Status, tx.Status))
}
}
}
}
🔐 Webhooks: Verification & Replay Protection
Webhooks are the lifeblood of your payment gateway — but also its biggest attack vector.
Best Practices
1 - Verify Signatures Each PSP (Stripe, Adyen, etc.) provides a signing secret. Always validate the payload before processing.
event, err := webhook.ConstructEvent(payload, sigHeader, secret)
if err != nil {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
2 - Prevent Replays Store recent event IDs in Redis for a short TTL.
if ok := replayStore.SeenBefore(event.ID); ok {
return // drop duplicate webhook
}
3 - Normalize Events
Map PSP-specific events to your WebhookEvent model (from Chapter 2).
4 - Use Background Workers Never perform heavy logic (like DB updates or notifications) inline. Enqueue a lightweight task instead.
🧩 The Source of Truth
In a distributed payment system, truth is fragmented:
-
PSPs emit their own status,
-
Webhooks arrive asynchronously,
-
Your database stores historical state.
Your gateway must define the single source of truth:
“A payment is considered successful only when both the PSP event and the local reconciliation confirm it.”
This is what separates a payment integration from a payment infrastructure.
🚀 Coming Next
In Chapter 4 – The Rebill Scheduler, we’ll shift from one-time payments to recurring transactions.
You’ll learn how to:
-
Design a rebill scheduler in Go (cron + queues),
-
Handle Merchant-Initiated Transactions (MIT) correctly,
-
Manage grace periods, retries, and notifications.
Your gateway now processes payments safely, consistently, and audibly. Next, we’ll make it recurring — and unstoppable.