Skip to main content
INS // Insights

Fintech API Integration: Building Payments That Don't

Updated June 2026 · 7 min read

Fintech API integrations don't fail gracefully. A network timeout during a payment charge attempt leaves you uncertain whether the charge succeeded. A missed webhook means your system thinks a transfer failed when it actually completed. A reconciliation gap between your ledger and the payment processor means real money is unaccounted for.

Building fintech integrations correctly requires thinking like a financial system, not like a typical web service integration. This means idempotency for every write, independent reconciliation, and defensive event processing.

Idempotency: The Most Critical Pattern

Idempotency ensures that retrying an operation produces the same result as executing it once. For financial operations, this is non-negotiable — without it, network retries create duplicate charges.

Implementing idempotency keys with Stripe:

import stripe
import uuid
import hashlib

stripe.api_key = STRIPE_SECRET_KEY

def charge_customer(
    customer_id: str,
    amount_cents: int,
    currency: str,
    order_id: str,
) -> stripe.PaymentIntent:
    """
    Charge a customer with idempotency — safe to retry on failure.
    The idempotency key is deterministic from order_id, so retries
    hit the same Stripe operation rather than creating duplicates.
    """
    idempotency_key = hashlib.sha256(
        f"charge:{order_id}".encode()
    ).hexdigest()

    try:
        payment_intent = stripe.PaymentIntent.create(
            amount=amount_cents,
            currency=currency,
            customer=customer_id,
            metadata={"order_id": order_id},
            idempotency_key=idempotency_key,  # Safe to retry with same key
        )
        return payment_intent
    except stripe.error.IdempotencyError:
        # Different params with same key — programming error, not transient
        raise ValueError(f"Idempotency key conflict for order {order_id}")
    except stripe.error.RateLimitError:
        # Retry with exponential backoff
        raise  # Let retry decorator handle this
    except stripe.error.CardError as e:
        # Permanent error — do not retry
        return handle_card_declined(e, order_id)

The idempotency key must be deterministic from your business identifier (order ID, transaction ID) — not a random UUID generated fresh each attempt. A random UUID provides no protection across process restarts or distributed retry scenarios.

Stripe's idempotency window: Stripe stores idempotency keys for 24 hours. A request with the same key within that window returns the same response. After 24 hours, the key can be reused — design your retry windows to stay within the 24-hour window.

Webhook Processing: Reliable and Ordered

Webhooks are how payment processors tell your system about events. Unreliable webhook processing causes your system state to diverge from the processor's state.

Stripe webhook verification:

from fastapi import FastAPI, Request, HTTPException
import stripe
import json

app = FastAPI()

@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
    payload = await request.body()
    sig_header = request.headers.get("stripe-signature")

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, STRIPE_WEBHOOK_SECRET
        )
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid payload")
    except stripe.error.SignatureVerificationError:
        raise HTTPException(status_code=400, detail="Invalid signature")

    # Process the event
    await handle_stripe_event(event)

    return {"received": True}  # Always return 200 to prevent Stripe retries for processed events

Idempotent webhook handling:

Stripe retries webhooks on failure — your handler must be idempotent. The same event ID processed twice must produce the same result, not a duplicated state change:

async def handle_stripe_event(event: stripe.Event) -> None:
    # Check if already processed (idempotency)
    processed = await db.check_event_processed(event.id)
    if processed:
        logger.info("duplicate_webhook_ignored", extra={"event_id": event.id})
        return

    async with db.transaction():
        # Process the event
        if event.type == "payment_intent.succeeded":
            await process_payment_success(event.data.object)
        elif event.type == "payment_intent.payment_failed":
            await process_payment_failure(event.data.object)
        elif event.type == "transfer.paid":
            await process_transfer_completed(event.data.object)

        # Mark as processed — inside the same transaction
        await db.mark_event_processed(event.id, event.type)

The mark_event_processed write must be in the same transaction as the business logic update — otherwise, a crash after the business logic succeeds but before marking processed will cause the event to be processed again on the retry.

Reconciliation: The Safety Net

Never trust that your system state matches the payment processor's state. Network failures, webhook delivery failures, and application bugs all cause divergence. Automated reconciliation detects and resolves discrepancies.

import stripe
from datetime import datetime, timedelta
from decimal import Decimal

