spare_link
Flutter SDK for Spare Link — open-banking payments for iOS and Android.
How it works
- Your backend creates the payment request (
POST /payment-requests) withchannel: 'sdk_flutter'and the return URLs your app is registered for, then gives the app theinternalReferenceit returns. - The SDK presents the payment flow for that reference. The payer selects a bank, checks the payee and gives consent.
- 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.
- The bank returns, Spare posts the payment, and the payer is deep-linked back into your app.
- 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
PaymentResulttype for exhaustive handling Stream<SpareLinkEvent>for analyticsSpareLink.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.
Handle the deep-link return
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:
PaymentAbandoneddoes 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.outcomeUnknownis 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.
- Call
spareLink.start(navigator:, reference:)from any widget with aNavigatorState. - Forward return deep links to
spareLink.handleRedirect(uri). - 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 |
Deep-link requirements summary
| 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
Libraries
- spare_link
- The spare_link Flutter SDK — open-banking payments for Spare Link.