How I Integrated Revolut Payments — Apple Pay, Google Pay & Revolut Pay — into a React Native Mobile App

A car wash chain approached us to build them a car wash mobile app — We went for technologies such as Expo, React Native, Firebase backend, etc, and started building, but right away we realized: it wasn't just car washes. The same app needed to sell prepaid credit for vacuuming stations, cleaning-rag/wipe dispensers, and even self-service toilets — the system is built to scale to 24 partner companies, 59 locations, and 571 individual self-service machines across Croatia. One credit balance, usable across all of it, topped up from a phone with whatever payment method the customer already had — Apple Pay, Google Pay, saved card, or Revolut itself. No web checkout redirect, no "pay at the terminal" fallback. It had to feel native.
We ended up integrating Revolut Merchant API for all four payment paths, backed by Firebase Cloud Functions and a webhook-driven order pipeline. Here's how it actually went — including the parts that weren't fun.
Why Revolut over Stripe
This was the client's call, and the numbers back it up. As of January 2026, Revolut Business charges 1% + €0.20 on EEA consumer card payments through its merchant API. Stripe's standard EEA domestic rate is 1.5% + €0.25. On a business doing frequent small top-ups — a few euros at a time, many times a day, across dozens of locations — that spread adds up fast. Revolut Pay itself (paying with a Revolut account balance) is cheaper again than a routed card payment. So for a mobile application that takes payment seriously, Revolut is something that definitely should be considered.
There was also a practical angle: the client already banked with Revolut Business, so settlement was same-account, same-currency, no extra intermediary.
The four payment paths
The app needed to support:
- Apple Pay (iOS) — via
@rnw-community/react-native-payments, the W3CPaymentRequestAPI - Google Pay (Android) — same library, same API, different payment method identifier
- Revolut Pay (native button) —
@revolut/revolut-pay-lite, Revolut's own SDK - Card entry —
@revolut/revolut-merchant-card-form
Apple Pay and Google Pay both go through the same PaymentRequest flow on the client — the only difference is which supportedMethods you declare:
const request = new PaymentRequest([{
supportedMethods: PaymentMethodNameEnum.ApplePay, // or AndroidPay
data: { merchantIdentifier, countryCode: "HR", supportedNetworks: [...] },
}], {
total: { label: "Self-service credits", amount: { currency: "EUR", value: amount.toFixed(2) } },
});
const response = await request.show();
const paymentData = response.details.applePayToken?.paymentData;
// forward paymentData to our backend, which hands it to Revolut
The token that comes back from Apple's PassKit sheet — or Google Pay's equivalent — never touches Revolut directly from the client. It goes to a Cloud Function first, which attaches it to a Revolut order:
const paymentPayload = { payment_method: { type: "apple_pay", token: paymentToken } };
await fetch(`${REVOLUT_API_BASE}/api/orders/${orderId}/payments`, {
method: "POST",
headers: { Authorization: `Bearer ${REVOLUT_SECRET_KEY}`, "Revolut-Api-Version": "2024-05-01" },
body: JSON.stringify(paymentPayload),
});
Revolut Pay is simpler on the client side — the native SDK handles its own payment sheet once it has an order token — but everything still funnels through the same order-creation and webhook logic on the backend.

Order creation, webhooks, and idempotency
Every payment — regardless of method — starts the same way: the client calls a createRevolutOrder function with the amount, user ID, and some order metadata (which partner location, how much credit, which payment method was chosen). That function creates a Revolut order and returns a token and checkout reference.
The actual confirmation doesn't come from the client — it comes from Revolut's webhook, on ORDER_COMPLETED:
- Revolut calls our webhook with the event
- We re-fetch the order from Revolut's API directly (never trust the webhook payload's amount/state at face value)
- We check a
processed_orderscollection in Firestore before doing anything — if this order ID was already processed, we bail out. Webhooks can and do arrive more than once. - Only after that do we credit the user's balance and sync it to the client's own facility-management backend — a separate legacy system each partner location was already running on.
That last step turned out to be its own small project — the facility vendor's API predates most of this stack, expects specific field shapes, and needed a translation layer between "what Revolut's webhook tells us" and "what the machines on-site actually understand."
The part that actually took the longest: getting iOS to build at all
None of the payment logic above was the hard part. The hard part was getting an Expo app with use_frameworks! :linkage => :static (required by Firebase), New Architecture, and three separate Revolut SDKs to compile on Xcode 16.4 without CocoaPods falling over.
A sample of what that fight looked like:
- Firebase pods vs. static linkage:
use_frameworks! :linkage => :staticforcesDEFINES_MODULE = YESon every pod. Firebase's ObjC pods (RNFBApp,RNFBAuth, etc.) then trip a Clang "module consistency" check becauseRCTBridgeModulegets imported twice — once through React's module map, once through the non-modular import inside Firebase's own headers. Fix: forceDEFINES_MODULE = NOon the pure-ObjC Firebase pods via a custom Expo config plugin — exceptRNFBFirestore, which mixes Swift and ObjC and needs its module map to survive, just with the offending React header stripped out of its umbrella header post-pod install. - Xcode 16.4 / iOS 18 SDK vs. iOS 26-only APIs: A few Expo packages (
expo-image-picker,expo-image) call iOS 26 APIs inside@available(iOS 26, *)guards. Swift still type-checks those branches against whatever SDK you're actually compiling with — so even code that can never execute on iOS 18 fails to compile on iOS 18. Had to patch those files to strip the iOS 26 branches entirely. - A Revolut SDK binary compiled for the wrong Xcode: at one point, a routine
npm installbumped@revolut/revolut-pay-liteto a version whose underlying nativeRevolutPaymentsbinary had been compiled with Xcode 26 — and it referenced Swift 6.1 runtime symbols that simply don't exist on Xcode 16.4. Pinned the Revolut packages to the last version built against the older toolchain.

None of these were one-line fixes — they're baked into custom Expo config plugins and a set of idempotent post-install patch scripts that run on every build, local and CI, so nobody has to remember to redo them by hand.
Working with Revolut's team directly
A few of the trickier issues — particularly around the native SDK behavior on React Native's New Architecture — weren't things I could solve from documentation alone. I ended up in a series of live debugging sessions with Revolut's own developer support team, going through the integration together in real time. At one point, something we worked through together was specific and useful enough that it made it back into a documentation update on Revolut's own React Native integration guide. It's a rare thing to have direct access to the team behind an SDK you're integrating, rather than just a support ticket queue — that made a genuine difference in how fast this shipped.
What shipped
Four payment methods, one order pipeline, webhook-verified and idempotent, synced live into each partner location's existing on-premise management system, running on New Architecture React Native in production. The build pipeline survived several rounds of Expo SDK and Revolut SDK upgrades since, because the fixes live in code, not in someone's memory of what to click in Xcode. The feature and the app was shipped under Nordia Digital, where I was the CEO and the only developer on the project.
If you're integrating Revolut into a React Native app and hit any of the build errors above, the Revolut Merchant API docs and Revolut Business fees page are the two references worth having open.