Skip to content

feat: add deepLinkHosts prop to escape known external URLs#241

Open
allen-paystack wants to merge 2 commits into
just1and0:mainfrom
allen-paystack:feat/deep-link-hosts-prop
Open

feat: add deepLinkHosts prop to escape known external URLs#241
allen-paystack wants to merge 2 commits into
just1and0:mainfrom
allen-paystack:feat/deep-link-hosts-prop

Conversation

@allen-paystack
Copy link
Copy Markdown

@allen-paystack allen-paystack commented May 21, 2026

Summary

Adds an opinionated deepLinkHosts prop to PaystackProvider. The provider now intercepts navigations to a curated list of partner deep-link hosts and forwards them to the OS via Linking.openURL, so they trigger iOS universal-link handoff (or custom-scheme dispatch) instead of loading as a web page inside the checkout WebView.

The default ships with joinzap.com already in the list, so every consumer of the library gets the fix without needing to opt in. Integrators can extend, replace, or disable the list via the deepLinkHosts prop.

Why

On iOS, WKWebView does not perform universal-link handoff or custom-scheme dispatch for navigations originated inside the WebView. The only entry points that do are UIApplication.open(_:) (which Linking.openURL calls under the hood), Safari, Mail, Messages, and SFSafariViewController.

Practical consequence: when checkout is opened in react-native-paystack-webview and a payment channel needs to hand off to a partner app — for example the Zap channel, which renders a universal link to https://joinzap.com/... — tapping the link inside the WebView currently loads the URL as a regular web page instead of opening the Zap app. Confirmed on a physical iPhone running iOS 26.x.

This is widely-known WebView behavior (Apple Developer Forums, WWDC 2015 Session 509). The canonical workaround is host-side: intercept the navigation and call UIApplication.open(_:).

What changed

  • PaystackProvider now accepts a deepLinkHosts?: Array<string | RegExp> prop.
  • When the WebView attempts to navigate to a URL matched by any entry in the list, the navigation is cancelled and the URL is forwarded to Linking.openURL(url).
  • Matching: RegExp entries use .test(url); string entries match if the URL starts with the string.
  • A DEFAULT_DEEP_LINK_HOSTS constant is exported, currently containing joinzap.com (and subdomains). It's the prop's default value.
  • Anything not in the list — all Paystack hosts, 3DS / issuer ACS challenge pages, partner redirects required by the checkout flow — passes through to the WebView unchanged.

Examples

Out of the box, no integrator action needed:

<PaystackProvider publicKey={PAYSTACK_PUBLIC_KEY} currency="NGN">
  {children}
</PaystackProvider>
// joinzap.com universal links now hand off to the Zap app via iOS.

Extend the default list with your own partner deep links:

import { PaystackProvider, DEFAULT_DEEP_LINK_HOSTS } from 'react-native-paystack-webview';

<PaystackProvider
  publicKey={PAYSTACK_PUBLIC_KEY}
  deepLinkHosts={[...DEFAULT_DEEP_LINK_HOSTS, /^https?:\/\/my-partner\.app\//, 'mypartner://']}
>
  {children}
</PaystackProvider>

Disable interception entirely:

<PaystackProvider publicKey={PAYSTACK_PUBLIC_KEY} deepLinkHosts={[]}>
  {children}
</PaystackProvider>

Verified

  • iOS — Manually verified on a physical iPhone (iOS 26.x): the Zap channel's "Open Zap" link now hands off to the Zap app via iOS universal link instead of loading inside the checkout WebView.
  • Android — Manually verified on a physical Android device: the same flow now hands off to the Zap app via Android App Links instead of loading inside the checkout WebView. (onShouldStartLoadWithRequest works equivalently on both platforms in react-native-webview, and Linking.openURL routes through Intent.ACTION_VIEW on Android, which respects App Links.)
  • All existing tests pass (yarn test, 8/8).
  • TypeScript build succeeds (yarn build).

Backwards compatibility

This is the one place the patch changes default behavior: navigations to joinzap.com (and any subdomain) that were previously loaded inside the WebView will now open via the OS. That's the intended fix — those navigations were already broken in practice (WKWebView refuses to do universal-link handoff for them). Any integrator that needs to preserve the previous in-WebView behavior can pass deepLinkHosts={[]}.

Notes for reviewers

  • The default list is intentionally narrow. A broader "escape all external URLs" rule would break 3DS challenge pages, bank-redirect flows, and other in-flow navigations that must stay in the WebView.
  • This pairs well with a server-side intermediary page on the deep-link host (e.g. the universal link target rendering a graceful fallback for contexts that can't hand off), but each is useful independently.

Adds an opt-in prop to PaystackProvider that intercepts specific
WebView navigations and hands them to the OS via Linking.openURL.

This is needed because WKWebView on iOS does not perform universal-link
handoff or custom-scheme dispatch for navigations originated inside the
WebView. Integrators using partner deep links (e.g. universal links
pointing to a partner app) currently see those links load as web pages
inside the checkout, instead of opening the target app.

Default is an empty array, so behavior is unchanged for existing users.
Allowlist entries can be RegExp or string-prefix matchers. Paystack
hosts, 3DS ACS hosts, and other checkout-flow URLs are intentionally
NOT matched by default — those must stay in the WebView.
Make the deep-link interceptor opinionated: every consumer of the
library gets universal-link handoff to the Zap app out of the box,
without needing to pass deepLinkHosts explicitly.

Exports DEFAULT_DEEP_LINK_HOSTS so integrators can extend, override, or
disable the curated list (pass [] to turn off interception).

The list is intentionally narrow — currently joinzap.com only — to
avoid escaping URLs that the checkout flow needs to load in-WebView
(3DS ACS challenge pages, issuer redirects, Paystack hosts).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant