spare_link 1.0.0 copy "spare_link: ^1.0.0" to clipboard
spare_link: ^1.0.0 copied to clipboard

Flutter SDK for Spare Link open-banking payments. Presents the payment flow, hands bank authorization to a real browser, and reports the payer back through your deep link.

spare_link #

pub package License: BSD-3-Clause

Flutter SDK for Spare Link — open-banking payments for iOS and Android.

How it works #

  1. Your backend creates the payment request (POST /payment-requests) with channel: 'sdk_flutter' and the return URLs your app is registered for, then gives the app the internalReference it returns.
  2. The SDK presents the payment flow for that reference. The payer selects a bank, checks the payee and gives consent.
  3. When a consent exists, the SDK opens the bank in a Custom Tab / SFSafariViewController. Banks commonly refuse to serve authorization pages inside an embedded view, and the address bar lets the payer check they are on a real bank page.
  4. The bank returns, Spare posts the payment, and the payer is deep-linked back into your app.
  5. Your backend reads the result with GET /payment-requests/{reference}.

Step 5 is required, not optional — see Confirming the result.

The SDK makes no API calls and holds no credentials. It reports only what the payment flow and the return deep link tell it.

Features #

  • Full-screen payment flow launched with a single await
  • Single, cross-border and mandate payments
  • Bank authorization in a real browser, never in the app's own view
  • Deep-link return, with a return that arrives before the app is ready held rather than lost
  • Sealed PaymentResult type for exhaustive handling
  • Stream<SpareLinkEvent> for analytics
  • SpareLink.test() constructor for unit testing with injectable dependencies

Installation #

dependencies:
  spare_link: ^1.0.0

Android Gradle Plugin 9 #

The payment page is hosted by flutter_inappwebview. Its stable line (6.1.5) references proguard-android.txt in the Android build, and AGP 9 removed that file, so an app on AGP 9 fails to build. If that affects you, override the dependency until flutter_inappwebview 6.2.0 is stable:

dependency_overrides:
  flutter_inappwebview: ^6.2.0-beta.3

Platform setup #

Register the scheme your return URLs use. Spare registers nothing on your behalf — the return URLs are whatever your backend recorded on the payment request.

Android #

<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>

iOS #

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

Environments #

PaymentEnvironment Host
sandbox https://api.sandbox.tryspare.ae
production https://api.tryspare.ae

Pass baseUrl to override the host. The payment flow currently serves UAE only; constructing with another tenant throws instead of showing the payer another country's flow.

Usage #

Create one SpareLink instance per environment and reuse it for every payment:

import 'package:spare_link/spare_link.dart';

final spareLink = SpareLink(
  environment: PaymentEnvironment.sandbox,
  tenant: SpareApiTenant.uae,
);

spareLink.events.listen((event) {
  switch (event) {
    case SpareLinkRedirectToBank(:final reference):
      debugPrint('opening the bank for $reference');
    case SpareLinkSucceeded(:final result):
      debugPrint('succeeded: ${result.reference}');
    default:
      break;
  }
});

Start a payment #

final session = await spareLink.start(
  navigator: Navigator.of(context),
  reference: reference, // from your backend
);

final result = await session.result;

switch (result) {
  case PaymentSuccess(:final reference, :final paymentId):
    // Confirm with your backend, then fulfil
  case PaymentCancelled():
    // The payer cancelled; the request is already rejected server-side
  case PaymentFailed(:final errorCode):
    // Show the payer something useful
  case PaymentAbandoned(:final reference):
    // Outcome unknown — ask your backend. See below.
}

A second start() during an active payment resolves as PaymentFailed with concurrent_start, so disable your pay button while a flow is in progress.

Forward every incoming URI. The SDK reports whether it was one of ours:

if (await spareLink.handleRedirect(uri)) return true;

A WidgetsBindingObserver is the usual place:

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

After a cold start #

If the app was killed mid-payment, call this once during startup:

final recovery = await spareLink.resumeIfNeeded();

switch (recovery) {
  case SessionRecovered(:final result):
    debugPrint('recovered: ${result.status.name}');
  case NoRecoveryNeeded(:final reason, :final reference):
    if (reason == NoRecoveryReason.outcomeUnknown) {
      // A payment was pending and no return arrived. Ask your backend about
      // `reference` — it may have completed.
    }
}

The other NoRecoveryReason values — noPendingSession, flowAlreadyActive, persistenceUnavailable — need no action.

Payment statuses #

Each status belongs to one of three groups. Only the first group is routed to your successRedirectUrl; every other status is routed to your failureRedirectUrl, so both URLs must be registered and both must open your app.

