SpareSpare Docs
GuidesAPI Reference

Flutter

Add Spare's open banking payment SDK to a Flutter app for iOS and Android in the UAE.

The Flutter SDK (spare_link) runs the Spare Link payment flow inside your iOS and Android app. Your backend creates a payment request; the SDK shows bank selection, consent, and the bank redirect, then hands you the result to confirm on your server. It makes no API calls and holds no credentials.

Flutter

flutter pub add spare_link

Available now

This SDK is published on pub.dev (1.0.0). The install command above is live. Pin an exact version in pubspec.yaml, and read the release notes before you upgrade. It serves the UAE only.

Function of the SDK

The SDK shows the payment flow to the payer. It sends the payer to their bank. Then it tells you the result.

  1. Your backend makes a payment request. The backend gives the reference to the app.
  2. Your app calls SpareLink.start(reference: ...). The SDK shows the payment flow.
  3. The payer selects a bank, checks the payee and gives consent.
  4. The SDK opens the bank in a browser. It uses a Custom Tab on Android and SFSafariViewController on iOS.
  5. Your app sends the return deep link to SpareLink.handleRedirect().
  6. Your backend reads the result with GET /payment-requests/{reference}.

The SDK opens the bank in a browser for two reasons. Banks usually refuse to authorize a payment inside an app. The address bar also helps the payer to identify a correct bank page.

The SDK makes no API calls. It holds no credentials. Thus it can tell you only what the payment flow and the deep link tell it. This is why step 6 is necessary.


Prerequisites

Do these tasks before you call SpareLink.start() on a device.

Backend

ItemReason
Keep the Spare API credentials on your serverThe app holds no credentials and does not call Spare
POST /payment-requests with an x-signature headerIt gives the reference for start(). Read Signature of the payload
channel: 'sdk_flutter' in the requestIt selects the return path. Without it, the payer stops at a receipt page in the browser
successRedirectUrl and failureRedirectUrl in the requestThey must agree with the scheme of your app
GET /payment-requests/{reference}This is the correct result. It is necessary. Read Confirmation of a result
GET /payment-requests/{reference} before you fulfil an orderThe deep link to your app has no signature. Thus the record is the only proof
Idempotent fulfilment by merchantReferenceA payer can try again. The recovery function can also report the same payment two times

Spare platform

ItemReason
Access to the sandbox environment, then to productionThese agree with the PaymentEnvironment values in the SDK
The UAE tenantThe payment flow serves the UAE only. Another tenant causes an error

Flutter app

ItemReason
Flutter 3.22 or later, Dart 3.12 or laterThese are the minimum versions
A deep link in AndroidManifest.xml and in Info.plistThe return from the bank must open your app
A call to handleRedirect() for each URIIf you do not do this, the flow stops after the bank
A call to resumeIfNeeded() at each start of the appIt reports a payment that did not complete
One SpareLink object for each environment and tenantThe events, the active flow and the recovery use one object
A NavigatorStatestart() puts a full-screen route on it

Prohibited in the app

Do not put these items in the app:

  • Spare API keys
  • Private keys for signatures
  • Bearer tokens that call Spare from the device

Signature of the payload

POST /payment-requests needs a detached ES256 JWS in the x-signature header. The signature shows that your backend controls the request.

WARNING: Make the signature on your server. Do not put the private key in the Flutter app.

Object to sign

Sign the request object only. Do not sign the full HTTP body.

{
  "user_ref": "optional",
  "provider_id": "optional",
  "request": { "...": "sign this object" }
}

Give the request object to your signature function. Send the full body as the JSON body.

Algorithm and key

ItemValue
AlgorithmES256. This is ECDSA with the P-256 curve and SHA-256
Private keyA PKCS8 PEM key from the Spare dashboard. It starts with -----BEGIN PRIVATE KEY-----
Header formatA detached JWS: base64url({"alg":"ES256"})..base64url(signature)
HTTP headerx-signature: <detached-jws>

A detached JWS has an empty payload segment. Thus it has two dots: header..signature. You use the canonical JSON bytes to calculate the signature. You do not put these bytes in the JWS.

Canonical serialization

