npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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

Consumer (e.g. QTM) then installs it like any other package:

yarn add @inno-isw/react-native-rtp
# or: npm install @inno-isw/react-native-rtp

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

payer is the party being asked to pay (your customer); payee is the party that receives the money (the merchant).

payee.bankCode is 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 rtpId arriving via foreground and background (or a double tap) emits request exactly once.
  • Expiry fires on its own — a timer is armed at expiresAt, so a request that lapses while the sheet is open transitions to EXPIRED in place (a state + result event) instead of waiting for the next user action.
  • Expiry re-checked on every transition — accept/decline/retrieve refuse an already-lapsed request and report EXPIRED.
  • Backend status is never assumed — an unrecognised status from accept/retrieve fails closed (FAILED / BACKEND_REJECTED) rather than being reported as an approved payment.
  • Retrieve failure is contained — a failed retrieve emits FAILED with RETRIEVE_FAILED; the request is not silently dropped.
  • Recipient mismatch — if payload.recipient doesn't match getCurrentUser(), the request fails with RECIPIENT_MISMATCH instead of being shown.
  • Logged-out → login resume — a request received while logged out sits in AWAITING_AUTH; when isAuthenticated flips true the provider calls resumeAuth() and emits request.

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; accept is 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 browser

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