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.
| Currency | Input Amount | Stripe Amount | Conversion |
|---|---|---|---|
| USD | 50.00 | 5000 | Multiply by 100 |
| EUR | 29.99 | 2999 | Multiply by 100 |
| GBP | 1.15 | 115 | Multiply by 100, round |
| XOF | 5000 | 5000 | No conversion |
| XAF | 10000 | 10000 | No conversion |
| JPY | 3000 | 3000 | No 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:
| Currency | Name | Region |
|---|---|---|
| XOF | West African CFA Franc | 8 UEMOA countries |
| XAF | Central African CFA Franc | 6 CEMAC countries |
| JPY | Japanese Yen | Japan |
| KRW | South Korean Won | South Korea |
| BIF | Burundian Franc | Burundi |
| DJF | Djiboutian Franc | Djibouti |
| GNF | Guinean Franc | Guinea |
| KMF | Comorian Franc | Comoros |
| MGA | Malagasy Ariary | Madagascar |
| PYG | Paraguayan Guarani | Paraguay |
| RWF | Rwandan Franc | Rwanda |
| UGX | Ugandan Shilling | Uganda |
| VND | Vietnamese Dong | Vietnam |
| VUV | Vanuatu Vatu | Vanuatu |
| XPF | CFP Franc | French Pacific territories |
| CLP | Chilean Peso | Chile |
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 FalseThe 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_urlThis 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:
| Mode | API Key | Stripe Behavior | Checkout Experience |
|---|---|---|---|
| Sandbox | sk_sand_* | Stripe test keys | Card form displayed inline in hosted checkout |
| Live | sk_live_* | Stripe live keys | Customer 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:
- 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.
- Floating-point arithmetic is never safe for money. The
int(round(...))pattern is not paranoia -- it fixed a real production bug where1.15 * 100produced114.99999999999999.
- 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.