The server compares the signature with this exact string. If one byte is different, the server sends a 401 error.

  1. Sort the keys of each object into alphabetical order. Do this at each level.
  2. Remove the fields that are null or undefined.
  3. If a top-level value is a number, round it to 5 decimal places. Then remove the zeros at the end. For example, 1000.00 becomes "1000", and 1000.5 becomes "1000.5".
  4. Keep the numbers inside objects as JSON numbers. This is the usual result of jsonEncode.
  5. Serialize each element of an array. Then put the elements in a JSON array.

Steps to make the signature

1. Make the payment request object (the `request` field).
2. canonical = serializePayload(request)   // the rules above
3. headerB64 = base64url('{"alg":"ES256"}')
4. payloadB64 = base64url(utf8(canonical))
5. signingInput = ascii(headerB64 + '.' + payloadB64)
6. signature = ECDSA-P256-SHA256(signingInput, privateKey)
7. x-signature = headerB64 + '..' + base64url(signature)   // detached

The rules above are the complete scheme. For the request contract, see the API Reference.

Example of a request

POST /payment-requests HTTP/1.1
Authorization: Bearer <access_token>
Content-Type: application/json
X-CLIENT-ID: <app-uuid>
X-Tenant: UAE
x-signature: eyJhbGciOiJFUzI1NiJ9..<signature>

{
  "request": {
    "type": "SingleInstantPayment",
    "channel": "sdk_flutter",
    "creditorType": "MERCHANT",
    "debtorReference": "debtorRef2",
    "creditorReference": "creditorRef",
    "creditorAccount": {
      "schemeName": "IBAN",
      "identification": "10000109010101",
      "name": "Mario International"
    },
    "purpose": "ACM",
    "merchantReference": "order12345",
    "successRedirectUrl": "myapp://payment-return",
    "failureRedirectUrl": "myapp://payment-return",
    "instructions": {
      "isInternationalPayment": false,
      "amount": { "amount": "1.01", "currency": "AED" }
    }
  }
}

Send the reference from the response to the app. The record also shows this value as internalReference. The SDK needs no other data.

Usual errors

ErrorResult
You do not send channel: 'sdk_flutter'The payment completes. But the payer stops at a receipt page in the browser, and the app gets PaymentAbandoned
You sign the full body and not the request objectThe server refuses the signature
You use jsonEncode and do not sort the keysThe server refuses some signatures
You put the payload in the JWSThe server refuses the signature
The redirect URLs do not agree with the scheme of the appThe return from the bank does not open your app
You make the signature in the Flutter appThe keys are visible in the binary file

Setup

1. Add the package

dependencies:
  spare_link: ^1.0.0
flutter pub add spare_link

This is version 1.0.0, the first major release of the SDK. Set an exact version in your pubspec.yaml.

Android Gradle Plugin 9

The SDK uses flutter_inappwebview to show the payment page. Version 6.1.5 of that package names proguard-android.txt in its Android build. Android Gradle Plugin 9 removed that file. Your Android build fails if you use Android Gradle Plugin 9.

To correct this, add an override to your pubspec.yaml:

dependency_overrides:
  flutter_inappwebview: ^6.2.0-beta.3

Remove the override when version 6.2.0 of flutter_inappwebview becomes stable.

Add this intent-filter to AndroidManifest.xml:

<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="payment-return" />
</intent-filter>

Add this key to Info.plist:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>myapp</string>
    </array>
  </dict>
</array>

Use the same scheme and host that your backend puts in successRedirectUrl and failureRedirectUrl. Spare registers no deep links for you.

3. Make the SDK object

Make one object. Subscribe to the events one time. Call the recovery function after the first frame.

final rootNavigatorKey = GlobalKey<NavigatorState>();

late final SpareLink spareLink;

@override
void initState() {
  super.initState();
  spareLink = SpareLink(
    environment: PaymentEnvironment.sandbox,
    tenant: SpareApiTenant.uae,
  );
  spareLink.events.listen(_onSpareLinkEvent); // for analytics only
  WidgetsBinding.instance.addObserver(this);
  WidgetsBinding.instance.addPostFrameCallback((_) {
    _handleRecovery(spareLink.resumeIfNeeded());
  });
}

The constructor checks the configuration. It causes an error for a baseUrl that is not a URL, and for a tenant that the payment flow cannot serve. Thus you find a bad value immediately, and not later as a payment that does not open.

Send each URI to the SDK. The SDK tells you if the URI is a payment return.

@override
Future<bool> didPushRouteInformation(RouteInformation info) async {
  if (await spareLink.handleRedirect(info.uri)) return true;
  return super.didPushRouteInformation(info);
}

