home / blog / Payment Gateway Integration: Patterns, P...
Software Engineering Jan 22, 2026

Payment Gateway Integration: Patterns, Pitfalls, and Best Practices

A deep dive into integrating payment gateways the right way — idempotency, webhook handling, error recovery, and the strategy pattern for multi-gateway support.

Payment Gateway Integration: Patterns, Pitfalls, and Best Practices

Payment Integration Is Not Just an API Call

Every developer's first payment integration looks the same: call the API, check the response, save the transaction. Ship it.

Then the real world happens — network timeouts, duplicate charges, webhook delivery failures, currency rounding errors, partial refunds, and that one customer who clicked "Pay" seventeen times.

This guide covers the patterns that separate a fragile payment integration from a production-ready one.

The Strategy Pattern: Supporting Multiple Gateways

Most applications eventually need to support multiple payment providers. The strategy pattern makes this clean:

interface PaymentGateway
{
    public function charge(Money $amount, PaymentMethod $method): PaymentResult;
    public function refund(string $transactionId, ?Money $amount = null): RefundResult;
    public function supportsMethod(string $method): bool;
}

class StripeGateway implements PaymentGateway
{
    public function __construct(private readonly StripeClient $client) {}

    public function charge(Money $amount, PaymentMethod $method): PaymentResult
    {
        try {
            $intent = $this->client->paymentIntents->create([
                'amount' => $amount->inCents(),
                'currency' => strtolower($amount->currency),
                'payment_method' => $method->token,
                'confirm' => true,
                'idempotency_key' => $method->idempotencyKey,
            ]);

            return PaymentResult::success(
                transactionId: $intent->id,
                amount: $amount,
            );
        } catch (CardException $e) {
            return PaymentResult::failed($e->getMessage(), $e->getDeclineCode());
        }
    }

    // ...
}

class PayPalGateway implements PaymentGateway
{
    // Same interface, different implementation
}

// Resolution
class PaymentGatewayFactory
{
    public function make(string $provider): PaymentGateway
    {
        return match($provider) {
            'stripe' => app(StripeGateway::class),
            'paypal' => app(PayPalGateway::class),
            default => throw new UnsupportedGatewayException($provider),
        };
    }
}

Idempotency: Preventing Double Charges

This is the single most important pattern in payment processing. If a user double-clicks, if your server retries after a timeout, if a webhook fires twice — the customer should only be charged once.

class ProcessPayment
{
    public function execute(PaymentRequest $request): PaymentResult
    {
        // Generate a deterministic idempotency key
        $idempotencyKey = $this->generateKey($request);

        // Check if we've already processed this payment
        $existing = Payment::where('idempotency_key', $idempotencyKey)->first();
        if ($existing) {
            return PaymentResult::fromExisting($existing);
        }

        // Create a pending record BEFORE calling the gateway
        $payment = Payment::create([
            'idempotency_key' => $idempotencyKey,
            'amount' => $request->amount,
            'status' => PaymentStatus::Pending,
            'gateway' => $request->gateway,
        ]);

        try {
            $result = $this->gateway->charge($request->amount, $request->method);

            $payment->update([
                'status' => $result->success ? PaymentStatus::Completed : PaymentStatus::Failed,
                'transaction_id' => $result->transactionId,
                'gateway_response' => $result->rawResponse,
            ]);

            return $result;
        } catch (Throwable $e) {
            $payment->update(['status' => PaymentStatus::Error, 'error' => $e->getMessage()]);
            throw $e;
        }
    }

    private function generateKey(PaymentRequest $request): string
    {
        return hash('sha256', implode('|', [
            $request->orderId,
            $request->amount->inCents(),
            $request->amount->currency,
            $request->userId,
        ]));
    }
}

Webhook Handling: The Backbone of Payment State

Never trust client-side payment confirmation alone. Webhooks are the source of truth.

class StripeWebhookController extends Controller
{
    public function handle(Request $request): Response
    {
        // 1. Verify the webhook signature
        try {
            $event = Webhook::constructEvent(
                $request->getContent(),
                $request->header('Stripe-Signature'),
                config('services.stripe.webhook_secret')
            );
        } catch (SignatureVerificationException $e) {
            return response('Invalid signature', 400);
        }

        // 2. Check for duplicate delivery
        if (WebhookLog::where('event_id', $event->id)->exists()) {
            return response('Already processed', 200);
        }

        // 3. Log before processing
        WebhookLog::create([
            'event_id' => $event->id,
            'type' => $event->type,
            'payload' => $event->toArray(),
        ]);

        // 4. Dispatch to handler
        match($event->type) {
            'payment_intent.succeeded' => $this->handlePaymentSuccess($event),
            'payment_intent.payment_failed' => $this->handlePaymentFailure($event),
            'charge.refunded' => $this->handleRefund($event),
            default => null, // Ignore unhandled events
        };

        // 5. Always return 200 to prevent retries
        return response('OK', 200);
    }
}

Error Recovery Patterns

Payments can fail in interesting ways. Here's how to handle the most common scenarios:

  • Gateway timeout: Don't retry blindly. Check the payment status first, then retry only if the original charge didn't go through
  • Partial failure: If the charge succeeded but your database write failed, the webhook will reconcile the state
  • Declined cards: Return user-friendly messages mapped from decline codes
  • Currency mismatch: Validate currency at the input layer, never at the gateway layer

Testing Payment Integrations

class ProcessPaymentTest extends TestCase
{
    public function test_idempotent_payment_does_not_double_charge(): void
    {
        $gateway = Mockery::mock(PaymentGateway::class);
        $gateway->shouldReceive('charge')
            ->once()  // Critical: should only be called ONCE
            ->andReturn(PaymentResult::success('txn_123', new Money(1000)));

        $processor = new ProcessPayment($gateway);
        $request = new PaymentRequest(orderId: 1, amount: new Money(1000));

        $result1 = $processor->execute($request);
        $result2 = $processor->execute($request);

        $this->assertTrue($result1->success);
        $this->assertTrue($result2->success);
        $this->assertEquals(1, Payment::count()); // Only one record
    }
}

Checklist Before Going Live

  • Webhook signature verification is enabled
  • Idempotency keys are generated for all charges
  • All monetary amounts use integer cents, not floats
  • Webhook endpoint returns 200 before processing (use queued jobs)
  • Failed webhook deliveries are monitored and alerting is configured
  • PCI compliance requirements are understood (use hosted payment forms)
  • Refund flows are tested end-to-end
back to all posts