# Mobile and app integrations

> iOS, Android, Flutter and React Native: open the checkout in a secure in-app browser and return with a deep link.

Source: https://rais.app/docs/mobile

A mobile app takes payments the same way a website does: your **server** creates the payment, and the app opens the `checkout_url` in a secure browser and comes back through a deep link. The app never talks to the Raïs API and never holds a key.


> [!CAUTION]
> **Never ship a secret key in an app**
>
> Anything inside an app can be extracted: strings in the binary, network traffic, a rooted device. A secret key in an app lets anybody create payments and refunds in your name. Create payments on your server only.


## The flow

1. The app asks **your server** to start a payment for an order or a cart.
2. Your server computes the amount, creates the payment with Raïs (see [Payments](https://rais.app/docs/payments.md)) using your secret key, stores the payment id with the order, and returns the `checkout_url` to the app.
3. The app opens `checkout_url` in a system browser session. The payer enters their card and confirms with 3-D Secure.
4. Raïs sends the payer to your `success_url` or `cancel_url`, which is a deep link into your app, with `payment_id` and `status` added.
5. The app **asks your server** for the real status of the order. Your server knows the answer from the signed webhook or by retrieving the payment.

## Return with a deep link

Set `success_url` and `cancel_url` to an address on your own app scheme. Raïs accepts any custom scheme in both environments and keeps it exactly as you wrote it, adding the result parameters:

```
myapp://pay/result?payment_id=5b1c2f4e-9d3a-4c61-8a7e-0f2d6b9e1a33&status=paid&reference=order-1042&order_number=RS12AB34CD
```


**Request**

```bash
curl https://api.rais.app/v1/payments \
  -H "Authorization: Bearer rais_sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 500000,
  "reference": "order-1042",
  "success_url": "myapp://pay/result",
  "cancel_url": "myapp://pay/cancelled",
  "language": "fr"
}'
```


After a successful payment the result page stays on screen, so the payer can read the details, and its **Return to** button opens the deep link. The page does not redirect by itself. If you prefer https links that open your app (universal links on iOS, app links on Android), use an https address that your site redirects to the app.

## Open the checkout

Use the secure browser of the platform, not a plain `WebView`. It shares the system's security features, shows the address to the payer and handles 3-D Secure pages the way banks expect.


**iOS (Swift)**

Use `ASWebAuthenticationSession`. It opens a secure sheet and returns to your app when the browser navigates to your scheme.

~~~swift
import AuthenticationServices

final class CheckoutPresenter: NSObject, ASWebAuthenticationPresentationContextProviding {
    private var session: ASWebAuthenticationSession?

    /// checkoutURL comes from YOUR server, which created the payment.
    func pay(checkoutURL: URL, completion: @escaping (_ paymentId: String?) -> Void) {
        let session = ASWebAuthenticationSession(url: checkoutURL, callbackURLScheme: "myapp") { callbackURL, _ in
            guard let callbackURL,
                  let items = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems else {
                completion(nil) // the payer closed the sheet
                return
            }
            completion(items.first { $0.name == "payment_id" }?.value)
        }
        session.presentationContextProvider = self
        session.start()
        self.session = session
    }

    func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
        UIApplication.shared.connectedScenes
            .compactMap { ($0 as? UIWindowScene)?.keyWindow }
            .first ?? ASPresentationAnchor()
    }
}
~~~

**Android (Kotlin)**

Use Chrome Custom Tabs from `androidx.browser:browser`, and an activity that receives your scheme.

~~~kotlin
import android.content.Context
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent

fun openCheckout(context: Context, checkoutUrl: String) {
    CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(checkoutUrl))
}
~~~

~~~xml
<activity android:name=".PaymentResultActivity" android:exported="true" android:launchMode="singleTask">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="myapp" android:host="pay" />
    </intent-filter>
</activity>
~~~

~~~kotlin
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity

class PaymentResultActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        handle(intent)
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        handle(intent)
    }

    private fun handle(intent: Intent) {
        val paymentId = intent.data?.getQueryParameter("payment_id") ?: return
        // Ask YOUR server for the real status. Never trust ?status= from the link.
        confirmWithServer(paymentId)
    }

    private fun confirmWithServer(paymentId: String) { /* call your API */ }
}
~~~

**Flutter**

Use `flutter_web_auth_2`, which wraps `ASWebAuthenticationSession` and Custom Tabs. Follow its Android setup for the callback activity of your scheme.

~~~dart
import 'package:flutter/services.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';

/// checkoutUrl comes from YOUR server, which created the payment.
Future<String?> payWithRais(String checkoutUrl) async {
  try {
    final result = await FlutterWebAuth2.authenticate(
      url: checkoutUrl,
      callbackUrlScheme: 'myapp',
    );
    return Uri.parse(result).queryParameters['payment_id'];
  } on PlatformException {
    return null; // the payer closed the browser
  }
}
~~~

**React Native**

With Expo, `expo-web-browser` opens the right secure browser on each platform and `expo-linking` reads the result. In a bare React Native app, `react-native-inappbrowser-reborn` offers `InAppBrowser.openAuth` with the same shape.

~~~typescript
import * as Linking from "expo-linking";
import * as WebBrowser from "expo-web-browser";

// checkoutUrl comes from YOUR server, which created the payment.
export async function payWithRais(checkoutUrl: string): Promise<string | null> {
  const redirectUrl = Linking.createURL("pay/result"); // myapp://pay/result in a built app
  const result = await WebBrowser.openAuthSessionAsync(checkoutUrl, redirectUrl);
  if (result.type !== "success") return null; // the payer closed the browser

  const { queryParams } = Linking.parse(result.url);
  return typeof queryParams?.payment_id === "string" ? queryParams.payment_id : null;
}
~~~


## The server side of the app

Your server owns the amount, the keys and the confirmation. A minimal version has two endpoints: one that starts the payment and one that tells the app the status of an order.

~~~javascript
// POST /api/checkout  (authenticated by your own login)
const order = await orders.createFor(req.user, req.body.cartId); // the amount is computed here
const response = await fetch("https://api.rais.app/v1/payments", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.RAIS_SECRET_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": `order-${order.id}`,
  },
  body: JSON.stringify({
    amount: order.amount,
    reference: `order-${order.id}`,
    success_url: "myapp://pay/result",
    cancel_url: "myapp://pay/cancelled",
  }),
});
const payment = await response.json();
await orders.attachPayment(order.id, payment.id);
res.json({ checkoutUrl: payment.checkout_url });

// GET /api/orders/:id/status  (what the app asks after the deep link or when it comes back)
// Answer from your database, which the signed webhook updates. If the order is still pending, retrieve the payment.
~~~

## Handle every ending

- **Success deep link.** Treat `status=paid` as a hint to refresh. Ask your server; show the order as paid only when it says so.
- **Cancel deep link.** The payer pressed **Back to**, or the attempt did not go through. The payment may still be `pending`: let them try again by reopening the same `checkout_url` until `expires_at`, and create a new payment when it is no longer `pending`.
- **The payer closes the browser.** You get no deep link. When the app comes back to the foreground, ask your server for the status of the order: the payment may have succeeded.
- **The app is killed during payment.** The webhook still reaches your server. When the app restarts, show the current status of the order.

## Checklist

- No Raïs key, and no key-like string, anywhere in the app.
- The amount is computed on your server, never sent by the app.
- The deep link carries no authority: the order is confirmed by your server, not by `?status=paid`.
- The app handles a closed browser and a killed process.
- You tested with sandbox keys and the [test cards](https://rais.app/docs/testing.md). The sandbox accepts `http://` return addresses for local servers; live payments need `https://` or your app scheme.
