Skip to main content

API Design Patterns for Fintech Platforms

00:03:27:90

APIs Are Your Platform's Interface

In fintech, APIs aren't just internal plumbing. They're how IB partners integrate their systems, how PSPs communicate payment status, and how mobile apps deliver the trading experience. A well-designed API reduces support tickets, accelerates partner onboarding, and prevents financial errors.

Versioning Strategy

Financial APIs can't break backward compatibility. Partners have built integrations they don't want to rewrite:

/api/v1/deposits          ← Original
/api/v2/deposits          ← Added new fields, deprecated old ones
/api/v1/deposits (sunset) ← 6 months notice before removal

Use URL-based versioning for clarity. Header-based versioning is technically cleaner but harder for partners to debug and test:

php
// Route registration
Route::prefix('api/v1')->group(function () {
    Route::post('/deposits', [V1\DepositController::class, 'store']);
});

Route::prefix('api/v2')->group(function () {
    Route::post('/deposits', [V2\DepositController::class, 'store']);
});

Support at least the current version and one previous version. Deprecation notices go in response headers:

php
return response()->json($data)
    ->header('Deprecation', 'true')
    ->header('Sunset', 'Sat, 01 Jun 2026 00:00:00 GMT')
    ->header('Link', '<https://api.platform.com/v2/deposits>; rel="successor-version"');

Idempotency for Financial Operations

The most critical API design decision in fintech: every financial operation must be idempotent. Network failures, timeouts, and retries will happen. A deposit processed twice means free money for the client.

php
class DepositController
{
    public function store(DepositRequest $request): JsonResponse
    {
        $idempotencyKey = $request->header('Idempotency-Key');

        // Check for existing request with same key
        $existing = IdempotencyRecord::where('key', $idempotencyKey)->first();

        if ($existing) {
            return response()->json(
                json_decode($existing->response_body),
                $existing->response_status
            );
        }

        // Process the deposit
        $result = $this->depositService->process($request->validated());

        // Store the response for future duplicate requests
        IdempotencyRecord::create([
            'key' => $idempotencyKey,
            'response_body' => json_encode($result),
            'response_status' => 201,
            'expires_at' => now()->addHours(24),
        ]);

        return response()->json($result, 201);
    }
}

Partners include an Idempotency-Key header with every request. If they retry the same request (same key), they get the original response back without the operation executing again.

Consistent Error Responses

Every error response follows the same structure:

json
{
    "error": {
        "code": "INSUFFICIENT_BALANCE",
        "message": "Account balance of 450.00 USD is insufficient for withdrawal of 500.00 USD",
        "details": {
            "available_balance": "450.00",
            "requested_amount": "500.00",
            "currency": "USD"
        },
        "request_id": "req_abc123def456"
    }
}

Error codes are machine-readable constants. Messages are human-readable explanations. Details provide context for debugging. Request IDs enable support to trace issues.

php
// Standardized error codes
enum ApiError: string
{
    case InsufficientBalance = 'INSUFFICIENT_BALANCE';
    case AccountNotVerified = 'ACCOUNT_NOT_VERIFIED';
    case InvalidCurrency = 'INVALID_CURRENCY';
    case DuplicateTransaction = 'DUPLICATE_TRANSACTION';
    case RateLimitExceeded = 'RATE_LIMIT_EXCEEDED';
    case MaintenanceMode = 'MAINTENANCE_MODE';
    case InvalidAmount = 'INVALID_AMOUNT';
    case WithdrawalLocked = 'WITHDRAWAL_LOCKED';
}

Rate Limiting

Different endpoints have different limits based on risk and resource cost:

php
// Read operations: generous
Route::middleware('throttle:300,1')->group(function () {
    Route::get('/accounts', [AccountController::class, 'index']);
    Route::get('/trades', [TradeController::class, 'index']);
});

// Write operations: moderate
Route::middleware('throttle:60,1')->group(function () {
    Route::post('/orders', [OrderController::class, 'store']);
});

// Financial operations: strict
Route::middleware('throttle:10,1')->group(function () {
    Route::post('/deposits', [DepositController::class, 'store']);
    Route::post('/withdrawals', [WithdrawalController::class, 'store']);
});

Always include rate limit headers in responses:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1709827200

Pagination and Filtering

Trade history can be millions of records. Cursor-based pagination outperforms offset pagination at scale:

php
// Cursor-based pagination
Route::get('/trades', function (Request $request) {
    $query = Trade::where('client_id', $request->user()->id)
        ->orderByDesc('id');

    if ($cursor = $request->query('cursor')) {
        $query->where('id', '<', $cursor);
    }

    $trades = $query->limit(50)->get();

    return response()->json([
        'data' => $trades,
        'meta' => [
            'next_cursor' => $trades->last()?->id,
            'has_more' => $trades->count() === 50,
        ],
    ]);
});

Support filtering by date range, symbol, and status — the three most common queries partners make:

GET /api/v2/trades?from=2026-01-01&to=2026-01-31&symbol=EURUSD&status=closed

Webhook Design

When your platform sends webhooks to partners:

json
{
    "event": "trade.closed",
    "timestamp": "2026-03-13T10:30:00Z",
    "data": {
        "trade_id": 12345,
        "symbol": "EURUSD",
        "volume": 1.0,
        "profit": 245.50
    },
    "signature": "sha256=abc123..."
}
  • Sign every webhook with HMAC-SHA256
  • Include timestamps to prevent replay attacks
  • Retry with exponential backoff (5s, 30s, 5m, 30m, 2h)
  • Log all delivery attempts for debugging

Key Takeaways

  1. URL-based versioning with 6-month deprecation windows
  2. Idempotency keys on every financial operation — non-negotiable
  3. Consistent error structure with machine-readable codes and request IDs
  4. Tiered rate limiting — stricter limits on financial endpoints
  5. Cursor-based pagination for large datasets like trade history
  6. Signed webhooks with retry logic and delivery logging

Your API is a product. Treat it like one — document it well, version it carefully, and design it for the developers who will integrate with it.

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

Contact me