🔧 Dev

OffreThe Augmented Engineering Programdès 2 500 € / mois

Tech Lead & équipe · ou MVP livré en 3 mois + recrutement

Découvrir

🧩 CH3 - Building a Modern Payment Gateway with a Rebill Scheduler - Chapter 3 - The Payment Lifecycle

Sébastien Techer24/10/2025

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 NextAction to 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

  1. Client sends a POST /payments with an Idempotency-Key header.

  2. Middleware checks if this key already exists in the store.

  3. If it does → return the cached response.

  4. 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 TypePurposeTypical TTL
Network retryPSP timeouts / transient errors3–5 times
Webhook retryPSP event delivery24h
Reconciliation retryAsync mismatch correction72h

🔄 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

  1. Receive PSP webhooks → update internal state.

  2. 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.


Respect de votre vie privée

Nous utilisons des cookies pour améliorer votre expérience, analyser le trafic et personnaliser le contenu. Vous pouvez choisir quels cookies accepter.