Group Statuses PaymentResult
Settled Processed, Scheduled PaymentSuccess
Terminal failure Rejected, Errored PaymentFailed
Still in flight New, Consumed, ConsentRaised PaymentAbandoned

Scheduled is a success: the bank has accepted a payment dated in the future, and the money has not moved yet. If your fulfilment requires settled funds, check the status you read back from your backend rather than PaymentSuccess alone.

The statuses in the third group are not outcomes — a request with one of them is still payable, so it should not appear on a return. If one does, the SDK reports PaymentAbandoned and your backend resolves it. The SDK does the same for a status it does not recognise, because reporting an unknown status as a success would tell a payer their money moved when it may not have.

status is always sent but can be empty, so errorCode is the reliable discriminator: it is present on every failure and on no success. Its value today is always SP500; use it for your own copy rather than to determine the outcome. An empty status alongside an errorCode is a failure, and the SDK reads it as one.

Older builds of the service sent status=success / status=failure and the code as error_code. The SDK still reads those, so an environment that has not been updated does not misreport.

Confirming the result #

Every result this SDK reports is read from a deep link. A deep link is not proof: the OS hands it over, any installed app can register the same custom scheme, and it deliberately carries nothing confidential — a reference, an outcome, and your own merchant reference. It names no bank identifiers.

The deep link an app receives is not signed, deliberately. Any installed app can claim a custom scheme, so a signature on a deep link would be a proof handed to whoever intercepted it. There is nothing on it to verify, and reading the record is the only way to establish what happened:

GET /payment-requests/{reference}

called by your backend with its own credentials. That is the authoritative record.

The deep link does carry paymentId on a single payment's success, or mandateId on a mandate's — record whichever is set. signature is populated only on Spare's https redirect, so expect it to be null in an app. When it is present it is an ES256 JWS over the return URL, and the SDK does not verify it: that requires Spare's public key, which the SDK does not hold.

Two cases make this unavoidable rather than merely advisable:

  • PaymentAbandoned does not mean the payment did not happen. A payer who authorized at their bank and then closed the app before returning lands here, and the payment may well have gone through.
  • NoRecoveryReason.outcomeUnknown is the same situation across an app restart. The SDK holds no credentials, so it has nothing to ask with. Only your backend can resolve it.

Error codes #

PaymentFailed.errorCode is always set — either one of the SDK's own codes or whatever the return carried.

Code Meaning
page_unavailable The payment flow could not be reached, or never became usable. The payer was told no payment had been taken, offered a retry, and gave up
page_lost The flow was working and then failed, before the bank
redirect_failed The bank URL could not be opened in a browser
authorize_failed The return reported a failure without naming a key
concurrent_start start() was called while a payment was already in flight

page_unavailable is not terminal on its own. When the flow cannot be reached the payer is offered a retry, and it resolves as a failure only if they give up.

Using with go_router #

Works with MaterialApp.router and go_router unchanged.

  1. Call spareLink.start(navigator:, reference:) from any widget with a NavigatorState.
  2. Forward return deep links to spareLink.handleRedirect(uri).
  3. Call spareLink.resumeIfNeeded() on startup.

Identifier glossary #

Dart field Wire Meaning
reference reference, internalReference The payment request. What your backend reads the result by
merchantRef merchantRef, merchantReference Your own reference, as recorded on the payment request
paymentId paymentId Spare's identifier for the payment. On a single payment's success
mandateId mandateId Spare's identifier for the mandate. On a mandate's success — never both
signature sig ES256 JWS over the return URL. The https redirect only; null in an app
errorCode errorCode Failure key on the return deep link
errorReason error Machine-readable reason, when distinct from the key
message error_description Human-readable failure text
Platform Action required
Android intent-filter in AndroidManifest.xml for your return scheme
iOS URL scheme in Info.plist
Both Call spareLink.handleRedirect(uri) in your URI handler
Backend Send channel: 'sdk_flutter' and your return URLs on POST /payment-requests

License #

BSD 3-Clause © Spare Technologies

1
likes
150
points
132
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Flutter SDK for Spare Link open-banking payments. Presents the payment flow, hands bank authorization to a real browser, and reports the payer back through your deep link.

Homepage
Repository (GitHub)
View/report issues

Topics

#payments #open-banking #fintech #sdk

License

BSD-3-Clause (license)

Dependencies

device_info_plus, flutter, flutter_inappwebview, http, logging, shared_preferences, url_launcher, uuid

More

Packages that depend on spare_link