Integrazione di Stripe Checkout per l'E-Commerce Internazionale: Casi Limite e Lezioni
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.charges.data[0].payment_method_details — for example, to log the card country for your VAT determination logic. Without explicitly requesting the expansion, you face a choice: make a second API call per event, or rely on the expand parameter at session creation time.
The expand parameter accepts an array of dotted paths. For our setup we request ["payment_intent.charges"] on the session create call. This embeds the expanded charge data directly in the session object returned by the webhook, avoiding the extra roundtrip. The trade-off is a larger payload per event, which matters if you are writing raw webhook payloads to a log store.
Webhook Reliability
Stripe’s webhook delivery is retry-based: if your endpoint returns anything other than a 2xx status within 30 seconds, Stripe will retry at increasing intervals for up to 72 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. This is sufficient for most cases.
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. The correct event for triggering fulfillment in those cases is payment_intent.payment_failed for failure and checkout.session.async_payment_succeeded for success. Listening only to checkout.session.completed will dispatch orders that subsequently fail to capture.
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 unfavourably between charge and refund, you can refund more than you received in your settlement currency. This is not a bug; it is expected behaviour. But it is not prominently documented, and it has real cost implications if you are operating thin margins across multiple currencies.
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 containing line items in different currencies — a situation that arose when we allowed customers to pay using Stripe’s multi-currency Checkout with currency conversion. Partial refunds require you to specify an amount in the original presentment currency. 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 automatic 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 automatic_payment_methods is enabled, 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 automatic methods, which gives us a deterministic set. The production Checkout uses automatic methods. The divergence is intentional and documented.
SEPA Direct Debit, iDEAL, Bancontact, and Przelewy24 all require additional customer data fields that card payments do not. If you enable these methods without updating your checkout flow to collect the required fields — IBAN for SEPA, for example — Stripe will reject the payment attempt after the customer has already committed to the purchase. 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. There have been cases where the Dashboard showed a successful delivery but our handler had returned 500 and the event was re-queued. Our logs told the truth; the Dashboard told an incomplete story.