Getting every payment through, from Stockholm to Kuwait
Players paid for courts, games, tournaments and memberships from several countries. I built the payment flows in the app so that European banks accept them, wallets take one tap, and clubs in Kuwait can take their local payment method.
- Market
- Padel booking, Sweden and Kuwait
- My role
- React Native developer
- Payments
- Cards, Apple Pay, Google Pay, credits, KNET, Tap
- Team
- A large product team
ProblemCard payments had to pass the extra bank check European banks require, and clubs in Kuwait needed a local way to pay.
What I didI built card payments with 3D Secure, Apple Pay and Google Pay through Stripe, credit pay, in-app purchase, and the first KNET and Tap integration for Kuwait.
ResultOne app that takes payments for bookings, games, tournaments and memberships across its markets, in an app with 100K+ downloads on Google Play.
A payment the bank refuses is a booking you lose
Picture a player in Stockholm on a Sunday evening. They find a free court for Tuesday, pick an hour and tap Pay. Their bank does not simply accept the card. It asks for proof that the real card holder is paying. A sheet should open, the player approves the payment in their bank app, and the booking goes through.
If the app cannot handle that extra step, the payment fails. The player sees an error they do not understand. The court stays empty, the club loses the money, and the player may book somewhere else next time.
This was the app I worked on: a padel booking and community app for players in Sweden and the rest of Europe, and later for clubs in Kuwait. Players booked courts, joined games, signed up for tournaments and training, bought club memberships and paid for a pro subscription, all inside the app. The company collected the money for the clubs and paid each club its share.
So the app had to take money in many situations, from many countries, with the payment methods people there expect. In Europe that means cards with strong customer authentication, plus Apple Pay and Google Pay. In Kuwait it means KNET, the local debit card network. And for some digital products, Apple and Google decide how you are allowed to charge at all.
Many things to pay for, many ways to pay, one checkout
There was not one checkout. There were many. A court booking, a game, a tournament, a membership and a pro subscription each had its own screen and its own rules. A booking might include extra slots and a member discount. A tournament might have a promo code. A membership could be bought, managed and cancelled. Each of these flows had to support every payment method, and each one had to end in the right state.
The bank check can happen in the middle of a payment. Under strong customer authentication, the bank decides at payment time whether to ask for proof. The same card can pass without a check one day and need one the next. So the app cannot know in advance which payments will need the extra step. It has to be ready for it every time.
Payment methods change by country. Swedish players expect cards, Apple Pay and Google Pay. Kuwaiti players expect KNET. These do not go through the same provider. Cards and wallets went through Stripe. KNET went through a different provider, with a different flow: the player pays on a bank page, not inside a native sheet.
The app stores have their own rules. Apple and Google require some digital purchases, like a subscription that unlocks app features, to go through their own billing. If an app sells those through a card form instead, it can be removed from the store. Court bookings are a real-world service, so they can use cards. The pro subscription was different.
And the native libraries were sensitive. A React Native app talks to Stripe through a native SDK, and that SDK has to match the app's build setup. Several of my commits from that year do nothing except fix the version of the Stripe library so the builds kept working on both platforms.
What happens between tapping Pay and seeing the booking
The core of the work was the card flow, so it is worth following one card payment from start to end.
First, the player taps Pay. The app sends the booking details and the amount to our backend. The backend creates a payment intent with Stripe and returns a client secret. The app never holds the secret key. It only gets the one-time secret for this payment.
Second, the app confirms the payment on the phone, using Stripe's native SDK. If the player has a saved card, the app passes that card. If they typed a new card, the app uses the card details from Stripe's card field, so the raw card number never touches our code.
Third, the bank decides. If it does not need proof, the payment succeeds straight away. If it does, the SDK opens the bank's 3D Secure sheet inside the app. The player approves, and the SDK returns the result. The app does not need a separate screen for this. It only needs to wait for the confirmation to finish and handle every possible answer: succeeded, failed, or cancelled by the player.
Fourth, the app asks the backend to check the charge. A phone can lose its connection right after paying. So the booking is not confirmed because the phone says so. It is confirmed when the server has seen the charge succeed.
Fifth, the booking is saved and the player sees the confirmation screen. If anything failed, they see a clear message in their language, and they can try again with another card or another method.
One payment, read like a receipt
| Event | State | Amount |
|---|---|---|
| Player taps Pay | Created | 400.00 kr |
| Bank asks for proof | Needs action | 400.00 kr |
| Player approves in the bank sheet | Confirming | 400.00 kr |
| Server checks the charge | Succeeded | 400.00 kr |
| Booking saved | Confirmed | 400.00 kr |
Making the bank check part of the normal flow
I added 3D Secure to card payments in July 2023. From then on, the bank check was one possible step inside the normal payment flow, not a dead end.
The first version went into bookings, games and tournaments. Memberships came the next day, because a membership payment is still a card payment and the bank can still ask. Membership and pro subscription payments also got clear error messages when a payment failed, in English and Swedish.
I also moved the Stripe setup to the root of the app. Before, payment code was set up screen by screen. Wrapping the whole app in one Stripe provider meant every screen used the same configuration, and switching between test and live keys became a change in one place instead of a search through many files.
Saved cards were part of the same work. Players can add a card, see their saved cards and remove one. When a card is saved, it is set up for later use, so the next payment can go through with fewer steps. The card list is also where I found a bug that mattered: if a player had no saved cards at all, the payment screens could crash. I fixed that so a player with no cards can still pay, either with a new card or with a wallet.
Apple Pay and Google Pay: one tap, same rules
On a phone, typing a card number is the slowest part of a checkout. Apple Pay and Google Pay remove it. The player looks at the price, confirms with their face or fingerprint, and the payment is done.
I added Google Pay and Apple Pay to the checkout through Stripe. The wallet sheet shows the amount and the currency of the club. After the player confirms, the app confirms the payment with Stripe and then asks the server to check the charge, just like the card path. So a wallet payment ends in the same place as a card payment, and the rest of the app does not need to know which method was used.
Wallets brought their own small bugs. Google Pay showed the wrong price in the tournament flow, and its button did not show correctly in the payment method list. I fixed both. Apple Pay was taken out of game and tournament payments for a while. It came back for bookings, games and tournaments in a teammate's branch that was built mostly on my code.
Credits, promo codes and the free booking
Not every payment is a card payment. Players could buy credit packages and spend credits on bookings, games and tournaments. I built credit pay for bookings and activities, and later the screen where players buy credit packages, with translations for each language the app supported.
Promo codes and member discounts lower the price. I built the coupon flow for bookings and then added promo codes to game and tournament payments. Early on the coupon price had a calculation bug. I fixed it and moved the coupon logic into one reusable component, so the same code gives the same price on every screen.
A discount can bring the price to zero. That sounds like a nice case, but it breaks a naive checkout. A card provider will not create a payment for nothing, so a free booking that still goes through the card flow fails. I added zero-payment handling to bookings and activities: when the total is zero or less, the app skips the payment step and saves the booking as paid, with no transaction attached.
KNET: a different provider and a different flow
When the app started working with clubs in Kuwait, cards were not enough. Many people there pay with KNET, the local debit card network. KNET does not go through Stripe, so it needed a separate integration, through a payment provider that supports it, called Tap.
The flow is different from a card sheet. The app asks the backend to start a KNET payment. The backend creates a charge with the provider and returns a payment page. The app opens that page in a web view, and the player pays on their bank's page. When the page finishes, the app asks the backend for the status of the charge. If the status is not final yet, it checks again a few times before it gives up.
I built the first version of this in the app in December 2023. It had a payment hook that every payment screen could use, so bookings, games and series games all took KNET the same way. It checked the club's currency to decide whether the local methods should be offered at all, so a Swedish club never showed KNET. It had a payment method picker, and a loader while the bank page was opening, because a blank web view looks like a broken app.
On the backend, I changed the KNET payment endpoint to take a POST request and made the provider's webhook reachable without a user login. The provider calls that webhook from its own servers, so it cannot carry a player's token. Without that change, the provider could never tell us that a payment had finished.
After that, the team built more on the same base: split payments for KNET, a Tap card payment screen and credit pay for Kuwaiti clubs. Much of the code in those changes was mine, but they were team work.
In-app purchase for the pro subscription
The pro subscription unlocked features inside the app. For that kind of product, Apple and Google require their own billing. So in August 2023 I added in-app purchase on both iOS and Android.
The app fetches the subscription products from the App Store and Google Play and maps them to the company's own pro packages, so the store's product and the business's plan always match. Players can subscribe, cancel and restore a purchase on a new phone. iOS needed extra care: test builds, purchase activation, and a bug where the purchase logic was called again after it had already run.
There was one more case. Some players had already subscribed through Stripe before in-app purchase existed. Moving them by force would have broken a working subscription. So players who were already Stripe subscribers kept seeing their Stripe packages, and new buyers went through the store.
Four choices that kept payments going through
Ask every card for a bank check, or only when the bank asks?
Force the bank check on every card payment
Confirm on the phone with Stripe's SDK; the bank sheet opens only when the bank asks
Most payments pass without a check, and the ones that need it still go through in the same flow.
What if a discount makes the price zero?
Send the zero amount through the card flow
Skip the payment step and save the booking as paid
A card provider will not charge nothing, so a free booking must never wait for a card.
One promo calculation, or one per screen?
Copy the coupon maths into each payment screen
One reusable coupon component
The same code must give the same price everywhere, and a fix should land in one place.
What happens to players who already paid through Stripe?
Move every subscriber to store billing at once
Existing Stripe subscribers keep their packages; new buyers use the store
A working subscription should not break because the way we sell changed.
Confirm on the phone, then trust the server
// Written for this page. Not client code.
async function payForBooking(booking: Booking) {
if (booking.total <= 0) return saveBooking(booking, { paid: true });
// The server creates the payment and returns a one-time secret
const { clientSecret, paymentId } = await api.createPayment(booking);
// Opens the bank's 3D Secure sheet only if the bank asks for it
const { error } = await confirmPayment(clientSecret, { paymentMethodType: "Card" });
if (error) return showError(error.message);
// The phone can lose its connection; the server decides
const charge = await api.checkCharge(paymentId);
if (charge.status !== "succeeded") return showError("Payment not completed");
return saveBooking(booking, { paid: true, paymentId });
}One app that takes money the way each market pays
A payment is a flow with many endings
The biggest lesson was to design for every ending, not only the happy one. A payment can succeed, fail, be cancelled by the player, wait for the bank, or succeed on the server while the phone loses its connection. Each of those needs a clear answer in the app. Many of my payment fixes that year were not in the happy path. They were in the endings nobody had drawn: no saved card, a zero price, a failed subscription, a wallet showing the wrong amount.
The second lesson was that the phone is not the source of truth. A phone can say "paid" and then die. So the booking should wait for the server to see the charge. I carry this into every payment flow I build now.
The third lesson was about local methods. Kuwait showed that in some markets the card is not the main way to pay. Adding a country can mean adding a new provider, a new flow and new webhooks, not a new button. It is better to plan for that early, with a payment layer that can take more than one provider.
And the store rules are part of the product. Whether a purchase can use a card or must use Apple and Google billing is not a technical detail. It decides which code you write, and it can decide whether the app stays in the store.
Any app that takes money in more than one country
The same problems show up wherever an app sells to people in different places. A gym app sells classes and memberships. A travel app takes deposits from customers in several countries. A marketplace takes payments in one country and pays sellers in another. A subscription app has to decide what goes through the stores and what goes through its own checkout.
In each case the checklist is the same. Make the bank check a normal part of the flow. Offer the wallets people already use. Find out how people in each market actually pay, and plan a provider for it. Handle free and failed payments on purpose. And let the server, not the phone, decide when something is paid.
Selling in more than one country?
Tell me what your checkout needs: the bank checks, the wallets and the local methods your customers expect. I can help you get payments through, not just started.