Skip to main content

PSP Integration Best Practices for Fintech Platforms

00:04:17:99

Why PSP Integration Is Hard

On paper, integrating a payment gateway is simple: send a request, get a response, update the balance. In practice, PSP integration is one of the most error-prone areas of fintech development. Webhooks arrive late or not at all. APIs return inconsistent status codes. Settlement timelines vary from instant to T+3. And every PSP has its own idea of what an API should look like.

After integrating 15+ PSPs across multiple broker platforms, here are the patterns that keep things reliable.

The Adapter Pattern

Every PSP gets its own adapter behind a unified interface. This is non-negotiable:

php
interface PaymentGateway
{
    public function createDeposit(DepositRequest $request): PaymentResponse;
    public function createWithdrawal(WithdrawRequest $request): PaymentResponse;
    public function handleWebhook(Request $request): WebhookResult;
    public function checkStatus(string $referenceId): TransactionStatus;
}

Each adapter translates between your domain model and the PSP's API:

php
class PayRetailersAdapter implements PaymentGateway
{
    public function createDeposit(DepositRequest $request): PaymentResponse
    {
        $response = $this->client->post('/v1/payments', [
            'amount' => $request->amount,
            'currency' => $request->currency,
            'customer_id' => $request->clientId,
            'callback_url' => route('webhooks.payretailers'),
            'redirect_url' => $request->returnUrl,
        ]);

        return new PaymentResponse(
            referenceId: $response['payment_id'],
            redirectUrl: $response['checkout_url'],
            status: $this->mapStatus($response['status']),
        );
    }
}

Adding a new PSP means creating a new adapter class. Zero changes to existing payment logic, deposit controllers, or withdrawal workflows.

Webhook Reliability

Webhooks are the most fragile part of any PSP integration. They fail for dozens of reasons: your server was deploying, the PSP had a timeout, a firewall rule changed, the payload format changed silently.

Rule 1: Always acknowledge immediately, process later.

php
public function handleWebhook(Request $request): Response
{
    // Validate signature first
    if (!$this->verifySignature($request)) {
        return response('Invalid signature', 401);
    }

    // Store raw payload, return 200 immediately
    WebhookPayload::create([
        'provider' => 'stripe',
        'payload' => $request->getContent(),
        'status' => 'pending',
    ]);

    // Process asynchronously
    ProcessWebhook::dispatch($request->getContent());

    return response('OK', 200);
}

Rule 2: Implement idempotency. PSPs often retry webhooks. Your handler must produce the same result whether it runs once or five times:

php
public function processPayment(string $referenceId, float $amount): void
{
    // Check if already processed
    $existing = Transaction::where('reference_id', $referenceId)->first();
    if ($existing && $existing->status === 'completed') {
        return; // Already processed, skip
    }

    DB::transaction(function () use ($referenceId, $amount) {
        $transaction = Transaction::lockForUpdate()
            ->where('reference_id', $referenceId)
            ->first();

        $transaction->update(['status' => 'completed']);
        $transaction->client->creditBalance($amount);
    });
}

Rule 3: Poll as backup. Don't rely solely on webhooks. Run a scheduled job that checks pending transactions older than 5 minutes:

php
$schedule->command('payments:check-pending')
    ->everyFiveMinutes();

Status Mapping

Every PSP uses different status names. Stripe uses succeeded, PayRetailers uses APPROVED, local PSPs might use 1 for success. Normalize everything to your internal statuses:

php
enum TransactionStatus: string
{
    case Pending = 'pending';
    case Processing = 'processing';
    case Completed = 'completed';
    case Failed = 'failed';
    case Refunded = 'refunded';
    case Expired = 'expired';
}

Each adapter maps the PSP's status to your enum. This keeps your business logic clean — it never deals with PSP-specific status codes.

Reconciliation

Daily reconciliation catches discrepancies before they become problems. Compare your transaction records against the PSP's settlement reports:

php
class ReconciliationService
{
    public function reconcile(string $provider, Carbon $date): ReconciliationReport
    {
        $ourRecords = Transaction::where('provider', $provider)
            ->whereDate('completed_at', $date)
            ->get();

        $pspRecords = $this->fetchPSPSettlement($provider, $date);

        $mismatches = [];

        foreach ($ourRecords as $record) {
            $pspRecord = $pspRecords->firstWhere('reference_id', $record->reference_id);

            if (!$pspRecord) {
                $mismatches[] = ['type' => 'missing_at_psp', 'record' => $record];
            } elseif (abs($record->amount - $pspRecord->amount) > 0.01) {
                $mismatches[] = ['type' => 'amount_mismatch', 'record' => $record];
            }
        }

        return new ReconciliationReport($mismatches);
    }
}

Run reconciliation daily via scheduled commands and alert the finance team on any discrepancies.

Currency and Amount Handling

Never use floating point for money. Use integer cents or a dedicated money library:

php
// Bad - floating point precision issues
$amount = 10.30; // Could be 10.299999999...

// Good - store as cents
$amountCents = 1030;

// Or use a money library
$amount = Money::of('10.30', 'USD');

Currency conversion adds another layer. If a client deposits in EUR but their trading account is in USD, you need:

  1. The conversion rate at the exact moment of deposit
  2. The rate source (your PSP's rate vs. market rate)
  3. An audit trail of the conversion

Store the original amount, converted amount, rate, and rate source on every transaction.

Security Essentials

  • Verify webhook signatures — every PSP provides a signing mechanism. Use it.
  • Whitelist IPs — restrict webhook endpoints to PSP IP ranges
  • Encrypt sensitive data — card tokens, account numbers never stored in plain text
  • Rate limit — prevent abuse of deposit/withdrawal endpoints
  • Audit log — every financial operation logged with who, what, when

Key Takeaways

  1. Adapter pattern is mandatory — you will integrate more PSPs than you think
  2. Acknowledge webhooks immediately, process asynchronously, implement idempotency
  3. Poll pending transactions as a safety net — don't trust webhooks alone
  4. Reconcile daily — catch discrepancies before they compound
  5. Never use floats for money — integer cents or a money library
  6. Security is not optional — webhook signatures, IP whitelisting, encryption, audit logs

Payment integration is the most operationally critical part of any fintech platform. Getting it right means reliable deposits, accurate balances, and happy clients. Getting it wrong means financial discrepancies and regulatory trouble.

Want to discuss this topic or work together? Get in touch.

Contact me