React Native
Add Spare's open banking payment SDK to a React Native or Expo app for the UAE.
The React Native SDK (@spare-technologies/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.
React Native
npm install @spare-technologies/spare-linkAvailable now
This SDK is published on npm (1.0.0). The install command above is live. Pin
an exact version in package.json, 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 in a modal. - The payer selects a bank, checks the payee and gives consent.
- The SDK opens the bank in a browser. It uses
expo-web-browser. - 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 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_react_native' 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 from the Expo plugin |
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 environment values in the SDK |
| The UAE tenant | The payment flow serves the UAE only. Another tenant causes an error |
App
| Item | Reason |
|---|---|
| Expo SDK 52 or later, React Native 0.76.9 or later, React 18.3 or later | These are the minimum versions |
| A custom development client, or a production build | The SDK uses native modules. It does not operate in Expo Go |
| The Expo plugin, or the same native configuration | The return from the bank must open your app |
One SpareLink object and one SpareLinkHost component | The events, the active flow and the recovery use one object |
A call to handleRedirect() for each URI | If you do not do this, the flow stops after the bank |
A pendingSessionStore and a call to resumeIfNeeded() | Without a store, the recovery function always reports persistenceUnavailable. Then you lose a payment that did not complete |
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 app bundle.
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
JSON.stringify. - 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_react_native",
"creditorType": "MERCHANT",
"debtorReference": "debtorRef2",
"creditorReference": "creditorRef",
"creditorAccount": {
"schemeName": "IBAN",
"identification": "10000109010101",
"name": "Mario International"
},
"purpose": "ACM",
"merchantReference": "order12345",
"successRedirectUrl": "myapp://callback",
"failureRedirectUrl": "myapp://callback",
"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_react_native' | The payment completes. But the payer stops at a receipt page in the browser, and the app gets an abandoned result |
You sign the full body and not the request object | The server refuses the signature |
You use JSON.stringify 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 merchantScheme and merchantHost | The return from the bank does not open your app |
| You make the signature in the app | The keys are visible in the bundle |
Setup
1. Install the package
bun add @spare-technologies/spare-link
# or: npm i @spare-technologies/spare-linkThis flow is a new major version of the SDK. Set an exact version in your
package.json.
Then install the peer dependencies with Expo:
npx expo install react-native-webview react-native-safe-area-context react-native-screens \
expo-web-browser expo-linking expo-secure-store expo-font expo-linear-gradientWARNING: Install these packages with expo install. Do not let your package
manager select the versions.
npm and bun install these packages automatically. The SDK declares the versions for the Expo 52 module line. Thus an automatic installation selects a version that operates.
expo install is safer. It reads your Expo SDK and selects the exact version for
it. It also prevents an error if you change your Expo SDK later.
This release operates with Expo 52 only. If your app uses a more recent Expo SDK, the package manager gives a conflict of the peer dependencies. This is an error at the installation. It is not a failure of the build or a failure on the device.
The SDK uses these packages in this way:
expo-web-browseropens the bank.expo-secure-storekeeps the data for the recovery function.- The other packages show the payment flow.
react-native-svgis an optional peer dependency. If it is not available, the SDK shows a different indicator.
The SDK needs no runtime permission. Earlier versions asked for the location of the device. The SDK now reads no data from the device.
2. Configure the Expo plugin
// app.config.ts
plugins: [
[
'@spare-technologies/spare-link/expo-plugin',
{
merchantScheme: 'myapp',
merchantHost: 'callback',
},
],
]The plugin registers the deep link for the return only. On iOS it adds an entry
to CFBundleURLSchemes. On Android it adds an intent-filter for
myapp://callback. The plugin asks for no runtime permissions.
Use the same scheme and host that your backend puts in successRedirectUrl and
failureRedirectUrl.
Then make the development client again. The plugin changes the native configuration.
npx expo prebuild --clean && npx expo run:android # or run:ios3. Make the SDK object
Make one object and one host component. Put both near the root of the app.
import {
SpareLink,
SpareLinkHost,
SecureStorePendingSessionStore,
useSpareLinkDeepLinks,
} from '@spare-technologies/spare-link';
import { useMemo } from 'react';
export default function App() {
const spareLink = useMemo(
() =>
new SpareLink({
environment: 'sandbox',
tenant: 'uae',
pendingSessionStore: new SecureStorePendingSessionStore(),
}),
[],
);
useSpareLinkDeepLinks(spareLink); // step 4
return (
<SpareLinkHost spareLink={spareLink}>
<YourApp spareLink={spareLink} />
</SpareLinkHost>
);
}Use useMemo with an empty array of dependencies. If your app makes a new object
at each render, it loses the active flow and the subscribers of the events.
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
useSpareLinkDeepLinks(spareLink) connects Linking.addEventListener('url', β¦)
and Linking.getInitialURL() to the SDK. If your app has its own router, call the
SDK yourself:
const handled = await spareLink.handleRedirect(url);
if (handled) return; // this URI is a payment returnThe SDK keeps a return that arrives too early. Then start() or
resumeIfNeeded() uses it.
5. Start a payment
async function pay(spareLink: SpareLink, reference: string) {
const session = await spareLink.start({ reference });
const result = await session.result;
switch (result.status) {
case 'success': {
// Confirm with your backend first. Do not fulfil the order here.
const record = await backend.getPaymentRequest(result.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 === 'Processed' || record.status === 'Scheduled') {
await fulfil(result.reference, record.paymentId);
}
break;
}
case 'cancelled':
// The payer stopped. The server rejects the request.
break;
case 'failed':
// result.errorCode always has a value. Show a message. Record the code.
break;
case 'abandoned':
// The result is unknown. The payment can be complete. Ask your backend.
await backend.getPaymentRequest(result.reference);
break;
}
}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.
Disable the pay button during a payment. The spareLink.isFlowActive property
tells you when a payment is active. A second call to start() gives a failed
result with the code concurrent_start.
6. Add the recovery function
Call this function when the app becomes active:
const recovery = await spareLink.resumeIfNeeded();
if (recovery.kind === 'recovered') {
// The return arrived before the flow started. The SDK read the result from it.
} else if (recovery.reason === 'outcomeUnknown' && recovery.reference) {
// A payment was in progress. No return arrived. The payment can be complete.
await backend.getPaymentRequest(recovery.reference);
}The other values of reason need no action: noPendingSession,
flowAlreadyActive and persistenceUnavailable. Without a pendingSessionStore,
the function always gives persistenceUnavailable. Then you get no report of a
payment that did not complete.
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://callback?reference=6G0TcjUlQDE&status=Processed
&paymentId=537f72c3&merchantRef=order12345This is an example of a failure:
myapp://callback?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 | Result 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 | result.status |
|---|---|---|
| Settled | Processed, Scheduled | 'success' |
| Terminal failure | Rejected, Errored | 'failed' |
| In progress | New, Consumed, ConsentRaised | 'abandoned' |
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 the 'success' result 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 'abandoned'. 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 'success' 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 signed 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:
- An
'abandoned'result does not mean that the payment failed. A payer can authorize the payment at the bank and then close the app. 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
environment | 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.
Events
The spareLink.events emitter sends these events: initialized, pageReady,
redirectToBank and bankCallbackReceived. Then it sends one of succeeded,
failed, cancelled or abandoned.
Use the events for analytics only. They cannot control the flow. Always use
session.result for the result.
The SDK does not send an event for each step of the payment. For example, it sends no event when the payer selects a bank or reads the consent. The events show only the steps that the SDK can identify.
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_react_native' -
successRedirectUrlandfailureRedirectUrlagree withmerchantScheme://merchantHost -
GET /payment-requests/{reference}operates, and the fulfilment needs a settled status (ProcessedorScheduled) - The fulfilment is idempotent by
merchantReference
Native
- The Expo plugin is in the configuration, and you made the development client again
- The redirect URL opens your app from a browser
- You do not use Expo Go for the tests
TypeScript
- Your app makes one
SpareLinkobject in auseMemo, and puts oneSpareLinkHostnear the root - Your app has a
pendingSessionStore, and callsresumeIfNeeded()when the app becomes active -
handleRedirectgets each URI - Your app processes the four values of
status, and usesoutcomeUnknown - Your app disables the pay button when
isFlowActiveis true
Identifiers
| Field | Source | Your action |
|---|---|---|
reference | Your backend, from POST /payment-requests | Give it to start(). Read the result with it |
paymentId | The signed redirect only. It is undefined in your app | Record it with the order. Read it from the record instead |
signature (sig) | The signed redirect only. It is undefined 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.
- Install the package. Configure the plugin. Make the development client again.
- Make the object and the host component. Add the deep links and
resumeIfNeeded. - Do a full payment in the sandbox on a device. Include the confirmation from your backend.
- Set an exact version of the SDK in
package.json.