# Sage Pay

- Tool: FlutterFlow
- Difficulty: Advanced
- Time required: 2 hours
- Last updated: July 2026

## TL;DR

Connect FlutterFlow to Sage Pay (now Opayo, owned by Elavon) using an API Call group targeting a Firebase Cloud Function that runs the three-step session flow: create a merchantSessionKey with Basic Auth, tokenize the card to a cardIdentifier via Opayo's hosted fields, then POST /transactions. The Basic Auth credentials must never appear in FlutterFlow — keep them in the Cloud Function. 3-D Secure v2 challenge handling also requires a Cloud Function return endpoint.

## Sage Pay / Opayo in FlutterFlow: The Three-Step Session Flow

Sage Pay rebranded as Opayo after Elavon acquired it, and the platform is primarily used by UK businesses processing GBP and EUR transactions. The REST API is called the Pi API and lives at pi.sagepay.com/api/v1 (production) and pi-test.sagepay.com/api/v1 (test). If you still see 'Sage Pay' in your merchant account, you're on the same platform — just an older branding.

The Pi API uses a distinctive three-step authentication pattern that makes it one of the more complex payment gateways to integrate. Step one: POST to /merchant-session-keys using Basic Auth (your integration key and password, base64-encoded). This returns a short-lived merchantSessionKey, valid for roughly 400 seconds. Step two: the customer's card data is tokenized using that key into a cardIdentifier — Opayo's hosted drop-in form or JavaScript library handles this directly, so raw card numbers never pass through your server. Step three: POST the cardIdentifier plus payment details to /transactions to execute the charge. If the card requires 3-D Secure authentication (mandatory for many UK and EU cards under SCA regulations), Opayo returns a challenge URL and you must redirect the user's browser to complete authentication before the transaction can be authorized.

The security requirement is straightforward: the Basic Auth integration key and password (equivalent to a username/password pair for your merchant account) must never appear in a Flutter app compiled for distribution. Keep them in a Firebase Cloud Function. The merchantSessionKey is similarly sensitive — it's a bearer credential valid for minutes that authorizes card tokenization against your merchant account. The practical integration pattern is: FlutterFlow app calls a Cloud Function (which does the session key and transaction), while card entry is handled in Opayo's hosted drop-in embedded in a Custom Widget WebView. Opayo pricing is plan-dependent and not publicly standardized — check your contract or contact Opayo support for current rates.

## Before you start

- An Opayo (Sage Pay) merchant account — sign up at opayo.co.uk or contact Elavon if migrating from legacy Sage Pay
- Your Opayo integration key (username) and password from the MyOpayo dashboard — these are the Basic Auth credentials
- Test environment access: pi-test.sagepay.com with separate test credentials (not your live credentials)
- A Firebase project on the Blaze (pay-as-you-go) plan for Cloud Functions
- A FlutterFlow project with Firebase connected (Settings → Firebase)

## Step-by-step guide

### 1. Get Opayo integration key, password, and test environment access

