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:
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:
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.
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:
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:
$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:
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:
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:
// 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:
- The conversion rate at the exact moment of deposit
- The rate source (your PSP's rate vs. market rate)
- 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
- Adapter pattern is mandatory — you will integrate more PSPs than you think
- Acknowledge webhooks immediately, process asynchronously, implement idempotency
- Poll pending transactions as a safety net — don't trust webhooks alone
- Reconcile daily — catch discrepancies before they compound
- Never use floats for money — integer cents or a money library
- 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.
