Stripe Checkout Integration for International E-Commerce: Edge Cases and Lessons
Stripe’s documentation is genuinely excellent, and the initial integration usually works on the first attempt. But in reality, international e-commerce surfaces a class of problems the quickstart guide doesn’t cover: webhook delivery reliability across infrastructure restarts, the subtle mechanics of refunds in non-settlement currencies, and the operational overhead of managing API keys across multiple environments. This post documents the edge cases we encountered and what we changed as a result.
The Checkout Session Model and Its Assumptions
Stripe Checkout is a hosted payment page. You create a session on your server, redirect the customer, and Stripe calls your webhook when the payment completes. The flow is straightforward until you operate across multiple currencies or need to inspect the full payment object at the point of fulfillment.
By default, a Checkout Session response contains a minimal representation of the payment. The payment_intent field is a string ID, not the expanded object. This matters when your fulfillment logic needs to read payment_intent.latest_charge.payment_method_details — for example, to log the card country for your VAT determination logic. (On API versions before 2022-11-15, the path was payment_intent.charges.data[0]; Stripe removed the charges property from PaymentIntents in that version.) Webhook events never carry expanded objects, so getting this data at fulfillment time takes a second API call per event.
The expand parameter accepts an array of dotted paths. It applies only to the response of the API call that requests it: setting it on the session create call does not change the session object Stripe later sends in the webhook, because Stripe always sends objects in events in their minimal form. In the webhook handler, retrieve the session with ["payment_intent.latest_charge"] expanded instead. The trade-off is an extra API call per event and a larger response, which matters if you are writing raw responses to a log store.
Webhook Reliability
Stripe’s webhook delivery is retry-based: if your endpoint does not return a 2xx status (or times out), Stripe retries with exponential backoff for up to three days in live mode; sandbox events are retried three times over a few hours. This is good design, but it creates a correctness problem if your handler is not idempotent. Order creation, inventory reservation, and email dispatch are all side effects that should fire exactly once.
The standard solution is to record processed event IDs before executing side effects. We use a simple database table with the Stripe event ID as the primary key. The handler reads the incoming id field, attempts an insert, and proceeds only if the insert succeeds. A duplicate event ID causes a conflict and the handler returns 200 immediately. For this to be safe, the insert has to commit in the same database transaction as the side effects (or the row has to be marked as processed only after they succeed); otherwise a crash after the insert makes Stripe’s retry look like a duplicate, and the order is never fulfilled.
What the documentation understates is the gap between receiving a checkout.session.completed event and the payment actually being captured in Stripe’s settlement timeline. For certain payment methods — particularly bank transfers and some BNPL instruments available in Germany and the Netherlands — the checkout session completes before the funds are confirmed. In those cases the session completes with a payment_status of unpaid, and the correct events are checkout.session.async_payment_succeeded for triggering fulfillment and checkout.session.async_payment_failed for handling failure. Fulfilling on every checkout.session.completed without checking payment_status will dispatch orders whose payment later fails.
We also encountered an issue with our container restart cycle. During a deployment, in-flight webhook requests can arrive at the moment the old container is shutting down and the new one is starting. Nginx holds connections during the upstream transition, but we observed dropped events during a brief window. The fix was to add explicit keepalive_timeout and proxy_read_timeout settings to our Nginx config, and to rely on Stripe’s retry mechanism rather than trying to make our own deployment window zero-downtime at the webhook receiver level.
Live vs Test Key Management
The most common mistake we see in Stripe integrations is not keeping live and test keys truly separate. The obvious version is accidentally using a live key in a test environment and generating real charges. The less obvious version is more subtle: using test mode keys in a staging environment that shares a database with production-like data.
Our convention is three environments, each with its own complete set of Stripe credentials: development, staging, and production. Development uses Stripe test mode. Staging also uses test mode but with a different set of restricted keys and a separate webhook endpoint registered in the Stripe dashboard. Production uses live mode. All keys are injected at runtime as environment variables — never committed to source control, never interpolated into Docker images at build time.
The webhook signing secret is a separate credential from the API key. Each registered endpoint in the Stripe dashboard has its own signing secret. When you have three environments, you have three signing secrets. It is easy to copy the wrong one, especially when setting up a new deployment. We document the mapping explicitly in our internal runbook: which Stripe account, which endpoint, which signing secret, which key prefix. This sounds like overhead, but the alternative is a debugging session involving live mode events silently failing signature verification.
Stripe’s restricted keys feature is underused. Rather than giving every service your full secret key, you can create keys scoped to only the operations that service needs. A fulfillment worker that only needs to read payment intents and create refunds does not need write access to products or subscriptions. We issue restricted keys per service and rotate them independently of the main secret key.
Currency Handling and the Refund Problem
Stripe supports over 135 currencies for presentment, but your settlement currency is determined by the country of your Stripe account. If your account settles in EUR and a customer pays in SGD, Stripe performs the conversion at the point of charge. This is transparent until you issue a refund.
Refunds are processed in the presentment currency — the currency the customer paid in. But the amount deducted from your Stripe balance is calculated at the current exchange rate at the time of refund, not at the time of the original charge. If the exchange rate has moved unfavorably between charge and refund, you can refund more than you received in your settlement currency. This is not a bug; it is expected behavior. It is documented in Stripe’s guide to refunds and disputes, but it is easy to miss, and it has real cost implications if you are operating on thin margins across multiple currencies. (As of October 2026, Stripe’s Adaptive Pricing is an exception: refunds there use the exchange rate of the original transaction.)
The practical mitigation is to either restrict accepted currencies to those where you understand the exposure, or to hold a currency buffer that accounts for exchange rate variance. For high-volume operations, Stripe’s multi-currency settlement accounts let you hold balances in specific currencies, settling in each currency separately. This eliminates conversion at the point of refund for those currencies. The trade-off is more complex treasury management.
We also encountered an edge case with partial refunds on orders charged in a currency other than our internal base currency. (A single Checkout Session uses one currency, so all line items in an order share it.) Partial refunds require you to specify an amount in the currency of the original charge. If your order management system stores amounts in your internal base currency, you need to store the original presentment amount and currency alongside the order record, not just the converted amount. We added both fields to our orders table after the first partial refund attempt failed with a confusing validation error.
Payment Method Availability by Country
Stripe Checkout’s dynamic payment methods feature enables the payment form to show relevant payment methods based on the customer’s location. This is convenient but requires care. When you let Stripe choose methods from your Dashboard settings instead of passing an explicit list, the set of available methods is not fully predictable at the time you create the session — it depends on factors Stripe evaluates at render time.
This matters for your test suite. If you are testing checkout flows that assert specific payment methods appear, those tests will behave differently in different regions. We write our integration tests against the explicit payment_method_types parameter rather than dynamic payment methods, which gives us a deterministic set. The production Checkout uses dynamic payment methods. The divergence is intentional and documented.
SEPA Direct Debit, iDEAL, Bancontact, and Przelewy24 each come with requirements that card payments do not. Hosted Checkout collects the method-specific details itself — the IBAN and mandate acceptance for SEPA, for example — but the methods carry other constraints: SEPA Direct Debit in Checkout requires all line items to be priced in EUR, and it is a delayed-notification method, so the async events above apply. We test each enabled payment method against the full checkout flow, not just the payment confirmation step.
What We Changed After the First Production Issues
After running the integration through its first few months of real transactions, we made three structural changes. First, we moved idempotency key storage from in-memory to the database, because an in-memory store does not survive container restarts. Second, we added explicit logging of the Stripe event ID, the event type, and the outcome of each webhook handler invocation — not just errors. This made it possible to reconstruct the event sequence when diagnosing order state discrepancies. Third, we stopped relying on the Stripe Dashboard’s event log as our primary debugging tool and started treating our own logs as authoritative.
The Stripe Dashboard is useful for exploring events interactively, but it shows you Stripe’s view of delivery, not your application’s view of receipt. A delivery the Dashboard marks as successful only means that our endpoint returned a 2xx status; it cannot show whether the work behind that response completed. Our logs told the full story; the Dashboard told an incomplete one.