Log in to the MyOpayo dashboard at myopayo.com (or Elavon's portal if your account was migrated). Navigate to Settings → Integrations → Pi API. You'll find two sets of credentials:

**Integration Key**: a string that serves as the username in Basic Auth (e.g., 'hcPDGvXVkLFGqoFM...')
**Integration Password**: the corresponding password

These two values combined and base64-encoded form the Basic Auth header: `Basic base64('integrationKey:integrationPassword')`.

For testing, Opayo provides a separate test environment at pi-test.sagepay.com with separate test credentials. Your live credentials do NOT work on the test host, and test credentials do NOT work on the production host — mixing them returns a 401 error. Request test credentials through the MyOpayo portal or Opayo's developer support.

Also note your **Vendor Name** — a short identifier for your merchant account that's required in some Opayo API calls. It's different from the integration key and is visible in your MyOpayo account settings.

Store the integration key and password in Firebase Cloud Function environment variables. Store the vendor name as a FlutterFlow App Value (App Settings → App Values) — it's not a secret by itself, though it should still be treated with care.

**Expected result:** You have test integration key and password from Opayo. You know the test host (pi-test.sagepay.com) and production host (pi.sagepay.com). Vendor name is noted.

### 2. Deploy a Firebase Cloud Function for the three-step Opayo flow

The Cloud Function orchestrates all three Opayo API steps that must happen server-side. It accepts a `cardIdentifier` from FlutterFlow (the tokenized card from the hosted drop-in form, which happens client-side), executes the merchantSessionKey creation for validation, and posts the transaction.

Wait — actually the flow is slightly different from a naive reading of the Opayo docs. The merchantSessionKey is generated server-side (by the Cloud Function) and passed to the hosted drop-in form running client-side. The hosted form uses the merchantSessionKey to tokenize the card into a cardIdentifier and returns the cardIdentifier to your app. Your app then calls the Cloud Function again with the cardIdentifier to submit the transaction.

So the Cloud Function has TWO endpoints:
1. `POST /opayoGetSessionKey` — generates a merchantSessionKey and returns it to FlutterFlow (which passes it to the WebView drop-in form)
2. `POST /opayoProcessPayment` — accepts a cardIdentifier and processes the transaction

Set environment variables:
```
firebase functions:config:set opayo.integration_key='YOUR_KEY' opayo.integration_password='YOUR_PASSWORD' opayo.vendor_name='YOUR_VENDOR' opayo.env='test'
```

Deploy both functions. The code below implements both endpoints in a single `functions/index.js` file. Note that merchantSessionKey expires in approximately 400 seconds — generate it fresh for each checkout session, not once at app startup.

```
const functions = require('firebase-functions');
const axios = require('axios');

function getOpayoConfig() {
  const env = functions.config().opayo.env || 'test';
  const baseUrl = env === 'production'
    ? 'https://pi.sagepay.com/api/v1'
    : 'https://pi-test.sagepay.com/api/v1';
  const key = env === 'production'
    ? functions.config().opayo.live_key
    : functions.config().opayo.integration_key;
  const password = env === 'production'
    ? functions.config().opayo.live_password
    : functions.config().opayo.integration_password;
  const credentials = Buffer.from(`${key}:${password}`).toString('base64');
  return { baseUrl, credentials, vendorName: functions.config().opayo.vendor_name };
}

// Endpoint 1: Generate merchantSessionKey for the hosted drop-in form
exports.opayoGetSessionKey = functions.https.onRequest(async (req, res) => {
  res.set('Access-Control-Allow-Origin', '*');
  if (req.method === 'OPTIONS') {
    res.set('Access-Control-Allow-Methods', 'POST');
    res.set('Access-Control-Allow-Headers', 'Content-Type');
    return res.status(204).send('');
  }

  const { baseUrl, credentials, vendorName } = getOpayoConfig();

  try {
    const response = await axios.post(
      `${baseUrl}/merchant-session-keys`,
      { vendorName },
      {
        headers: {
          'Authorization': `Basic ${credentials}`,
          'Content-Type': 'application/json',
        },
      }
    );
    return res.status(200).json({
      merchantSessionKey: response.data.merchantSessionKey,
      expiry: response.data.expiry,
    });
  } catch (error) {
    const errData = error.response?.data || { error: error.message };
    return res.status(error.response?.status || 500).json(errData);
  }
});

// Endpoint 2: Submit transaction with tokenized cardIdentifier
exports.opayoProcessPayment = functions.https.onRequest(async (req, res) => {
  res.set('Access-Control-Allow-Origin', '*');
  if (req.method === 'OPTIONS') {
    res.set('Access-Control-Allow-Methods', 'POST');
    res.set('Access-Control-Allow-Headers', 'Content-Type');
    return res.status(204).send('');
  }

  const { baseUrl, credentials, vendorName } = getOpayoConfig();
  const { cardIdentifier, merchantSessionKey, amount, currency, description } = req.body;

  if (!cardIdentifier || !merchantSessionKey || !amount) {
    return res.status(400).json({ error: 'Missing required fields' });
  }

  try {
    const response = await axios.post(
      `${baseUrl}/transactions`,
      {
        transactionType: 'Payment',
        paymentMethod: {
          card: {
            merchantSessionKey,
            cardIdentifier,
          },
        },
        vendorTxCode: `FF-${Date.now()}`,
        amount,
        currency: currency || 'GBP',
        description: description || 'Purchase',
        apply3DSecure: 'UseMSPSetting',
        customerFirstName: 'Customer',
        customerLastName: 'Name',
        billingAddress: {
          address1: 'Test Address',
          city: 'London',
          postalCode: 'SW1A 1AA',
          country: 'GB',
        },
        entryMethod: 'Ecommerce',
      },
      {
        headers: {
          'Authorization': `Basic ${credentials}`,
          'Content-Type': 'application/json',
        },
      }
    );
    const data = response.data;
    // Return flat status for FlutterFlow
    return res.status(200).json({
      status: data.status,
      statusCode: data.statusCode,
      transactionId: data.transactionId,
      acsUrl: data.acsUrl || '', // For 3DS challenge
      paReq: data.paReq || '',
    });
  } catch (error) {
    const errData = error.response?.data || { error: error.message };
    return res.status(error.response?.status || 500).json(errData);
  }
});
```

**Expected result:** Firebase Console shows opayoGetSessionKey and opayoProcessPayment functions deployed. Both URLs are ready. Test opayoGetSessionKey with a curl POST and confirm it returns a merchantSessionKey string.

### 3. Embed Opayo's hosted drop-in form in a Custom Widget WebView

Card data must be entered through Opayo's hosted environment to stay within Opayo's PCI DSS compliance boundary. Opayo provides a hosted drop-in form (a JavaScript-based card entry form loaded from Opayo's CDN) that accepts the merchantSessionKey, collects card details from the user, and returns a cardIdentifier — a safe, short-lived token you can send to your Cloud Function.

In FlutterFlow, go to Custom Code → + Add → Widget. Name it 'OpayoDropIn'. Add 'webview_flutter' (4.x) as a dependency. The widget accepts two parameters: `merchantSessionKey` (String) and `onCardTokenized` (callback with String argument for the cardIdentifier).

The WebView loads an HTML page you serve (or host at a URL) that initializes the Opayo drop-in form using the merchantSessionKey. When the form tokenizes the card, it calls a JavaScript interface that your Flutter WebView intercepts and fires the `onCardTokenized` callback.

In practice, the cleanest implementation is a static HTML page hosted on Firebase Hosting (free tier) that contains the Opayo drop-in form initialization code. Your Custom Widget loads this hosted HTML page with the merchantSessionKey appended as a URL parameter. The HTML page reads the key, initializes the drop-in form, and on tokenization, navigates to a custom URL scheme (e.g., `opayo://card-tokenized?cardIdentifier=XYZ`) that the WebView's NavigationDelegate intercepts.

For the test environment, use Opayo's test card numbers available in their developer documentation (login required at developer.sagepay.com). Typical test cards follow the same pattern as production card numbers — the test environment distinguishes them by the test host URL, not the card number format.

```
import 'package:webview_flutter/webview_flutter.dart';

class OpayoDropIn extends FlutterFlowWidget {
  const OpayoDropIn({
    super.key,
    required this.merchantSessionKey,
    required this.dropInPageUrl,
    this.onCardTokenized,
  });

  final String merchantSessionKey;
  // URL of your hosted HTML page containing the Opayo drop-in form
  final String dropInPageUrl;
  final Future<void> Function(String cardIdentifier)? onCardTokenized;

  @override
  State<OpayoDropIn> createState() => _OpayoDropInState();
}

class _OpayoDropInState extends State<OpayoDropIn> {
  late final WebViewController _controller;

  @override
  void initState() {
    super.initState();
    // Append merchantSessionKey as URL param
    final pageUrl = Uri.parse(widget.dropInPageUrl)
        .replace(queryParameters: {'msk': widget.merchantSessionKey})
        .toString();

    _controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setNavigationDelegate(
        NavigationDelegate(
          onNavigationRequest: (request) {
            // Intercept the custom opayo:// scheme from the hosted form
            if (request.url.startsWith('opayo://card-tokenized')) {
              final uri = Uri.parse(request.url);
              final cardIdentifier = uri.queryParameters['cardIdentifier'] ?? '';
              if (cardIdentifier.isNotEmpty) {
                widget.onCardTokenized?.call(cardIdentifier);
              }
              return NavigationDecision.prevent;
            }
            return NavigationDecision.navigate;
          },
        ),
      )
      ..loadRequest(Uri.parse(pageUrl));
  }

  @override
  Widget build(BuildContext context) {
    return WebViewWidget(controller: _controller);
  }
}
```

**Expected result:** OpayoDropIn Custom Widget appears in FlutterFlow's component list. When placed on a checkout screen with a valid merchantSessionKey, it renders Opayo's card entry form. Completing the form fires the onCardTokenized callback with a cardIdentifier string.

### 4. Create FlutterFlow API Calls for both Cloud Function endpoints

In the left nav, click API Calls → + Add → Create API Group. Name it 'OpayoAPI'. Set the base URL to your Firebase Cloud Function URL (e.g. https://us-central1-YOUR_PROJECT_ID.cloudfunctions.net). Leave Authentication as 'None'.

**API Call 1: GetSessionKey**
+ Add API Call → name it 'GetSessionKey' → Method: POST → endpoint: '/opayoGetSessionKey'. No variables needed (the Cloud Function reads everything from its environment). In Response & Test, paste a sample response: `{ "merchantSessionKey": "M1E996F5-A9BC-41FE-B088-E5B73DB94277", "expiry": "2026-07-11T12:00:00.000Z" }`. Generate JSON Paths: `merchantSessionKey` → `$.merchantSessionKey`, `expiry` → `$.expiry`.

**API Call 2: ProcessPayment**
+ Add API Call → name it 'ProcessPayment' → Method: POST → endpoint: '/opayoProcessPayment'. In Body tab (JSON), define variables:
- `cardIdentifier` (String) — from the drop-in form callback
- `merchantSessionKey` (String) — from the GetSessionKey response (must match the one used to tokenize)
- `amount` (Integer) — in pence (GBP) or cents (EUR/USD)
- `currency` (String) — ISO code
- `description` (String) — shown on the customer's statement

Body template:
```
{
  "cardIdentifier": "{{ cardIdentifier }}",
  "merchantSessionKey": "{{ merchantSessionKey }}",
  "amount": {{ amount }},
  "currency": "{{ currency }}",
  "description": "{{ description }}"
}
```

In Response & Test, paste: `{ "status": "Ok", "statusCode": "0000", "transactionId": "OP-TXXX", "acsUrl": "", "paReq": "" }`. Generate JSON Paths: `status` → `$.status`, `statusCode` → `$.statusCode`, `transactionId` → `$.transactionId`, `acsUrl` → `$.acsUrl`.

The `acsUrl` field is the 3-D Secure challenge URL — it will be empty for non-3DS transactions and populated with a URL when the card requires a 3DS challenge.

```
{
  "cardIdentifier": "{{ cardIdentifier }}",
  "merchantSessionKey": "{{ merchantSessionKey }}",
  "amount": {{ amount }},
  "currency": "{{ currency }}",
  "description": "{{ description }}"
}
```

**Expected result:** OpayoAPI group has two API Calls: GetSessionKey (POST, no body) and ProcessPayment (POST, five body variables). JSON Paths configured for both. API Call tests return 200 with parsed response fields.

### 5. Build the full checkout Action Flow including 3-D Secure handling

The checkout flow requires coordinating four phases: get the session key, collect the card (WebView), submit payment, and handle 3DS if required. Wire these in the FlutterFlow Action Flow on your checkout trigger button.

**Phase 1 — Get session key**: On the 'Pay' button, add a Backend API Call → OpayoAPI → GetSessionKey. Store the response `merchantSessionKey` in a page state variable (String, name: `opayoMSK`) and `expiry` in another variable. If the call fails, show a Snack Bar and stop.

**Phase 2 — Collect card**: After GetSessionKey succeeds, navigate to a 'Card Entry' screen that contains the OpayoDropIn Custom Widget, configured with the `opayoMSK` page state. When the widget fires `onCardTokenized`, store the cardIdentifier in page state (`opayoCardId`), then navigate back or to the payment processing screen.

**Phase 3 — Submit payment**: On the payment screen, automatically trigger (via an init action or a Continue button) the ProcessPayment API Call, passing `opayoCardId`, `opayoMSK`, and the payment amount and currency from page state.

**Phase 4 — Handle outcome**:
- If `status == 'Ok'` and `statusCode == '0000'`: payment authorized. Write to Firestore (orders collection: userId, amount, transactionId, status 'authorized', createdAt) and navigate to confirmation.
- If `acsUrl` is non-empty: 3-D Secure challenge required. Open a WebView with the `acsUrl`. After the user completes 3DS authentication, Opayo sends a callback to your configured 3DS callback URL (a Firebase Cloud Function). That function calls Opayo's /3ds-challenge endpoint and updates Firestore. Show a 'Pending 3DS verification' screen while polling Firestore for the status update.
- If `status == 'NotAuthed'` or other failure codes: show a 'Payment declined — please try a different card' Snack Bar and clear the page state.

3-D Secure v2 is mandatory for many UK and EU card transactions under SCA (Strong Customer Authentication) rules — skipping it leads to high decline rates from European issuing banks.

**Expected result:** Full checkout flow works on device: session key fetched, card entry form appears, cardIdentifier returned, payment submitted, Firestore order created for successful transactions. 3DS-required transactions show the challenge WebView.

### 6. Test with Opayo test cards and validate the 3DS path

Opayo's test environment at pi-test.sagepay.com uses specific test card numbers that simulate different outcomes. Access the test card list from the Opayo developer portal (login required at developer.sagepay.com) — they provide cards for authenticated 3DS success, 3DS challenge flows, declines, and fraud blocks.

Common test scenarios to verify:

**Successful payment (no 3DS)**: Enter a test card that bypasses 3DS. The `status` response should be 'Ok' with statusCode '0000'. Confirm the Firestore order document is created.

**3DS challenge required**: Enter a test card that triggers 3DS. The ProcessPayment response returns an `acsUrl`. Verify your Action Flow opens the URL in a WebView. Complete the mock 3DS authentication (Opayo test environment uses a simplified mock 3DS form). Confirm the 3DS callback Cloud Function fires and updates Firestore.

**Decline**: Enter Opayo's test decline card. The response should return `status: 'NotAuthed'` or similar. Confirm the FlutterFlow Action Flow shows the decline Snack Bar.

**merchantSessionKey expiry**: Set the `expiry` variable threshold to the current time minus 1 second (to simulate an expired key), then attempt tokenization. The drop-in form should return an error — your Action Flow should catch this by re-calling GetSessionKey before retry.

For production: change `opayo.env` to 'production', update the live integration key and password in Firebase config, set your live vendor name, and redeploy. Run one live test transaction at the minimum amount before full rollout.

**Expected result:** Test payments succeed in the Opayo test environment. 3DS challenge flow completes and updates Firestore. Declined cards trigger the failure branch. All scenarios tested before production credentials are set.

## Best practices

- Never put Opayo integration key or password in a FlutterFlow API Call header — use Firebase Cloud Function environment variables and the Basic Auth header inside the function.
- Generate a fresh merchantSessionKey for each checkout session — per-session generation, not per-app-start. The key expires in ~400 seconds and is bound to the card tokenization.
- Always implement the 3-D Secure challenge path — UK and EU cards under SCA regulations require 3DS authentication, and skipping it causes high decline rates rather than a straightforward error.
- Use Opayo's hosted drop-in form via a WebView Custom Widget for card entry to keep raw card data within Opayo's PCI-certified environment and outside your Flutter app's widget tree.
- Match the merchantSessionKey between the drop-in tokenization step and the ProcessPayment call — a mismatch causes a misleading 'invalid cardIdentifier' error that's hard to debug.
- Add a session expiry timer on the checkout screen: warn users who have been idle for 5+ minutes that their payment session will expire and offer to refresh the session key.
- Store the transactionId returned by Opayo in your Firestore order document immediately after authorization — this is the reference number needed for refunds, voids, and dispute resolution.
- Test both the 3DS and non-3DS card paths explicitly in the test environment before going live — different cards trigger different flows and the UI differs significantly between them.

## Use cases

### UK e-commerce app for physical or digital goods

A retail FlutterFlow app serving UK customers uses Opayo to process card payments. The checkout screen opens Opayo's hosted drop-in form in a WebView, the customer enters card details, the drop-in tokenizes the card, and the Cloud Function submits the transaction. 3-D Secure authentication happens inside the WebView for SCA-compliant flow.

Prompt example:

```
A UK online shop app where customers browse products, add to basket, and pay by card — card entry through Opayo's secure hosted form, 3DS challenge handled in-app, order confirmed after transaction succeeds.
```

### Subscription service for UK members

A membership or subscription FlutterFlow app where UK users set up a recurring payment. The initial card tokenization via Opayo returns a reusable token (with appropriate permissions from Opayo), and subsequent charges are triggered by the Cloud Function using the stored token — no card re-entry required for renewals.

Prompt example:

```
A gym membership app where users sign up and pay £20/month — card tokenized on first signup, monthly charges triggered automatically via the Cloud Function with the stored Opayo token.
```

### B2B invoice payment portal in a FlutterFlow app

A trade supplier's FlutterFlow app lets business customers view outstanding invoices and pay by card. The Cloud Function creates a merchantSessionKey per payment session, the customer enters card details in the drop-in WebView, and the transaction is submitted with the invoice reference as a custom field in the Opayo request body.

Prompt example:

```
A supplier portal app listing open invoices with a 'Pay Now' button — clicking opens the Opayo drop-in form in-app, and the payment is linked to the invoice number in Firestore on success.
```

## Troubleshooting

### 401 Unauthorized when calling /merchant-session-keys

Cause: The integration key and password are incorrect, swapped, or belong to a different environment — test credentials on the production host (pi.sagepay.com) or live credentials on the test host (pi-test.sagepay.com) both return 401.

Solution: Verify in Firebase config that the key and password match the environment set by opayo.env. Double-check by running 'firebase functions:config:get' in the terminal. Also confirm the Basic Auth encoding: the header must be 'Basic ' followed by base64('integrationKey:integrationPassword') — not just base64 of the key alone.

```
const credentials = Buffer.from(`${key}:${password}`).toString('base64');
// Header: 'Authorization': `Basic ${credentials}`
```

### cardIdentifier rejected with 'invalid or expired' error during transaction submission

Cause: The merchantSessionKey used to tokenize the card (in the drop-in form) and the merchantSessionKey passed to the ProcessPayment call are different, or the key expired (>400 seconds after creation) before the transaction was submitted.

Solution: Ensure you're passing the SAME merchantSessionKey to both the OpayoDropIn widget and the ProcessPayment API Call. Store it in a page state variable set from the GetSessionKey response, and bind that same variable to both. If the user takes more than 5 minutes on the card entry screen, re-fetch the session key before submitting.

### Transaction declined with SCA/3DS failure — high decline rate from UK/EU cards

Cause: The apply3DSecure field in the transaction request was omitted or set incorrectly, or the 3-D Secure ACS challenge was not completed before the transaction was submitted.

Solution: Set apply3DSecure to 'UseMSPSetting' to honor your merchant account's 3DS configuration. If `acsUrl` is returned in the ProcessPayment response, you MUST open it in a WebView and let the user complete the 3DS authentication before proceeding. Skipping 3DS for EU/UK cards under SCA regulations results in mandatory decline by the issuing bank.

```
apply3DSecure: 'UseMSPSetting',
```

### MissingPluginException or blank WebView when OpayoDropIn Custom Widget is shown

Cause: The webview_flutter native plugin wasn't compiled into the build being tested. Custom Widgets using native plugins don't work in FlutterFlow's web Test/Run mode or in the standard Companion app.

Solution: Download the Flutter project code from FlutterFlow (Developer Tools → Download Code → Flutter Project) and run it from Xcode or Android Studio on a simulator or real device. This compiles all Custom Widget dependencies including webview_flutter.

## Frequently asked questions

### Is Sage Pay the same as Opayo? What URL should I use?

Yes — Sage Pay was acquired by Elavon and rebranded as Opayo in 2021. The platform, API, and merchant accounts are the same. The Pi API lives at pi.sagepay.com (production) and pi-test.sagepay.com (test) — Opayo has not migrated these URLs to opayo.co.uk as of 2026, so the 'sagepay.com' domain is still the correct API host. The merchant portal is at myopayo.com.

### Can I use Opayo/Sage Pay outside the UK?

Opayo is primarily a UK and Ireland payment gateway with GBP and EUR support. It's owned by Elavon, which operates globally, but Opayo specifically is designed for UK merchant acquiring. If you're building for a non-UK market, Stripe or Adyen are more appropriate choices. If you have an existing Opayo contract and are serving international customers, confirm with your Opayo account manager which currencies and countries are supported under your specific agreement.

### Why does the merchantSessionKey need to match between card tokenization and transaction submission?

Opayo cryptographically binds the cardIdentifier (the tokenized card) to the specific merchantSessionKey that was used during tokenization. This binding prevents a tokenized card from being charged by a different merchant session or a different merchant account. If you generate a new merchantSessionKey after tokenization and use it in the transaction call, Opayo sees the cardIdentifier as belonging to a different session and rejects it with an authentication error.

### What is 3-D Secure v2 and do I need to implement it?

3-D Secure v2 (3DS2) is an authentication standard required by the EU's Payment Services Directive 2 (PSD2) for Strong Customer Authentication (SCA). For UK and EU card transactions, many issuing banks require 3DS authentication before authorizing a payment. If your Opayo transaction request returns an `acsUrl`, the card requires a 3DS challenge — you must open that URL in a WebView, let the user complete authentication (typically a biometric or OTP confirmation in their banking app), and then confirm the transaction. Skipping this step causes mandatory decline from the issuing bank, not an Opayo-level error.

### How do I process refunds through Opayo from a FlutterFlow app?

Refunds in Opayo are called 'Refund' transaction types. Add a third Cloud Function endpoint that accepts a transactionId (the original payment's ID stored in Firestore) and amount, then POSTs to Opayo's /transactions endpoint with transactionType 'Refund' and the relatedTransactionId field set to the original transactionId. Restrict refund access to admin users in your FlutterFlow app using Firestore Security Rules and a role check. Never allow end-user-initiated refunds without server-side validation of the request.

---

Source: https://www.rapidevelopers.com/flutterflow-integrations/sage-pay
© RapidDev — https://www.rapidevelopers.com/flutterflow-integrations/sage-pay