If handleRedirect returns true, the URI is a payment return. Do not send it to your own router. The SDK keeps a return that arrives too early. Then start() or resumeIfNeeded() uses it.

5. Start a payment

Future<void> pay(String reference) async {
  final session = await spareLink.start(
    navigator: rootNavigatorKey.currentState!,
    reference: reference, // from your backend
  );

  switch (await session.result) {
    case PaymentSuccess(:final reference):
      // Confirm with your backend first. Do not fulfil the order here.
      final record = await backend.getPaymentRequest(reference);
      // `Scheduled` is also settled. The bank accepted a payment for a later date.
      // The record also holds the paymentId. The deep link to your app does not.
      if (record.status case 'Processed' || 'Scheduled') {
        await fulfil(reference, record.paymentId);
      }

    case PaymentCancelled():
      // The payer stopped. The server rejects the request.

    case PaymentFailed(:final errorCode, :final message):
      // Show a message to the payer. Record the errorCode.

    case PaymentAbandoned(:final reference):
      // The result is unknown. The payment can be complete. Ask your backend.
      await backend.getPaymentRequest(reference);
  }
}

Get the reference when the payer selects the pay button. Make a new payment request after each cancellation, failure or confirmed success. You cannot use a reference two times.

6. Add the recovery function

final recovery = await spareLink.resumeIfNeeded();

switch (recovery) {
  case SessionRecovered(:final result):
    // The return arrived before the flow started. The SDK read the result from it.
  case NoRecoveryNeeded(:final reason, :final reference):
    if (reason == NoRecoveryReason.outcomeUnknown && reference != null) {
      // A payment was in progress. No return arrived. The payment can be complete.
      await backend.getPaymentRequest(reference);
    }
}

NoRecoveryReason has four values: noPendingSession, flowAlreadyActive, persistenceUnavailable and outcomeUnknown. Only outcomeUnknown needs an action from you.


The SDK reads the result from the URI. This is the URI in your successRedirectUrl and failureRedirectUrl.

Your app gets a deep link. A browser gets an https redirect. Both carry the same parameters with the same names and the same values. Only one thing differs: the deep link has no signature.

This is an example of a success:

myapp://payment-return?reference=6G0TcjUlQDE&status=Processed
  &paymentId=537f72c3&merchantRef=order12345

This is an example of a failure:

myapp://payment-return?reference=6381GLdXnPQ&errorCode=SP500&status=Rejected
  &merchantRef=order12345

The deep link to your app has no sig parameter. Thus you cannot verify it. Read the record from your backend. See Confirmation of a result.

ParameterDart fieldFunction
referencereferenceThe payment request. The SDK compares it with the active payment. It ignores a return with a different value
status-The status of the payment request. It is always present, but it can be empty. Read Statuses
errorCodeerrorCodeThe failure code. It is on each failure and on no success. Read The failure code
paymentIdpaymentIdThe Spare identifier of the payment. It is on the success of a single payment
mandateIdmandateIdThe Spare identifier of the mandate. It is on the success of a mandate. A return has this or paymentId, never both
merchantRefmerchantRefYour own reference, if the request recorded one
sigsignatureAn ES256 JWS of the full return URL. The https redirect only. The SDK does not verify it

WARNING: Use errorCode to tell a failure from a success. Do not use the status value, because it can be empty. The service puts errorCode on each failure and on no success.

Older builds of the service send status=success or status=failure, and send the failure code as error_code. The SDK reads these values too.

Statuses

The status parameter holds the status of the payment request. Each value is in one of three groups. The first group goes to your successRedirectUrl. All other values go to your failureRedirectUrl. Thus you must register both URLs, and both URLs must open your app.

GroupValuesPaymentResult
SettledProcessed, ScheduledPaymentSuccess
Terminal failureRejected, ErroredPaymentFailed
In progressNew, Consumed, ConsentRaisedPaymentAbandoned

Scheduled is a success. The bank accepted a payment for a later date. The money did not move, but the payment is correct. If your fulfilment needs the money in the account, read the status from your backend. Do not use PaymentSuccess only.

The values in the third group are not results. The payer can still pay a request with one of these values. Thus a return must not have one of them. If the SDK finds one, it reports PaymentAbandoned. Then you must ask your backend.

The SDK does the same for a value that it does not know, and for an empty value. Spare can add new values. If the SDK reads an unknown value as a success, it can tell a payer that the money moved when the money did not move.

