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_linkAvailable 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.
- Your backend makes a payment request. The backend gives the
referenceto the app. - Your app calls
SpareLink.start(reference: ...). The SDK shows the payment flow. - The payer selects a bank, checks the payee and gives consent.
- The SDK opens the bank in a browser. It uses a Custom Tab on Android and
SFSafariViewControlleron iOS. - Your app sends the return deep link to
SpareLink.handleRedirect(). - 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
| Item | Reason |
|---|---|
| Keep the Spare API credentials on your server | The app holds no credentials and does not call Spare |
POST /payment-requests with an x-signature header | It gives the reference for start(). Read Signature of the payload |
channel: 'sdk_flutter' in the request | It selects the return path. Without it, the payer stops at a receipt page in the browser |
successRedirectUrl and failureRedirectUrl in the request | They 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 order | The deep link to your app has no signature. Thus the record is the only proof |
Idempotent fulfilment by merchantReference | A payer can try again. The recovery function can also report the same payment two times |
Spare platform
| Item | Reason |
|---|---|
| Access to the sandbox environment, then to production | These agree with the PaymentEnvironment values in the SDK |
| The UAE tenant | The payment flow serves the UAE only. Another tenant causes an error |
Flutter app
| Item | Reason |
|---|---|
| Flutter 3.22 or later, Dart 3.12 or later | These are the minimum versions |
A deep link in AndroidManifest.xml and in Info.plist | The return from the bank must open your app |
A call to handleRedirect() for each URI | If you do not do this, the flow stops after the bank |
A call to resumeIfNeeded() at each start of the app | It reports a payment that did not complete |
One SpareLink object for each environment and tenant | The events, the active flow and the recovery use one object |
A NavigatorState | start() 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
| Item | Value |
|---|---|
| Algorithm | ES256. This is ECDSA with the P-256 curve and SHA-256 |
| Private key | A PKCS8 PEM key from the Spare dashboard. It starts with -----BEGIN PRIVATE KEY----- |
| Header format | A detached JWS: base64url({"alg":"ES256"})..base64url(signature) |
| HTTP header | x-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.
- Sort the keys of each object into alphabetical order. Do this at each level.
- Remove the fields that are
nullorundefined. - If a top-level value is a number, round it to 5 decimal places. Then remove the zeros at the end. For example,
1000.00becomes"1000", and1000.5becomes"1000.5". - Keep the numbers inside objects as JSON numbers. This is the usual result of
jsonEncode. - 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) // detachedThe 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
| Error | Result |
|---|---|
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 object | The server refuses the signature |
You use jsonEncode and do not sort the keys | The server refuses some signatures |
| You put the payload in the JWS | The server refuses the signature |
| The redirect URLs do not agree with the scheme of the app | The return from the bank does not open your app |
| You make the signature in the Flutter app | The keys are visible in the binary file |
Setup
1. Add the package
dependencies:
spare_link: ^1.0.0flutter pub add spare_linkThis 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.3Remove the override when version 6.2.0 of flutter_inappwebview becomes stable.
2. Configure the deep links
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.
4. Send the deep links to the SDK
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 return deep link
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=order12345This is an example of a failure:
myapp://payment-return?reference=6381GLdXnPQ&errorCode=SP500&status=Rejected
&merchantRef=order12345The 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.
| Parameter | Dart field | Function |
|---|---|---|
reference | reference | The 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 |
errorCode | errorCode | The failure code. It is on each failure and on no success. Read The failure code |
paymentId | paymentId | The Spare identifier of the payment. It is on the success of a single payment |
mandateId | mandateId | The Spare identifier of the mandate. It is on the success of a mandate. A return has this or paymentId, never both |
merchantRef | merchantRef | Your own reference, if the request recorded one |
sig | signature | An 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.
| Group | Values | PaymentResult |
|---|---|---|
| Settled | Processed, Scheduled | PaymentSuccess |
| Terminal failure | Rejected, Errored | PaymentFailed |
| In progress | New, Consumed, ConsentRaised | PaymentAbandoned |
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:
PaymentAbandoneddoes not mean that the payment failed. A payer can authorize the payment at the bank and then close the app.NoRecoveryReason.outcomeUnknownis 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.
| Code | Meaning | Result for the payer |
|---|---|---|
page_unavailable | The SDK could not open the payment flow, or the flow did not become usable | The payer saw a message and a retry button. Then the payer stopped. The SDK started no payment |
page_lost | The payment flow worked and then failed | The flow stopped before the bank |
redirect_failed | The SDK could not open the bank URL in a browser | A consent exists, but no browser opened |
authorize_failed | The return reports a failure with no error key | The payer came back from the bank with no success |
concurrent_start | Your app called start() during an active payment | Nothing. 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
PaymentEnvironment | Host |
|---|---|
sandbox | https://api.sandbox.tryspare.ae |
production | https://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-signatureheader has a canonical JSON and ES256 detached JWS signature of therequestobject - Each request has
channel: 'sdk_flutter' -
successRedirectUrlandfailureRedirectUrlagree with the scheme of the app -
GET /payment-requests/{reference}operates, and the fulfilment needs a settled status (ProcessedorScheduled) - The fulfilment is idempotent by
merchantReference
Native
- The Android
intent-filterand the iOS URL scheme agree with the redirect URLs - The redirect URL opens your app from a browser
Dart
- Your app makes one
SpareLinkobject for each environment and tenant -
handleRedirectgets each URI -
resumeIfNeededoperates at each start of the app, and your app usesoutcomeUnknown - Your app processes the four
PaymentResulttypes - Your app disables the pay button during a payment
Identifiers
| Field | Source | Your action |
|---|---|---|
reference | Your backend, from POST /payment-requests | Give it to start(). Read the result with it |
paymentId | The return, on the success of a single payment | Record it with the order |
mandateId | The return, on the success of a mandate | Record it with the order. A return has this or paymentId, never both |
signature (sig) | The https redirect only. It is null in your app | Verify it on your backend with the Spare public key |
merchantRef | Your own order reference | Use it for an idempotent fulfilment |
Sequence of the integration
- Make and read payment requests in the sandbox from your backend. Confirm that the signature operates.
- Add the package to the app. Register the deep links. Make the
SpareLinkobject. - Add
handleRedirectandresumeIfNeeded. - Do a full payment in the sandbox on a device. Include the confirmation from your backend.
- Set an exact version of the SDK in
pubspec.yaml.