diff --git a/README.md b/README.md
index 5593dcd..c5fc078 100644
--- a/README.md
+++ b/README.md
@@ -89,6 +89,7 @@ const Checkout = () => {
variable_name: 'order_id',
value: 'OID1234'
}
+
]
},
onSuccess: (res) => console.log('Success:', res),
@@ -124,6 +125,7 @@ const Checkout = () => {
| `publicKey` | `string` | โ | Your Paystack public key |
| `currency` | `string` | โ | Currency code (optional) |
| `defaultChannels` | `string[]`| `['card']`| Payment channels |
+| `deepLinkHosts` | `(string \| RegExp)[]` | `[]` | Extra hosts to hand off to the OS instead of loading in the WebView (see [Deep linking](#-deep-linking)) |
| `debug` | `boolean` | `false` | Show debug logs |
| `onGlobalSuccess` | `func` | โ | Called on all successful transactions |
| `onGlobalCancel` | `func` | โ | Called on all cancelled transactions |
@@ -185,6 +187,45 @@ const Checkout = () => {
---
+## ๐ Deep linking
+
+Some payment channels need to hand off to a partner app. For example, the **Zap** channel renders a universal link to `https://joinzap.com/app/...` that should open the Zap app.
+
+WKWebView on iOS does **not** perform universal-link handoff or custom-scheme dispatch for navigations that originate inside the WebView, so tapping such a link would otherwise just load it as a web page. To fix this, the provider intercepts these navigations and forwards them to the OS via `Linking.openURL`, which triggers the universal-link handoff (iOS) or App Links / `Intent.ACTION_VIEW` dispatch (Android).
+
+`https://joinzap.com/app/` ships as a built-in default and **always applies** โ it can't be removed. Use the `deepLinkHosts` prop to add your own partner hosts on top of the defaults:
+
+```tsx
+
+
+
+```
+
+Each entry is matched against the navigation URL. String entries match if the URL **starts with** the string; `RegExp` entries are matched with `RegExp.test(url)`.
+
+> โ ๏ธ Only add hosts that should leave the WebView. Do **not** add 3DS / issuer ACS challenge pages, bank-redirect flows, or `checkout.paystack.com` โ those must stay in the WebView for checkout to complete.
+
+### Returning to your app from Zap
+
+When the customer is sent to the Zap app, you can have Zap return to your app after the action completes by passing a `callback_url` in the transaction `metadata`. Set it to a URL (or deep link) that resolves back to your app:
+
+```tsx
+popup.checkout({
+ email: 'jane.doe@example.com',
+ amount: 5000,
+ metadata: {
+ callback_url: 'https://paystack.com',
+ },
+ onSuccess: (res) => console.log('Success:', res),
+ onCancel: () => console.log('User cancelled'),
+});
+```
+
+---
+
## ๐งช Debugging
Enable `debug={true}` on the `PaystackProvider` to get logs like:
diff --git a/__tests__/index.test.tsx b/__tests__/index.test.tsx
index 969c473..52c30ae 100644
--- a/__tests__/index.test.tsx
+++ b/__tests__/index.test.tsx
@@ -1,8 +1,9 @@
-import { validateParams, sanitize, generatePaystackParams } from '../development/utils';
-import { Alert } from 'react-native';
+import { validateParams, sanitize, generatePaystackParams, shouldHandleExternally, openExternalUrl } from '../development/utils';
+import { Alert, Linking } from 'react-native';
jest.mock('react-native', () => ({
- Alert: { alert: jest.fn() }
+ Alert: { alert: jest.fn() },
+ Linking: { canOpenURL: jest.fn(), openURL: jest.fn() }
}));
describe('Paystack Utils', () => {
@@ -84,4 +85,66 @@ describe('Paystack Utils', () => {
expect(js).toContain("amount: 10000");
});
});
+
+ describe('shouldHandleExternally', () => {
+ it('matches a string host by prefix', () => {
+ expect(
+ shouldHandleExternally('https://joinzap.com/app/abc', ['https://joinzap.com/app/'])
+ ).toBe(true);
+ });
+
+ it('does not match when only part of the URL contains the prefix', () => {
+ expect(
+ shouldHandleExternally('https://evil.com/?u=https://joinzap.com/app/', ['https://joinzap.com/app/'])
+ ).toBe(false);
+ });
+
+ it('matches a RegExp host', () => {
+ expect(
+ shouldHandleExternally('mypartner://pay', [/^mypartner:\/\//])
+ ).toBe(true);
+ });
+
+ it('returns false when no host matches', () => {
+ expect(
+ shouldHandleExternally('https://checkout.paystack.com/123', ['https://joinzap.com/app/'])
+ ).toBe(false);
+ });
+
+ it('returns false for an empty URL', () => {
+ expect(shouldHandleExternally('', ['https://joinzap.com/app/'])).toBe(false);
+ });
+ });
+
+ describe('openExternalUrl', () => {
+ beforeEach(() => {
+ (Linking.canOpenURL as jest.Mock).mockReset();
+ (Linking.openURL as jest.Mock).mockReset();
+ });
+
+ it('opens the URL when an app can handle it', async () => {
+ (Linking.canOpenURL as jest.Mock).mockResolvedValue(true);
+ (Linking.openURL as jest.Mock).mockResolvedValue(undefined);
+
+ await openExternalUrl('https://joinzap.com/app/abc');
+
+ expect(Linking.canOpenURL).toHaveBeenCalledWith('https://joinzap.com/app/abc');
+ expect(Linking.openURL).toHaveBeenCalledWith('https://joinzap.com/app/abc');
+ });
+
+ it('does not open the URL when no app can handle it', async () => {
+ (Linking.canOpenURL as jest.Mock).mockResolvedValue(false);
+
+ await openExternalUrl('mypartner://pay');
+
+ expect(Linking.openURL).not.toHaveBeenCalled();
+ });
+
+ it('swallows errors instead of throwing', async () => {
+ (Linking.canOpenURL as jest.Mock).mockRejectedValue(new Error('boom'));
+
+ await expect(openExternalUrl('https://joinzap.com/app/abc')).resolves.toBeUndefined();
+ expect(Linking.openURL).not.toHaveBeenCalled();
+ });
+ });
});
\ No newline at end of file
diff --git a/development/PaystackProvider.tsx b/development/PaystackProvider.tsx
index c70d88c..6f13feb 100644
--- a/development/PaystackProvider.tsx
+++ b/development/PaystackProvider.tsx
@@ -1,14 +1,18 @@
import React, { createContext, useCallback, useMemo, useState } from 'react';
-import { Modal, ActivityIndicator, } from 'react-native';
+import { Modal, ActivityIndicator } from 'react-native';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
PaystackParams,
PaystackProviderProps,
} from './types';
-import { validateParams, paystackHtmlContent, generatePaystackParams, handlePaystackMessage } from './utils';
+import { validateParams, paystackHtmlContent, generatePaystackParams, handlePaystackMessage, shouldHandleExternally, openExternalUrl } from './utils';
import { styles } from './styles';
+export const DEFAULT_DEEP_LINK_HOSTS: string[] = [
+ 'https://joinzap.com/app/',
+];
+
export const PaystackContext = createContext<{
popup: {
checkout: (params: PaystackParams) => void;
@@ -20,6 +24,7 @@ export const PaystackProvider: React.FC = ({
publicKey,
currency,
defaultChannels = ['card'],
+ deepLinkHosts = [],
debug = false,
children,
onGlobalSuccess,
@@ -31,6 +36,11 @@ export const PaystackProvider: React.FC = ({
const fallbackRef = useMemo(() => `ref_${Date.now()}`, []);
+ const resolvedDeepLinkHosts = useMemo(
+ () => [...DEFAULT_DEEP_LINK_HOSTS, ...deepLinkHosts],
+ [deepLinkHosts]
+ );
+
const open = useCallback(
(params: PaystackParams, selectedMethod: 'checkout' | 'newTransaction') => {
if (debug) console.log(`[Paystack] Opening modal with method: ${selectedMethod}`);
@@ -95,6 +105,15 @@ export const PaystackProvider: React.FC = ({
originWhitelist={["*"]}
source={{ html: paystackHTML }}
onMessage={handleMessage}
+ onShouldStartLoadWithRequest={(request) => {
+ const url = request.url ?? '';
+ if (!shouldHandleExternally(url, resolvedDeepLinkHosts)) {
+ return true;
+ }
+ if (debug) console.log('[Paystack] Opening external/deep link via OS:', url);
+ void openExternalUrl(url, debug);
+ return false;
+ }}
javaScriptEnabled
domStorageEnabled
startInLoadingState
diff --git a/development/types.ts b/development/types.ts
index eff687d..fa3fc73 100644
--- a/development/types.ts
+++ b/development/types.ts
@@ -12,6 +12,12 @@ export type PaystackProviderProps = {
publicKey: string;
currency?: Currency;
defaultChannels?: PaymentChannels;
+ /**
+ * Additional hosts to hand off to the OS instead of loading inside the
+ * checkout WebView. Added to the built-in defaults, which always apply.
+ * See the "Deep linking" section of the README.
+ */
+ deepLinkHosts?: Array;
debug?: boolean;
children: React.ReactNode;
onGlobalSuccess?: (data: PaystackTransactionResponse) => void;
diff --git a/development/utils.ts b/development/utils.ts
index f404c3d..3f3e05d 100644
--- a/development/utils.ts
+++ b/development/utils.ts
@@ -1,5 +1,36 @@
-import { Alert } from 'react-native';
-import { Currency, DynamicMultiSplitProps, PaymentChannels, PaystackParams, PaystackTransactionResponse } from './types';
+import { Alert, Linking } from 'react-native';
+import { Currency, DynamicMultiSplitProps, PaymentChannels, PaystackParams, PaystackTransactionResponse } from './types';
+
+/**
+ * Whether a navigation URL should be handed off to the OS instead of being
+ * loaded inside the checkout WebView. String matchers match by prefix;
+ * RegExp matchers use `.test(url)`.
+ */
+export const shouldHandleExternally = (
+ url: string,
+ hosts: Array
+): boolean =>
+ !!url &&
+ hosts.some((matcher) =>
+ typeof matcher === 'string' ? url.indexOf(matcher) === 0 : matcher.test(url)
+ );
+
+/**
+ * Hand a URL off to the OS. Checks `Linking.canOpenURL` first so we don't
+ * attempt to open a URL no installed app can handle, and swallows any error.
+ */
+export const openExternalUrl = async (url: string, debug = false): Promise => {
+ try {
+ const supported = await Linking.canOpenURL(url);
+ if (!supported) {
+ if (debug) console.log('[Paystack] No app can handle URL:', url);
+ return;
+ }
+ await Linking.openURL(url);
+ } catch (err) {
+ if (debug) console.log('[Paystack] Linking.openURL failed:', err);
+ }
+};
export const validateParams = (params: PaystackParams, debug: boolean): boolean => {
const errors: string[] = [];