Back to 0fee
0fee

Stripe Integration: Global Card Payments

How 0fee.dev integrates Stripe Checkout Sessions for global card payments, handling zero-decimal currencies and middleman callbacks.

Thales & Claude | March 25, 2026 9 min 0fee
stripecard-paymentscheckout-sessionszero-decimal-currencies

Stripe is the default card payment provider for most of the world. When a customer in the United States, Europe, or Japan wants to pay with a Visa or Mastercard, Stripe handles the charge. The challenge is not whether Stripe works -- it is how you integrate it into a multi-provider orchestrator without exposing your developers to Stripe's specific quirks around currency formatting, session management, and callback verification.

This article covers how we built the Stripe adapter for 0fee.dev: the Checkout Sessions API, zero-decimal currency handling, amount conversion functions, the middleman callback pattern for security, and the differences between sandbox and live mode.

The Stripe Provider Adapter

Every provider in 0fee implements the same BasePayinProvider abstract class. Stripe is no exception. The adapter translates our unified payment format into Stripe Checkout Sessions API calls and translates Stripe's responses back into our standard format.

pythonclass StripeProvider(BasePayinProvider):
    """Stripe provider for global card payments."""

    PROVIDER_ID = "stripe"
    SUPPORTED_METHODS = ["PAYIN_CARD"]

    ZERO_DECIMAL_CURRENCIES = {
        "BIF", "CLP", "DJF", "GNF", "JPY", "KMF",
        "KRW", "MGA", "PYG", "RWF", "UGX", "VND",
        "VUV", "XAF", "XOF", "XPF"
    }

    async def initiate_payment(self, data: dict) -> dict:
        amount = data["amount"]
        currency = data["currency"]
        stripe_amount = self._convert_amount_to_stripe(amount, currency)

        session = stripe.checkout.Session.create(
            payment_method_types=["card"],
            line_items=[{
                "price_data": {
                    "currency": currency.lower(),
                    "product_data": {"name": f"Payment {data['reference']}"},
                    "unit_amount": stripe_amount,
                },
                "quantity": 1,
            }],
            mode="payment",
            success_url=callback_url,
            cancel_url=callback_url + "?cancelled=true",
            metadata={
                "transaction_id": data["transaction_id"],
                "app_id": data["app_id"],
            },
        )

        return {
            "status": "pending",
            "provider_reference": session.id,
            "redirect_url": session.url,
            "payment_flow": {"type": "redirect"},
        }

The structure is straightforward. We create a Checkout Session with the amount, currency, and a callback URL. Stripe returns a session object with a url property -- the hosted payment page where the customer enters their card details.

Zero-Decimal Currency Handling

This is where Stripe integration gets interesting. Stripe expects amounts in the smallest currency unit for most currencies. For USD, that means cents: $50.00 is sent as 5000. For EUR, it is the same: 50.00 EUR becomes 5000.

But not all currencies have subunits. The West African CFA Franc (XOF), Central African CFA Franc (XAF), and Japanese Yen (JPY) are "zero-decimal" currencies. When someone pays 5,000 XOF, you send 5000 to Stripe -- not 500000.

This distinction caused a real production bug in Session 023. A developer sent amount: 200 expecting a $200 charge, but Stripe displayed $2.00. The amount was not being multiplied by 100 for standard currencies.

The Conversion Functions

We solved this with two explicit conversion functions:

pythondef _convert_amount_to_stripe(self, amount: float, currency: str) -> int:
    """Convert our amount to Stripe's expected format.

    Standard currencies (USD, EUR, GBP): multiply by 100
    Zero-decimal currencies (XOF, XAF, JPY): use as-is
    """
    if currency.upper() in self.ZERO_DECIMAL_CURRENCIES:
        return int(round(amount))
    return int(round(amount * 100))

