@loopbitstudio/payment-sdk
v0.1.0
Published
React Native payment SDK — UPI payment orchestration with backend-authoritative verification
Readme
@loopbitstudio/payment-sdk
React Native payment SDK for UPI and other gateways. It owns the mobile payment experience — launching the payment app, handling the callback, managing state, polling, and recovery — while your backend keeps payment authority and every secret.
const result = await payment.start({ orderId: 'ORD-10001' });Design rule
The SDK never decides whether a payment succeeded. A payment app's reported
outcome is treated as a hint; the backend's status is the verdict. This is not
defensiveness for its own sake — UPI apps are known to report CANCELLED for
payments that in fact settled.
The package contains no gateway secrets, merchant keys, webhook secrets, or verification logic. Those live in your backend.
Installation
npm install @loopbitstudio/payment-sdk
cd ios && pod installRequires React Native 0.76+ with the New Architecture enabled (it ships as a Turbo Native Module).
Android
The library declares the <queries> element it needs for Android 11+ package
visibility. Add an intent filter to your MainActivity for the callback deep
link, and keep launchMode="singleTask" so the callback reaches the running
task rather than launching a second copy:
<activity android:name=".MainActivity" android:launchMode="singleTask" ...>
<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" />
</intent-filter>
</activity>iOS
Declare your callback scheme under CFBundleURLTypes, and list every payment
app scheme you intend to probe under LSApplicationQueriesSchemes — canOpenURL
returns false for anything not listed, which makes installed apps look absent:
<key>LSApplicationQueriesSchemes</key>
<array>
<string>upi</string>
<string>phonepe</string>
<string>gpay</string>
<string>paytmmp</string>
<string>bhim</string>
</array>Usage
Imperative
import { PaymentSDK } from '@loopbitstudio/payment-sdk';
const payment = new PaymentSDK({
apiBaseUrl: 'https://api.example.com',
getAuthToken: async () => authToken,
callbackUrl: 'myapp://payment/callback',
});
const result = await payment.start({ orderId: 'ORD-10001' });
if (result.status === 'SUCCESS') {
// Navigate to the order
}React hook
import { PaymentProvider, usePayment } from '@loopbitstudio/payment-sdk';
function Root() {
return (
<PaymentProvider config={{ apiBaseUrl, getAuthToken }}>
<Checkout />
</PaymentProvider>
);
}
function Checkout() {
const { startPayment, isProcessing } = usePayment();
return (
<Button
title="Pay now"
disabled={isProcessing}
onPress={() => startPayment({ orderId: 'ORD-10001' })}
/>
);
}Configuration
| Option | Type | Default | Notes |
|---|---|---|---|
| apiBaseUrl | string | — | Required. Your payment API. |
| getAuthToken | () => Promise<string> | — | Required. Called per request; not cached. |
| timeout | number | 20000 | Per-request timeout, ms. |
| callbackUrl | string | — | Deep link the payment app returns to. |
| paymentAppTimeout | number | 300000 | How long to wait for the user to return. |
| polling.enabled | boolean | true | Poll while the backend reports PENDING. |
| polling.interval | number | 3000 | Delay between attempts, ms. |
| polling.maxAttempts | number | 5 | Then settle as PENDING. |
| polling.backoffFactor | number | 1 | Multiplier applied after each attempt. |
| storage | PaymentStorageAdapter | in-memory | Pass AsyncStorage/MMKV in production — the default does not survive a restart, so crash recovery cannot work. |
| providers | Record<string, PaymentProvider> | — | Extra gateways. |
| debug | boolean | false | Verbose logging. |
Payment states
state reports where the flow is; isProcessing is what a pay button should
disable on. The terminal states are exactly the values a PaymentResult.status
can take.
IDLE → CREATING_PAYMENT → PAYMENT_CREATED → OPENING_PAYMENT_APP
→ PAYMENT_APP_OPENED → RETURNED_FROM_PAYMENT_APP → VERIFYING
→ SUCCESS | FAILED | POLLING → SUCCESS | FAILED | PENDING | TIMEOUTPENDING as a result means the SDK stopped waiting while the payment was still
genuinely in flight — not that it failed. Reconcile it from your backend.
Backend contract
POST /api/payments
{ "orderId": "ORD-10001", "paymentMethod": "UPI" }{
"paymentId": "PAY-10001",
"orderId": "ORD-10001",
"status": "PENDING",
"provider": "UPI",
"paymentData": { "upiUri": "upi://pay?pa=..." }
}GET /api/payments/:paymentId/status
{ "paymentId": "PAY-10001", "orderId": "ORD-10001", "status": "SUCCESS" }provider selects the registered provider; paymentData is opaque to the SDK
core and handed straight to it. The UPI provider needs paymentData.upiUri.
Providers
Built in: UPI (deep link / Android Intent) and GENERIC (opens a URL).
Register more without touching the core:
new PaymentSDK({
apiBaseUrl,
getAuthToken,
providers: { RAZORPAY: new RazorpayProvider() },
});A provider implements launch(), optional initialize(), and
handleCallback(). launch() returns a hint when the platform reports the
outcome directly (Android activity results) or null when it does not (iOS),
in which case the SDK waits for the deep link and then verifies.
Crash recovery
Call once on startup, with a persistent storage adapter configured:
const recovered = await payment.recoverPendingPayment();If the app was killed mid-payment, the stored session is re-verified against the backend rather than left stranded.
Error handling
Every failure is a PaymentError with a code:
NETWORK_ERROR · PAYMENT_CREATION_FAILED · PAYMENT_APP_NOT_FOUND ·
PAYMENT_CANCELLED · PAYMENT_FAILED · PAYMENT_TIMEOUT ·
VERIFICATION_FAILED · INVALID_STATE · INVALID_CONFIG ·
UNSUPPORTED_PROVIDER · PAYMENT_IN_PROGRESS · UNAUTHORIZED ·
UNKNOWN_ERROR
try {
await payment.start({ orderId });
} catch (error) {
if (PaymentError.is(error) && error.code === PaymentErrorCode.PAYMENT_APP_NOT_FOUND) {
// Prompt the user to install a UPI app
}
}Development
yarn # install
yarn test # unit tests
yarn typecheck
yarn lint
yarn prepare # build to lib/
yarn mock # mock payment backend on :4000
yarn example androidThe mock backend implements the contract above so the example app runs without a
real gateway. Order ids drive its behaviour: ORD-INSTANT-* succeeds
immediately, ORD-FAIL-* fails, ORD-STUCK-* never settles, anything else goes
PENDING twice then SUCCESS.
Docs
- docs/installation-guide.md — install and configure in an app
- docs/requirement.md — requirements
- docs/plan.md — build plan and roadmap
License
MIT
