Stripe International Payments - Deep Dive
Full lifecycle of a Stripe payment, idempotency math, webhook reliability, multi-currency settlement, and the PSD2/SCA traps.
This is the version I wish I had been handed on day one at Binocs. Stripe looks simple from the outside, but the failure modes are subtle and they all happen in production, never on your laptop.
The PaymentIntent state machine
A PaymentIntent is not a charge. It is a state machine with seven canonical states, and you have to handle each one explicitly.
The states that bite are requires_action and processing. requires_action means the bank wants 3DS, OTP, or biometric. The user is staring at a Stripe-hosted page or an iframe right now. processing means the funds are moving but not yet confirmed, common for SEPA debits and some Indian rails. Neither state means failure, neither means success. Your UI must reflect this honestly.
The single biggest mistake I see junior engineers make is treating PaymentIntent.create as the moment of truth. It is not. The moment of truth is the webhook saying payment_intent.succeeded. Everything else is intermediate.
Idempotency, properly
Stripe lets you send an Idempotency-Key header on any POST. Stripe stores the response keyed by (key, account_id) for 24 hours. Send the same key, get the same response, no side effect. This is the only thing standing between you and a duplicate charge when your retry logic fires.
The key has to be unique per logical operation. At Binocs we used invoice_id + ":" + attempt_counter. The attempt counter increments only if the previous attempt returned a non-retryable error like card_declined. Network timeouts and 5xx errors reuse the same key, which is exactly what you want.
The subtle gotcha: if you change any parameter (amount, currency, customer) between retries with the same key, Stripe returns a 400 error. So either lock your parameters at the moment you mint the key, or roll the key when you change them.
Webhook reliability
Stripe sends webhooks on a retry schedule: immediately, then exponentially backing off over 3 days. You will receive every successful event eventually, and you will receive most of them more than once. Two rules:
- Deduplicate by
event.id. Store it in a table with a unique index. If insert fails on conflict, return 200 OK without processing. The 200 tells Stripe to stop retrying. - Make handlers idempotent at the business level. "Mark invoice X paid" should be safe to run twice. Use UPSERT or check current state before mutating.
Signature verification is non-negotiable. Stripe signs the request with HMAC-SHA256 over a timestamp and the raw body, using your endpoint secret. The signature is in Stripe-Signature header. The Stripe SDK does this for you, but you have to feed it the raw bytes of the body, not a re-serialized JSON object. In FastAPI this means calling await request.body() before any parsing.
from fastapi import Request, HTTPException
import stripe
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
payload = await request.body()
sig = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(payload, sig, WEBHOOK_SECRET)
except stripe.error.SignatureVerificationError:
raise HTTPException(400, "bad sig")
# dedup
if await event_exists(event["id"]):
return {"ok": True}
await handle(event)
return {"ok": True}Multi-currency and settlement
Stripe charges in any of 135 currencies, but settles to your bank in the currencies your account is configured for. At Binocs we settled USD to a US bank, EUR to a German bank, and we deliberately did not accept INR through Stripe.
The presentment currency (what the customer sees) and the settlement currency (what hits your bank) are different. Stripe converts at their FX rate plus a 1 percent fee. If you want to lock the rate, you have to use Stripe Connect with a custom flow or do the conversion yourself before calling Stripe.
Cross-border fees stack: 2.9 percent base + 1 percent international + 1 percent FX. A US-issued card paying our EUR-denominated invoice costs us about 5 percent. We surfaced this in pricing.
SCA, 3DS, and PSD2
PSD2 is the EU regulation that says most card transactions over EUR 30 need Strong Customer Authentication. SCA in card terms means 3D Secure 2. The user is challenged by their bank, usually with a push notification or OTP.
PaymentIntents handle this for you if you use automatic_payment_methods. The intent transitions to requires_action and the client SDK opens the challenge. Two failure modes to plan for:
- The user abandons the challenge. The intent sits in
requires_actionforever. We have a sweeper that cancels intents older than 24 hours. - The bank approves but the redirect back to our site fails. The webhook still fires, so the invoice gets marked paid, but the user sees an error page. We added a "your payment may have succeeded, refresh in 30 seconds" message.
Refunds, disputes, and the audit trail
Refunds are simple: stripe.Refund.create(payment_intent=pi_id). Partial refunds are allowed. The dispute flow is harder. A dispute (chargeback) is when the cardholder tells their bank "I did not authorize this." You have 7 days to submit evidence: receipt, IP, user agent, login history, delivery proof. Lose the dispute, you lose the funds plus a $15 dispute fee.
We log everything that could be evidence at payment time: IP, user agent, session ID, the exact line items, the timestamp of the click that triggered the intent. This goes into a separate payment_audit table that we never delete from.
What I would do differently
- Build the webhook handler first, before any UI. Test it with Stripe CLI before writing the front end.
- Use Stripe Tax from day one. Calculating tax ourselves was a mistake. Their API handles US sales tax, VAT, GST.
- Treat the Stripe dashboard as the source of truth for finance, not your DB. Reconcile nightly.
Learn more
- Docs
- Docs
- Docs
- ArticleStripe Engineering: Designing robust APIsStripe Blog