def _convert_amount_from_stripe(self, amount: int, currency: str) -> float: """Convert Stripe's amount back to our format.""" if currency.upper() in self.ZERO_DECIMAL_CURRENCIES: return float(amount) return amount / 100.0 ```

The int(round(...)) pattern deserves attention. In Session 063, we discovered a floating-point precision bug. When the system moved from integer-only amounts to floating-point (to support fractional USD values like $1.15), Stripe started rejecting payments with errors like "Invalid integer: 114.99999999999999". The floating-point multiplication 1.15 * 100 does not produce exactly 115 in IEEE 754 arithmetic -- it produces something like 114.99999999999999. Wrapping the result in int(round(...)) eliminates the precision issue.

CurrencyInput AmountStripe AmountConversion
USD50.005000Multiply by 100
EUR29.992999Multiply by 100
GBP1.15115Multiply by 100, round
XOF50005000No conversion
XAF1000010000No conversion
JPY30003000No conversion

The Complete Zero-Decimal Currency Set

Stripe's zero-decimal currency list is not arbitrary. These are currencies where the base unit is already the smallest denomination:

CurrencyNameRegion
XOFWest African CFA Franc8 UEMOA countries
XAFCentral African CFA Franc6 CEMAC countries
JPYJapanese YenJapan
KRWSouth Korean WonSouth Korea
BIFBurundian FrancBurundi
DJFDjiboutian FrancDjibouti
GNFGuinean FrancGuinea
KMFComorian FrancComoros
MGAMalagasy AriaryMadagascar
PYGParaguayan GuaraniParaguay
RWFRwandan FrancRwanda
UGXUgandan ShillingUganda
VNDVietnamese DongVietnam
VUVVanuatu VatuVanuatu
XPFCFP FrancFrench Pacific territories
CLPChilean PesoChile

For a payment orchestrator targeting Africa, several of these currencies appear in daily transactions. Getting the conversion wrong means charging someone 100 times too much or 100 times too little.

The Middleman Callback Pattern for Stripe

In a naive Stripe integration, you set the success_url to your customer's "thank you" page and the cancel_url to your checkout page. When the customer completes payment, Stripe redirects them directly to your success URL.

The problem: this redirect does not prove the payment succeeded. Anyone can navigate directly to your success URL. In a payment orchestrator where we are responsible for verifying transactions on behalf of thousands of developers, this is unacceptable.

Our middleman callback pattern solves this. Instead of sending the developer's success URL to Stripe, we send our own callback endpoint:

python# Instead of this (insecure):
success_url = developer_success_url

# We do this:
callback_url = f"{BASE_URL}/v1/payments/{transaction_id}/return"

When Stripe redirects the customer to our callback, we verify the payment before redirecting to the developer:

pythonasync def verify_stripe_payment(transaction_id: str, session_id: str) -> bool:
    """Verify Stripe payment by retrieving the session."""
    session = stripe.checkout.Session.retrieve(session_id)

    if session.payment_status == "paid":
        # Update transaction status to completed
        await update_transaction_status(transaction_id, "completed")
        # Update invoice status to paid
        await update_invoice_status(transaction_id, "paid")
        return True

    return False

The flow works as follows:

1. Developer: POST /v1/payments
   -> 0fee stores developer's success_url in payment_flow JSON
   -> 0fee creates Stripe session with OUR callback URL
   -> Returns Stripe checkout URL to developer

2. Customer: Completes payment on Stripe's hosted page

3. Stripe: Redirects to /v1/payments/{txn_id}/return

4. 0fee Callback:
   -> Retrieves Stripe session via API
   -> Checks session.payment_status == "paid"
   -> Updates transaction status
   -> Updates invoice status
   -> Redirects customer to developer's success_url

This adds a single redirect hop but guarantees that every payment marked as "completed" has been verified against Stripe's API. The developer never needs to implement their own Stripe verification -- 0fee handles it.

Sandbox vs. Live Mode

Stripe's test mode uses separate API keys (sk_test_...) that create real Checkout Sessions against a sandbox environment. In 0fee, the behavior differs slightly between sandbox and live:

ModeAPI KeyStripe BehaviorCheckout Experience
Sandboxsk_sand_*Stripe test keysCard form displayed inline in hosted checkout
Livesk_live_*Stripe live keysCustomer redirected to Stripe's hosted page

In sandbox mode, when a developer tests through our hosted checkout page, we render a card input form directly on the page using Stripe's test environment. This allows developers to use test card numbers like 4242424242424242 without leaving 0fee's checkout flow.

In live mode, the customer is redirected to Stripe's full hosted Checkout page, which handles card collection, 3D Secure authentication, and PCI compliance. This is the recommended production flow because Stripe's hosted page maintains PCI DSS compliance without requiring the merchant to handle card data.

Stripe Session Metadata

We use Stripe's metadata field to store the information needed for callback processing:

pythonmetadata = {
    "transaction_id": data["transaction_id"],
    "app_id": data["app_id"],
}

When the callback endpoint receives the redirect, it can retrieve the Stripe session, extract our transaction ID from the metadata, and look up the original transaction in our database. This avoids the need to encode transaction information in the URL (which could be tampered with).

Error Handling

Stripe can fail in several ways during session creation:

pythontry:
    session = stripe.checkout.Session.create(...)
except stripe.error.InvalidRequestError as e:
    # Invalid parameters (wrong currency, bad amount)
    return {"status": "failed", "error": str(e)}
except stripe.error.AuthenticationError:
    # Bad API key
    return {"status": "failed", "error": "Invalid Stripe credentials"}
except stripe.error.APIConnectionError:
    # Network issue
    return {"status": "failed", "error": "Could not connect to Stripe"}

When session creation fails, the routing engine can attempt the next provider in the priority chain -- though for card payments, Stripe is typically the only configured provider.

What We Learned

Three lessons from the Stripe integration:

  1. Zero-decimal currencies are not edge cases. For a platform targeting Africa, XOF and XAF are primary currencies. The amount conversion must be correct from day one.
  1. Floating-point arithmetic is never safe for money. The int(round(...)) pattern is not paranoia -- it fixed a real production bug where 1.15 * 100 produced 114.99999999999999.
  1. Never trust client-side redirects for payment verification. The middleman callback pattern adds one redirect but eliminates an entire class of payment fraud. When you are processing payments for other developers, verification is not optional.

Stripe was the first "gateway" provider we integrated, and it set the pattern for how all redirect-based providers would work in 0fee. The middleman callback, the amount conversion, the metadata storage -- these patterns repeat across PayPal, PawaPay, and every other provider that uses redirects.


This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles