@inno-isw/react-native-rtp
v0.3.0
Published
Request to Pay SDK for React Native
Readme
@inno-isw/react-native-rtp
Request to Pay (RTP) SDK for React Native.
@inno-isw/react-native-rtp owns the business logic of a Request to Pay flow — payload
validation, de-duplication, expiry, recipient matching, normalized status, and
the accept / decline / status calls. The host app owns all UI: it renders the
bottom sheet, collects the PIN, and decides navigation.
It is pure JS/TS with zero native code and zero runtime dependencies
(only react / react-native peer deps). Every external effect is injected, so
the same contract can be re-implemented on Kotlin or Swift.
Installation
yarn add @inno-isw/react-native-rtp
# or: npm install @inno-isw/react-native-rtpPeer dependencies: react, react-native.
Publishing (maintainers)
The package is consumed as a normal npm dependency — do not link it locally.
yarn install
yarn prepare # builds lib/ (bob build) via the prepare script
npm publish # publishes @inno-isw/react-native-rtp to the npm registryConsumer (e.g. QTM) then installs it like any other package:
yarn add @inno-isw/react-native-rtp
# or: npm install @inno-isw/react-native-rtpThe portable contract
The SDK never sees a push notification. It sees a decoded RTP payload. Each platform is responsible for mapping its push shape (RTCP, raw FCM, APNs, in-house PN) into this structure.
interface RtpPayload {
version?: string; // schema version; unknown values are ignored
type: 'RTP'; // discriminator
rtpId: string; // stable id — the idempotency key
amount: number; // minor unit (e.g. kobo); integer, no floats
currency: string; // ISO 4217, e.g. "NGN"
payer: { name: string; accountNumber?: string; bank?: string; logoUrl?: string };
payee: { name: string; accountNumber?: string; bank?: string; bankCode?: string; logoUrl?: string };
narration?: string;
transactionRef?: string;
orderNumber?: string; // merchant/issuer order no. (≠ transactionRef)
deepLink?: string; // secure transaction / deep-link reference
actionMessage?: string; // instruction shown to the customer
expiresAt: string; // ISO 8601
recipient?: string; // alias the request is addressed to
[key: string]: unknown; // forward-compatible passthrough
}
payeris the party being asked to pay (your customer);payeeis the party that receives the money (the merchant).
payee.bankCodeis the destination institution code (e.g. a NIP bank code). The host uses it to route the transfer; the SDK passes it through untouched.
Unknown fields are ignored, not rejected, so older SDKs keep working when the backend adds fields.
isRtp(payload)
The type guard operates on the decoded payload, not the notification envelope:
payload.type === 'RTP' && typeof payload.rtpId === 'string';Quick start
1. Create the client (injected ports)
import { createRtpClient } from '@inno-isw/react-native-rtp';
export const rtpClient = createRtpClient({
// (optional) drives the auth gate + recipient matching
getCurrentUser: () => authStore.user?.email ?? null,
// (optional) injectable clock for deterministic expiry
now: () => Date.now(),
// (optional) diagnostics sink
logger: (...args) => console.log('[rtp]', ...args),
});Transport-free by default. With no transport, the client validates,
de-duplicates, expires and tracks the request, but performs no network
calls — accept/decline record the decision locally and retrieve reports
the details already held from the payload. This is the iteration-1 shape: the
host finalises the payment through its own core banking, not an RTP backend.
Inject a transport only when your host has a real RTP service to talk to:
import { api } from './api'; // your existing HTTP client / ky / axios instance
export const rtpClient = createRtpClient({
transport: {
post: (path, body, headers) => api.post(path, { json: body, headers }),
get: (path, headers) => api.get(path, { headers }),
},
basePath: '/rtp', // defaults to "/rtp"
getCurrentUser: () => authStore.user?.email ?? null,
});2. Normalize your push notification → payload
This is the only platform-specific input seam. Use the reference helper, or write your own:
import { mapPushToRtp } from '@inno-isw/react-native-rtp';
// Works with RTCP/FCM shapes, including app_data JSON unwrapping:
const payload = mapPushToRtp(notification);
if (payload) {
rtpClient.receive(payload);
}For a bespoke PN system, pass your own data accessor:
const payload = mapPushToRtp(envelope, (e) => e.customKeys);3. Mount the provider and build your UI
import { RtpProvider, useRtp, RTP_STATUS } from '@inno-isw/react-native-rtp';
import { rtpClient } from './rtpClient';
export default function App() {
const isAuthenticated = useAuthStore((s) => Boolean(s.user));
return (
<RtpProvider
client={rtpClient}
isAuthenticated={isAuthenticated}
storage={persistence} // optional — see "Cold start" below
>
<Dashboard />
</RtpProvider>
);
}Inside your screen, render your own sheet (e.g. QTM's QTBottomSheet):
function Dashboard() {
const { request, status, isReady, isLoading, accept, decline, dismiss, lastResult } =
useRtp();
return (
<>
<YourDashboard />
<QTBottomSheet isOpen={status !== null} onClose={dismiss}>
{isLoading && <ActivityIndicator />}
{request && (
<>
<Text>{request.payee.name}</Text>
<Text>{request.currency} {request.amount}</Text>
<Text>{request.transactionRef}</Text>
</>
)}
{isReady && (
<>
{/* collect PIN yourself, produce an opaque credential, then: */}
<Button title="Accept" onPress={() => accept(credential)} />
<Button title="Decline" onPress={() => decline('USER_DECLINED')} />
</>
)}
{status === RTP_STATUS.ACCEPTED && <Text>Payment accepted</Text>}
{status === RTP_STATUS.FAILED && <Text>Something went wrong</Text>}
{lastResult?.error && <Text>{lastResult.error}</Text>}
</QTBottomSheet>
</>
);
}Status catalog
The SDK computes status; the app only renders it.
| Status | Meaning |
| --- | --- |
| RECEIVED | Payload arrived and passed validation. |
| RETRIEVING | Details are being fetched from the backend (retrieve). |
| AWAITING_AUTH | Valid, but the user is logged out (auth gate). |
| READY | Valid, authenticated, actionable — show Accept / Decline. |
| PROCESSING | Accept/decline in flight. |
| PENDING | Backend accepted the action but a final decision is still pending. |
| ACCEPTED | Backend confirmed acceptance. |
| DECLINED | Backend confirmed (or user initiated) a decline. |
| EXPIRED | Past expiresAt; no longer actionable. |
| FAILED | Terminal failure. See RtpError for the reason. |
ACCEPTED, DECLINED, EXPIRED and FAILED are terminal; PENDING is not.
RtpError values: INVALID_PAYLOAD, RECIPIENT_MISMATCH, RETRIEVE_FAILED,
NETWORK_ERROR, BACKEND_REJECTED, UNKNOWN.
Events
Subscribe via client.on(event, handler). Every call returns an unsubscribe
function.
| Event | Payload | Fires when |
| --- | --- | --- |
| request | RtpRequest | A request becomes actionable (READY), or its details were refreshed. |
| state | { id, status } | Any status transition, including the in-place EXPIRED below. |
| result | RtpResult | A terminal outcome (ACCEPTED/DECLINED/EXPIRED/FAILED). |
const off = rtpClient.on('result', (r) => {
if (r.status === 'ACCEPTED') showToast('Paid');
});
// later
off();Client API
| Method | Description |
| --- | --- |
| isRtp(payload) | Type guard for decoded RTP payloads. |
| receive(payload) | Validate, de-dupe, apply the auth gate/expiry, and emit. Returns an outcome. |
| retrieve(id) | Fetch/refresh authorization details and reconcile the backend status. |
| accept({ id, credential }) | Authorize a request. credential is opaque — the SDK forwards it untouched. |
| decline({ id, reason }) | Decline a request. |
| getStatus(id) | Current normalized status. |
| getRequest(id) | The normalized request. |
| acknowledge(id) | Forget a request (after you dismiss the UI). |
| resumeAuth() | Release requests waiting on the auth gate. Usually called for you by the provider. |
| on(event, cb) | Subscribe; returns an unsubscribe fn. |
The opaque credential
accept() takes a credential that the SDK never inspects — no PIN, no
crypto, no card-service output crosses the boundary as structured data:
const credential = await cardService.generateSecureData(pin); // stays in the app
await accept(credential);This is what makes the portability promise hold: a Kotlin or Swift host produces its own credential type and posts it opaquely.
Edge cases handled for you
- Duplicate delivery — the same
rtpIdarriving via foreground and background (or a double tap) emitsrequestexactly once. - Expiry fires on its own — a timer is armed at
expiresAt, so a request that lapses while the sheet is open transitions toEXPIREDin place (astate+resultevent) instead of waiting for the next user action. - Expiry re-checked on every transition —
accept/decline/retrieverefuse an already-lapsed request and reportEXPIRED. - Backend status is never assumed — an unrecognised status from
accept/retrievefails closed (FAILED/BACKEND_REJECTED) rather than being reported as an approved payment. - Retrieve failure is contained — a failed
retrieveemitsFAILEDwithRETRIEVE_FAILED; the request is not silently dropped. - Recipient mismatch — if
payload.recipientdoesn't matchgetCurrentUser(), the request fails withRECIPIENT_MISMATCHinstead of being shown. - Logged-out → login resume — a request received while logged out sits in
AWAITING_AUTH; whenisAuthenticatedflips true the provider callsresumeAuth()and emitsrequest.
Cold start (killed app)
Register your PN handler early (before React mounts) and persist the payload:
// index.js — before App renders
rtcp.onNotificationTapped(async (notification) => {
const payload = mapPushToRtp(notification);
if (payload) await persistence.setItem('rtp.pendingPayload', JSON.stringify(payload));
});RtpProvider drains that key on mount. The storage key is
rtp.pendingPayload (exported as STORAGE_KEY in the example).
export const persistence = {
getItem: (k) => AsyncStorage.getItem(k),
setItem: (k, v) => AsyncStorage.setItem(k, v),
removeItem: (k) => AsyncStorage.removeItem(k),
};Security notes
- Never trust the push body for money movement. It carries an opaque
rtpId/token;acceptis verified server-side. - Recipient matching is enforced by the SDK via
getCurrentUser(), which matters most in the logged-out flow where you have no session to lean on. - Idempotency is keyed by
rtpId, so replays can't double-action a request.
Cross-platform conformance
This package is one of several implementations of the same spec. To keep them
honest, fixtures/conformance.json holds platform-neutral test vectors with a
fixed clock: valid, logged-out, expired, malformed, wrong-recipient,
non-RTP, duplicate, and unknown-field cases.
Every implementation (React Native, Kotlin, Swift) must produce the same outcome for each case. The vectors are the contract; the code is just one realization of it.
If you ever add signed payloads (HMAC), lock the canonical byte string (field order, encoding, key/digest encoding, MAC placement) in these vectors first — otherwise signatures silently diverge between platforms.
Example app
A runnable host app lives in example/. It ships a mock transport
and a control panel to simulate incoming PNs and every edge case:
yarn example start # then press i / a / w
yarn example web # or run it in a browserButtons: deliver valid / expired / duplicate requests, simulate a cold start
(persist + remount provider), toggle authentication, and force accept failures.
The event log shows the request / state / result stream live.
Development
yarn typecheck
yarn lint
yarn test
yarn prepare # build lib/Contributing
License
MIT
Made with create-react-native-library