An empty status with an errorCode is a failure. The SDK reads it as one.

The failure code

errorCode is on each failure and on no success. Thus it is what tells the two apart, and it is more reliable than status, which can be empty.

The value today is always SP500. Spare can add more codes. Use the code for your messages. Do not use it to find the result.

WARNING: A payment request also has its own status. Read GET /payment-requests/{reference} from your backend before you fulfil an order. The deep link tells you what to do next. It does not prove what happened.

Older builds of the service send the code as error_code. The SDK reads that name too, and reports the value as errorCode.


Confirmation of a result

The SDK reads each result from a deep link. A deep link is not proof. The operating system sends the deep link to your app. Each installed app can register the same custom scheme. The deep link has a reference, a result and your own merchant reference. It has no bank identifiers and no confidential data.

WARNING: Do not fulfil an order after a PaymentSuccess result. First confirm the payment with your backend.

Your backend must use its own credentials and read the record:

GET /payment-requests/{reference}

The deep link to your app has no sig parameter. Thus there is nothing on it to verify, and the record is the only way to know what happened.

The https redirect does have sig. It is an ES256 JWS of the full return URL with all the parameters, and a different app cannot make it. The SDK does not verify sig. A verification needs the Spare public key, and the SDK does not hold this key. Verify it on your backend if you receive it.

Two more conditions make this step necessary:

  • PaymentAbandoned does not mean that the payment failed. A payer can authorize the payment at the bank and then close the app.
  • NoRecoveryReason.outcomeUnknown is the same condition after a restart of the app. The SDK holds no credentials. Thus it cannot ask Spare.

Error codes

PaymentFailed.errorCode always has a value. The value is an SDK error code from the table below, or an error key from the return.

CodeMeaningResult for the payer
page_unavailableThe SDK could not open the payment flow, or the flow did not become usableThe payer saw a message and a retry button. Then the payer stopped. The SDK started no payment
page_lostThe payment flow worked and then failedThe flow stopped before the bank
redirect_failedThe SDK could not open the bank URL in a browserA consent exists, but no browser opened
authorize_failedThe return reports a failure with no error keyThe payer came back from the bank with no success
concurrent_startYour app called start() during an active paymentNothing. Disable your pay button

page_unavailable is not always terminal. If the SDK cannot open the payment flow, it tells the payer that it started no payment. It also shows a retry button. The flow gives a failure only if the payer stops.


Environments

PaymentEnvironmentHost
sandboxhttps://api.sandbox.tryspare.ae
productionhttps://api.tryspare.ae

Do your tests in sandbox. Then change this one value. If Spare gives you a different host, put it in baseUrl.


Checklist before the first payment

Do these checks before your first payment in the sandbox on a device.

Backend

  • The x-signature header has a canonical JSON and ES256 detached JWS signature of the request object
  • Each request has channel: 'sdk_flutter'
  • successRedirectUrl and failureRedirectUrl agree with the scheme of the app
  • GET /payment-requests/{reference} operates, and the fulfilment needs a settled status (Processed or Scheduled)
  • The fulfilment is idempotent by merchantReference

Native

  • The Android intent-filter and the iOS URL scheme agree with the redirect URLs
  • The redirect URL opens your app from a browser

Dart

  • Your app makes one SpareLink object for each environment and tenant
  • handleRedirect gets each URI
  • resumeIfNeeded operates at each start of the app, and your app uses outcomeUnknown
  • Your app processes the four PaymentResult types
  • Your app disables the pay button during a payment

Identifiers

FieldSourceYour action
referenceYour backend, from POST /payment-requestsGive it to start(). Read the result with it
paymentIdThe return, on the success of a single paymentRecord it with the order
mandateIdThe return, on the success of a mandateRecord it with the order. A return has this or paymentId, never both
signature (sig)The https redirect only. It is null in your appVerify it on your backend with the Spare public key
merchantRefYour own order referenceUse it for an idempotent fulfilment

Sequence of the integration

  1. Make and read payment requests in the sandbox from your backend. Confirm that the signature operates.
  2. Add the package to the app. Register the deep links. Make the SpareLink object.
  3. Add handleRedirect and resumeIfNeeded.
  4. Do a full payment in the sandbox on a device. Include the confirmation from your backend.
  5. Set an exact version of the SDK in pubspec.yaml.

On this page