async def reconcile_stripe_payments(date: datetime) -> ReconciliationReport:
    """
    Compare our internal payment records against Stripe's records
    for the given date. Flag discrepancies for investigation.
    """
    # Get our internal records
    internal_payments = await db.get_payments_for_date(date)
    internal_by_id = {p.payment_intent_id: p for p in internal_payments}

    # Get Stripe's records
    stripe_payments = stripe.PaymentIntent.list(
        created={
            "gte": int(date.timestamp()),
            "lt": int((date + timedelta(days=1)).timestamp()),
        },
        limit=100,  # Paginate for large volumes
    )

    discrepancies = []

    for stripe_pi in stripe_payments.auto_paging_iter():
        if stripe_pi.id not in internal_by_id:
            # Stripe has record; we don't — possible missed webhook
            discrepancies.append(Discrepancy(
                type="missing_in_system",
                stripe_id=stripe_pi.id,
                amount=stripe_pi.amount / 100,  # Convert from cents
                status=stripe_pi.status,
            ))
        else:
            internal = internal_by_id[stripe_pi.id]

            # Amount mismatch
            if Decimal(stripe_pi.amount) != Decimal(internal.amount_cents):
                discrepancies.append(Discrepancy(
                    type="amount_mismatch",
                    stripe_id=stripe_pi.id,
                    stripe_amount=stripe_pi.amount,
                    internal_amount=internal.amount_cents,
                ))

            # Status mismatch
            if stripe_pi.status != internal.status:
                discrepancies.append(Discrepancy(
                    type="status_mismatch",
                    stripe_id=stripe_pi.id,
                    stripe_status=stripe_pi.status,
                    internal_status=internal.status,
                ))

    # Check our records that aren't in Stripe (shouldn't happen)
    stripe_ids = {pi.id for pi in stripe_payments}
    for payment_id, payment in internal_by_id.items():
        if payment_id not in stripe_ids:
            discrepancies.append(Discrepancy(
                type="missing_in_stripe",
                internal_id=payment.id,
                payment_intent_id=payment_id,
            ))

    return ReconciliationReport(date=date, discrepancies=discrepancies)

Run reconciliation daily (or more frequently for high-volume operations). Alert on discrepancies immediately — most discrepancies are recoverable if caught within 24-48 hours; some are much harder to resolve if discovered weeks later.

Rate Limiting and Retry Strategy

Fintech APIs have rate limits. Exceeding them produces errors that your retry logic must handle differently from transient failures:

import asyncio
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type, before_sleep_log
import stripe
import logging

logger = logging.getLogger(__name__)

@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential(multiplier=2, min=1, max=60),
    retry=retry_if_exception_type((
        stripe.error.RateLimitError,
        stripe.error.APIConnectionError,
        stripe.error.APIError,
    )),
    before_sleep=before_sleep_log(logger, logging.WARNING),
    reraise=True,
)
async def create_payment_intent_with_retry(
    amount: int,
    currency: str,
    customer_id: str,
    idempotency_key: str,
) -> stripe.PaymentIntent:
    return stripe.PaymentIntent.create(
        amount=amount,
        currency=currency,
        customer=customer_id,
        idempotency_key=idempotency_key,
    )

Crucially: CardError (card declined) is NOT in the retry list. A declined card is a permanent failure — retrying it wastes API calls and delays the user experience. Only retry transient errors.

Testing Fintech Integrations

Test in Stripe's test mode with specific test card numbers that simulate different scenarios: - 4242424242424242 — always succeeds - 4000000000000002 — always declines - 4000002500003155 — requires authentication (SCA) - 4000000000000069 — expired card

Write integration tests against Stripe's test API (never against production) for every payment flow path. Test your idempotency implementation explicitly — submit the same operation twice and verify only one charge occurs.

Rutagon Builds Production Fintech Integrations

If you're building payment processing, banking data integration, or compliance verification workflows, Rutagon's engineering team specializes in production-grade fintech infrastructure. Contact us at rutagon.com/contact.

See also: custom software development capabilities and API performance optimization.

FAQ

How do you handle currency conversion in multi-currency fintech integrations?

Stripe handles multi-currency natively — you can charge customers in their local currency and settle in your account currency. For platforms that need to track and report amounts in multiple currencies, store both the transaction currency and your accounting currency at charge time, using the exchange rate at the time of the transaction. Never recalculate historical exchange rates — store the rate used at the time and use it for all historical reporting.

What's the difference between payment authorization and capture?

Authorization places a hold on a customer's funds; capture actually moves the money. Two-step auth/capture allows you to authorize the charge when an order is placed and capture only when the order ships — relevant for e-commerce where fulfillment and ordering are separated. Stripe supports separate auth and capture via capture_method: "manual" on PaymentIntents.

How should we handle partial refunds and chargebacks?

Partial refunds: create a Refund object against the original PaymentIntent with the partial amount. Track refunds in your internal ledger as negative entries against the original payment. Chargebacks (disputes): you receive a charge.dispute.created webhook. Respond with evidence through the Stripe dashboard or API within the dispute window. Track chargeback rates — high rates indicate fraud risk or product/fulfillment issues.

Do we need PCI compliance if we use Stripe?

Using Stripe's hosted components (Stripe.js, Payment Element) significantly reduces your PCI scope. If you never handle raw card numbers — only Stripe's tokens — you qualify for the SAQ A self-assessment questionnaire, which has minimal requirements. If you're building a custom payment UI that POSTs card data to your server before forwarding to Stripe, you have a much higher PCI scope (SAQ D). Use Stripe.js and never touch raw card numbers.

What are the most common causes of payment webhook delivery failures?

The most common causes: your server returned a non-2xx status code (Stripe retries), your server was temporarily unavailable during the retry window, your webhook verification failed due to a clock skew or incorrect webhook secret, or your server timed out processing the webhook (Stripe has a 30-second timeout). Design webhook handlers to respond 200 immediately and process asynchronously — validate and enqueue in the handler, process in a background worker.

Ready to discuss your project?

We deliver production-grade software for government, defense, and commercial clients. Let's talk about what you need.

Initiate Contact